Skip to content

Commit b31a2cb

Browse files
authored
Optimize ContractParser and DocblockNormalizer for performance; add fast-path string guards and enhance tests for edge cases (#43)
1 parent 4da1c3c commit b31a2cb

4 files changed

Lines changed: 146 additions & 92 deletions

File tree

src/Contract/ContractParser.php

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,13 +49,21 @@ final class ContractParser
4949
private static array $magicMethodCache = [];
5050

5151
/**
52-
* Resets the contract and property caches. Useful for test isolation or config changes.
52+
* In-memory cache for class-level templates and aliases per class name.
53+
*
54+
* @var array<string, array{templates: array<string, TemplateTagValueNode>, aliases: array<string, TypeNode>}>
55+
*/
56+
private static array $classLevelDocCache = [];
57+
58+
/**
59+
* Resets the contract, property, and class-level docblock caches.
5360
*/
5461
public static function reset(): void
5562
{
5663
self::$cache = [];
5764
self::$propertyCache = [];
5865
self::$magicMethodCache = [];
66+
self::$classLevelDocCache = [];
5967
DocblockExtractor::reset();
6068
FileFilter::reset();
6169
TypeValidatorRegistry::reset();
@@ -517,13 +525,23 @@ private static function parseFunction(\ReflectionFunction $ref): array
517525

518526
/**
519527
* Resolves class-level docblocks (templates and aliases) up the class inheritance chain.
528+
* Memoizes results in $classLevelDocCache per class name for O(1) performance across method calls.
520529
*
521530
* @param \ReflectionClass<object> $declaringClass
522531
* @param array<string, TemplateTagValueNode> $templates
523532
* @param array<string, TypeNode> $aliases
524533
*/
525534
private static function parseClassLevelDocs(\ReflectionClass $declaringClass, array &$templates, array &$aliases): void
526535
{
536+
$className = $declaringClass->getName();
537+
if (isset(self::$classLevelDocCache[$className])) {
538+
$cached = self::$classLevelDocCache[$className];
539+
$templates = $cached['templates'];
540+
$aliases = $cached['aliases'];
541+
542+
return;
543+
}
544+
527545
$classHierarchy = HierarchyResolver::getClassHierarchy($declaringClass);
528546

529547
foreach ($classHierarchy as $hierClass) {
@@ -544,6 +562,11 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar
544562
DocblockExtractor::extractAliases($classPhpDocNode, $aliases, $hierClass);
545563
}
546564
}
565+
566+
self::$classLevelDocCache[$className] = [
567+
'templates' => $templates,
568+
'aliases' => $aliases,
569+
];
547570
}
548571

549572
/**
@@ -819,4 +842,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo
819842

820843
return $node;
821844
}
822-
}
845+
}

src/Internal/DocblockNormalizer.php

Lines changed: 15 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,10 @@
55
namespace TypePHP\Internal;
66

77
/**
8-
* @internal
9-
*
108
* Normalizes PHPDoc comment strings before AST parsing.
119
* Converts class-specific shapes like stdClass{id: int} into intersection shapes (stdClass & object{id: int}).
10+
*
11+
* @internal
1212
*/
1313
final class DocblockNormalizer
1414
{
@@ -25,12 +25,21 @@ final class DocblockNormalizer
2525

2626
/**
2727
* Normalizes custom class shapes inside docblock text into PHPStan-compatible intersection shapes.
28+
* Uses fast string guards to bypass regex execution when special patterns are absent.
2829
*/
2930
public static function normalize(string $doc): string
3031
{
31-
$doc = preg_replace('/(@(?:phpstan|psalm)-type\s+[a-zA-Z0-9_\x80-\xff]+)\s*=\s*/', '$1 ', $doc) ?? $doc;
32-
$doc = preg_replace('/(callable|Closure)\s*\(([^)]*)\)(?!\s*:)/', '$1($2): mixed', $doc) ?? $doc;
33-
$doc = preg_replace('/(\\\\?[a-zA-Z_\x80-\xff][\\\\a-zA-Z0-9_\x80-\xff]*::[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*(\??:)/', '"$1"$2', $doc) ?? $doc;
32+
if (str_contains($doc, '-type') && str_contains($doc, '=')) {
33+
$doc = preg_replace('/(@(?:phpstan|psalm)-type\s+[a-zA-Z0-9_\x80-\xff]+)\s*=\s*/', '$1 ', $doc) ?? $doc;
34+
}
35+
36+
if (str_contains($doc, 'callable') || str_contains($doc, 'Closure')) {
37+
$doc = preg_replace('/(callable|Closure)\s*\(([^)]*)\)(?!\s*:)/', '$1($2): mixed', $doc) ?? $doc;
38+
}
39+
40+
if (str_contains($doc, '::') && str_contains($doc, ':')) {
41+
$doc = preg_replace('/(\\\\?[a-zA-Z_\x80-\xff][\\\\a-zA-Z0-9_\x80-\xff]*::[a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)\s*(\??:)/', '"$1"$2', $doc) ?? $doc;
42+
}
3443

3544
if (! str_contains($doc, '{')) {
3645
return $doc;
@@ -52,4 +61,4 @@ function (array $matches): string {
5261
$doc
5362
) ?? $doc;
5463
}
55-
}
64+
}

src/Internal/StreamWrapper.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -714,4 +714,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat
714714

715715
SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs);
716716
}
717-
}
717+
}

tests/Internal/DocblockNormalizerTest.php

Lines changed: 105 additions & 83 deletions
Original file line numberDiff line numberDiff line change
@@ -5,125 +5,147 @@
55
use TypePHP\Internal\DocblockNormalizer;
66

77
describe('DocblockNormalizer', function () {
8-
test('returns docblock string unchanged when no curly braces are present', function () {
8+
test('returns docblock string unchanged when no special keywords or curly braces are present', function () {
99
$doc = '/** @param string $name */';
1010
expect(DocblockNormalizer::normalize($doc))->toBe($doc);
1111
});
1212

13-
test('auto-completes omitted return types for callable and Closure signatures', function () {
14-
$doc1 = '/** @var callable(int[] $items) $callback */';
15-
$expected1 = '/** @var callable(int[] $items): mixed $callback */';
16-
expect(DocblockNormalizer::normalize($doc1))->toBe($expected1);
13+
describe('Fast-Path String Guards', function () {
14+
test('bypasses type alias regex when equals sign is absent', function () {
15+
$doc = '/** @phpstan-type MetricTypeValues "histogram"|"gauge" */';
16+
expect(DocblockNormalizer::normalize($doc))->toBe($doc);
17+
});
1718

18-
$doc2 = '/** @param Closure(string $name) $closure */';
19-
$expected2 = '/** @param Closure(string $name): mixed $closure */';
20-
expect(DocblockNormalizer::normalize($doc2))->toBe($expected2);
19+
test('bypasses callable regex when colon return type is already defined', function () {
20+
$doc1 = '/** @param callable(int): string $cb */';
21+
expect(DocblockNormalizer::normalize($doc1))->toBe($doc1);
2122

22-
$doc3 = '/** @param callable() $emptyCallable */';
23-
$expected3 = '/** @param callable(): mixed $emptyCallable */';
24-
expect(DocblockNormalizer::normalize($doc3))->toBe($expected3);
25-
});
23+
$doc2 = '/** @param Closure(int, string): bool $closure */';
24+
expect(DocblockNormalizer::normalize($doc2))->toBe($doc2);
25+
});
2626

27-
test('preserves existing return types on callable signatures untouched', function () {
28-
$doc1 = '/** @param callable(int): string $cb */';
29-
expect(DocblockNormalizer::normalize($doc1))->toBe($doc1);
27+
test('bypasses class constant regex when double colons or single colons are absent', function () {
28+
$doc1 = '/** @param string $class User::class */';
29+
expect(DocblockNormalizer::normalize($doc1))->toBe($doc1);
3030

31-
$doc2 = '/** @param Closure(int, string): bool $closure */';
32-
expect(DocblockNormalizer::normalize($doc2))->toBe($doc2);
31+
$doc2 = '/** @param array{id: int} $data */';
32+
expect(DocblockNormalizer::normalize($doc2))->toBe($doc2);
33+
});
3334
});
3435

35-
test('strips optional equals sign from @phpstan-type and @psalm-type tags', function () {
36-
$doc1 = '/** @phpstan-type MetricTypeValues = "histogram"|"gauge" */';
37-
$expected1 = '/** @phpstan-type MetricTypeValues "histogram"|"gauge" */';
38-
expect(DocblockNormalizer::normalize($doc1))->toBe($expected1);
36+
describe('Callable and Closure Return Type Normalization', function () {
37+
test('auto-completes omitted return types for callable and Closure signatures', function () {
38+
$doc1 = '/** @var callable(int[] $items) $callback */';
39+
$expected1 = '/** @var callable(int[] $items): mixed $callback */';
40+
expect(DocblockNormalizer::normalize($doc1))->toBe($expected1);
41+
42+
$doc2 = '/** @param Closure(string $name) $closure */';
43+
$expected2 = '/** @param Closure(string $name): mixed $closure */';
44+
expect(DocblockNormalizer::normalize($doc2))->toBe($expected2);
3945

40-
$doc2 = '/** @psalm-type UserRole = "admin"|"user" */';
41-
$expected2 = '/** @psalm-type UserRole "admin"|"user" */';
42-
expect(DocblockNormalizer::normalize($doc2))->toBe($expected2);
46+
$doc3 = '/** @param callable() $emptyCallable */';
47+
$expected3 = '/** @param callable(): mixed $emptyCallable */';
48+
expect(DocblockNormalizer::normalize($doc3))->toBe($expected3);
49+
});
4350
});
4451

45-
test('converts stdClass shapes into intersection shapes', function () {
46-
$doc = '/** @param stdClass{id: int, name: string} $data */';
47-
$expected = '/** @param (stdClass&object{id: int, name: string}) $data */';
52+
describe('Type Alias Normalization (@phpstan-type and @psalm-type)', function () {
53+
test('strips optional equals sign from @phpstan-type and @psalm-type tags', function () {
54+
$doc1 = '/** @phpstan-type MetricTypeValues = "histogram"|"gauge" */';
55+
$expected1 = '/** @phpstan-type MetricTypeValues "histogram"|"gauge" */';
56+
expect(DocblockNormalizer::normalize($doc1))->toBe($expected1);
4857

49-
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
58+
$doc2 = '/** @psalm-type UserRole = "admin"|"user" */';
59+
$expected2 = '/** @psalm-type UserRole "admin"|"user" */';
60+
expect(DocblockNormalizer::normalize($doc2))->toBe($expected2);
61+
});
5062
});
5163

52-
test('converts namespaced class shapes into intersection shapes', function () {
53-
$doc1 = '/** @param \stdClass{id: int} $data */';
54-
$expected1 = '/** @param (\stdClass&object{id: int}) $data */';
55-
expect(DocblockNormalizer::normalize($doc1))->toBe($expected1);
64+
describe('Class Constant Keys in Array Shapes', function () {
65+
test('wraps class constant array shape keys in quotes for parser compatibility', function () {
66+
$doc = '/** @param array{self::KEY_ID: int, App\Constants::ROLE: string, Config::OPTIONAL?: bool} $payload */';
67+
$expected = '/** @param array{"self::KEY_ID": int, "App\Constants::ROLE": string, "Config::OPTIONAL"?: bool} $payload */';
5668

57-
$doc2 = '/** @param App\Models\User{id: positive-int} $user */';
58-
$expected2 = '/** @param (App\Models\User&object{id: positive-int}) $user */';
59-
expect(DocblockNormalizer::normalize($doc2))->toBe($expected2);
69+
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
70+
});
6071
});
6172

62-
test('preserves built-in PHPStan shape keywords (array, list, object, non-empty-array, non-empty-list)', function () {
63-
$arrayDoc = '/** @param array{id: int, name: string} $data */';
64-
expect(DocblockNormalizer::normalize($arrayDoc))->toBe($arrayDoc);
73+
describe('Custom Class Shapes to Intersection Shapes', function () {
74+
test('converts stdClass shapes into intersection shapes', function () {
75+
$doc = '/** @param stdClass{id: int, name: string} $data */';
76+
$expected = '/** @param (stdClass&object{id: int, name: string}) $data */';
6577

66-
$listDoc = '/** @param list{int, string} $data */';
67-
expect(DocblockNormalizer::normalize($listDoc))->toBe($listDoc);
78+
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
79+
});
6880

69-
$objectDoc = '/** @param object{id: int} $data */';
70-
expect(DocblockNormalizer::normalize($objectDoc))->toBe($objectDoc);
81+
test('converts namespaced and leading backslash class shapes into intersection shapes', function () {
82+
$doc1 = '/** @param \stdClass{id: int} $data */';
83+
$expected1 = '/** @param (\stdClass&object{id: int}) $data */';
84+
expect(DocblockNormalizer::normalize($doc1))->toBe($expected1);
7185

72-
$nonEmptyArrayDoc = '/** @param non-empty-array{id: int} $data */';
73-
expect(DocblockNormalizer::normalize($nonEmptyArrayDoc))->toBe($nonEmptyArrayDoc);
86+
$doc2 = '/** @param App\Models\User{id: positive-int} $user */';
87+
$expected2 = '/** @param (App\Models\User&object{id: positive-int}) $user */';
88+
expect(DocblockNormalizer::normalize($doc2))->toBe($expected2);
89+
});
7490

75-
$nonEmptyListDoc = '/** @param non-empty-list{string} $data */';
76-
expect(DocblockNormalizer::normalize($nonEmptyListDoc))->toBe($nonEmptyListDoc);
77-
});
91+
test('preserves built-in PHPStan shape keywords (array, list, object, non-empty-array, non-empty-list)', function () {
92+
$arrayDoc = '/** @param array{id: int, name: string} $data */';
93+
expect(DocblockNormalizer::normalize($arrayDoc))->toBe($arrayDoc);
7894

79-
test('handles case-insensitive built-in keywords', function () {
80-
$upperArrayDoc = '/** @param ARRAY{id: int} $data */';
81-
expect(DocblockNormalizer::normalize($upperArrayDoc))->toBe($upperArrayDoc);
95+
$listDoc = '/** @param list{int, string} $data */';
96+
expect(DocblockNormalizer::normalize($listDoc))->toBe($listDoc);
8297

83-
$upperObjectDoc = '/** @param OBJECT{id: int} $data */';
84-
expect(DocblockNormalizer::normalize($upperObjectDoc))->toBe($upperObjectDoc);
85-
});
98+
$objectDoc = '/** @param object{id: int} $data */';
99+
expect(DocblockNormalizer::normalize($objectDoc))->toBe($objectDoc);
86100

87-
test('normalizes custom class shapes inside generic containers and unions', function () {
88-
$nestedDoc = '/** @param list<stdClass{id: positive-int}> $items */';
89-
$expected = '/** @param list<(stdClass&object{id: positive-int})> $items */';
101+
$nonEmptyArrayDoc = '/** @param non-empty-array{id: int} $data */';
102+
expect(DocblockNormalizer::normalize($nonEmptyArrayDoc))->toBe($nonEmptyArrayDoc);
90103

91-
expect(DocblockNormalizer::normalize($nestedDoc))->toBe($expected);
92-
});
104+
$nonEmptyListDoc = '/** @param non-empty-list{string} $data */';
105+
expect(DocblockNormalizer::normalize($nonEmptyListDoc))->toBe($nonEmptyListDoc);
106+
});
93107

94-
test('normalizes inline @var annotations with class shapes', function () {
95-
$varDoc = '/** @var stdClass{id: int, role: string} $user */';
96-
$expected = '/** @var (stdClass&object{id: int, role: string}) $user */';
108+
test('handles case-insensitive built-in keywords', function () {
109+
$upperArrayDoc = '/** @param ARRAY{id: int} $data */';
110+
expect(DocblockNormalizer::normalize($upperArrayDoc))->toBe($upperArrayDoc);
97111

98-
expect(DocblockNormalizer::normalize($varDoc))->toBe($expected);
99-
});
112+
$upperObjectDoc = '/** @param OBJECT{id: int} $data */';
113+
expect(DocblockNormalizer::normalize($upperObjectDoc))->toBe($upperObjectDoc);
114+
});
100115

101-
test('normalizes class shapes with weird spacing and newlines between name and brace', function () {
102-
$doc = '/** @param stdClass {id: int} $data */';
103-
$expected = '/** @param (stdClass&object{id: int}) $data */';
116+
test('normalizes custom class shapes inside generic containers and unions', function () {
117+
$nestedDoc = '/** @param list<stdClass{id: positive-int}> $items */';
118+
$expected = '/** @param list<(stdClass&object{id: positive-int})> $items */';
104119

105-
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
106-
});
120+
expect(DocblockNormalizer::normalize($nestedDoc))->toBe($expected);
121+
});
107122

108-
test('normalizes standard multi-line parameter docblocks with class shapes', function () {
109-
$doc = "/**\n * @param stdClass{id: positive-int, name: non-empty-string} \$payload\n */";
110-
$expected = "/**\n * @param (stdClass&object{id: positive-int, name: non-empty-string}) \$payload\n */";
123+
test('normalizes inline @var annotations with class shapes', function () {
124+
$varDoc = '/** @var stdClass{id: int, role: string} $user */';
125+
$expected = '/** @var (stdClass&object{id: int, role: string}) $user */';
111126

112-
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
113-
});
127+
expect(DocblockNormalizer::normalize($varDoc))->toBe($expected);
128+
});
114129

115-
test('normalizes multi-line class shapes with inner newlines and asterisks', function () {
116-
$doc = "/**\n * @param stdClass{\n * id: positive-int,\n * name: non-empty-string\n * } \$data\n */";
117-
$expected = "/**\n * @param (stdClass&object{\n * id: positive-int,\n * name: non-empty-string\n * }) \$data\n */";
130+
test('normalizes class shapes with spacing and newlines between name and brace', function () {
131+
$doc = '/** @param stdClass {id: int} $data */';
132+
$expected = '/** @param (stdClass&object{id: int}) $data */';
118133

119-
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
120-
});
134+
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
135+
});
136+
137+
test('normalizes standard multi-line parameter docblocks with class shapes', function () {
138+
$doc = "/**\n * @param stdClass{id: positive-int, name: non-empty-string} \$payload\n */";
139+
$expected = "/**\n * @param (stdClass&object{id: positive-int, name: non-empty-string}) \$payload\n */";
121140

122-
test('wraps class constant array shape keys in quotes for legacy phpdoc-parser compatibility', function () {
123-
$doc = '/** @param array{self::KEY_ID: int, App\Constants::ROLE: string} $payload */';
141+
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
142+
});
124143

125-
$expected = '/** @param array{"self::KEY_ID": int, "App\Constants::ROLE": string} $payload */';
144+
test('normalizes multi-line class shapes with inner newlines and asterisks', function () {
145+
$doc = "/**\n * @param stdClass{\n * id: positive-int,\n * name: non-empty-string\n * } \$data\n */";
146+
$expected = "/**\n * @param (stdClass&object{\n * id: positive-int,\n * name: non-empty-string\n * }) \$data\n */";
126147

127-
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
148+
expect(DocblockNormalizer::normalize($doc))->toBe($expected);
149+
});
128150
});
129-
});
151+
});

0 commit comments

Comments
 (0)