diff --git a/src/Contract/ContractParser.php b/src/Contract/ContractParser.php index f98a969..60b891a 100644 --- a/src/Contract/ContractParser.php +++ b/src/Contract/ContractParser.php @@ -49,13 +49,21 @@ final class ContractParser private static array $magicMethodCache = []; /** - * Resets the contract and property caches. Useful for test isolation or config changes. + * In-memory cache for class-level templates and aliases per class name. + * + * @var array, aliases: array}> + */ + private static array $classLevelDocCache = []; + + /** + * Resets the contract, property, and class-level docblock caches. */ public static function reset(): void { self::$cache = []; self::$propertyCache = []; self::$magicMethodCache = []; + self::$classLevelDocCache = []; DocblockExtractor::reset(); FileFilter::reset(); TypeValidatorRegistry::reset(); @@ -517,6 +525,7 @@ private static function parseFunction(\ReflectionFunction $ref): array /** * Resolves class-level docblocks (templates and aliases) up the class inheritance chain. + * Memoizes results in $classLevelDocCache per class name for O(1) performance across method calls. * * @param \ReflectionClass $declaringClass * @param array $templates @@ -524,6 +533,15 @@ private static function parseFunction(\ReflectionFunction $ref): array */ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, array &$templates, array &$aliases): void { + $className = $declaringClass->getName(); + if (isset(self::$classLevelDocCache[$className])) { + $cached = self::$classLevelDocCache[$className]; + $templates = $cached['templates']; + $aliases = $cached['aliases']; + + return; + } + $classHierarchy = HierarchyResolver::getClassHierarchy($declaringClass); foreach ($classHierarchy as $hierClass) { @@ -544,6 +562,11 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar DocblockExtractor::extractAliases($classPhpDocNode, $aliases, $hierClass); } } + + self::$classLevelDocCache[$className] = [ + 'templates' => $templates, + 'aliases' => $aliases, + ]; } /** @@ -819,4 +842,4 @@ public static function substituteAliases(TypeNode $node, array $aliases): TypeNo return $node; } -} +} \ No newline at end of file diff --git a/src/Internal/DocblockNormalizer.php b/src/Internal/DocblockNormalizer.php index 602d75a..1dc5583 100644 --- a/src/Internal/DocblockNormalizer.php +++ b/src/Internal/DocblockNormalizer.php @@ -5,10 +5,10 @@ namespace TypePHP\Internal; /** - * @internal - * * Normalizes PHPDoc comment strings before AST parsing. * Converts class-specific shapes like stdClass{id: int} into intersection shapes (stdClass & object{id: int}). + * + * @internal */ final class DocblockNormalizer { @@ -25,12 +25,21 @@ final class DocblockNormalizer /** * Normalizes custom class shapes inside docblock text into PHPStan-compatible intersection shapes. + * Uses fast string guards to bypass regex execution when special patterns are absent. */ public static function normalize(string $doc): string { - $doc = preg_replace('/(@(?:phpstan|psalm)-type\s+[a-zA-Z0-9_\x80-\xff]+)\s*=\s*/', '$1 ', $doc) ?? $doc; - $doc = preg_replace('/(callable|Closure)\s*\(([^)]*)\)(?!\s*:)/', '$1($2): mixed', $doc) ?? $doc; - $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; + if (str_contains($doc, '-type') && str_contains($doc, '=')) { + $doc = preg_replace('/(@(?:phpstan|psalm)-type\s+[a-zA-Z0-9_\x80-\xff]+)\s*=\s*/', '$1 ', $doc) ?? $doc; + } + + if (str_contains($doc, 'callable') || str_contains($doc, 'Closure')) { + $doc = preg_replace('/(callable|Closure)\s*\(([^)]*)\)(?!\s*:)/', '$1($2): mixed', $doc) ?? $doc; + } + + if (str_contains($doc, '::') && str_contains($doc, ':')) { + $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; + } if (! str_contains($doc, '{')) { return $doc; @@ -52,4 +61,4 @@ function (array $matches): string { $doc ) ?? $doc; } -} +} \ No newline at end of file diff --git a/src/Internal/StreamWrapper.php b/src/Internal/StreamWrapper.php index 609cc38..b7c16e7 100644 --- a/src/Internal/StreamWrapper.php +++ b/src/Internal/StreamWrapper.php @@ -714,4 +714,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); } -} +} \ No newline at end of file diff --git a/tests/Internal/DocblockNormalizerTest.php b/tests/Internal/DocblockNormalizerTest.php index 4bd15e8..99d62fc 100644 --- a/tests/Internal/DocblockNormalizerTest.php +++ b/tests/Internal/DocblockNormalizerTest.php @@ -5,125 +5,147 @@ use TypePHP\Internal\DocblockNormalizer; describe('DocblockNormalizer', function () { - test('returns docblock string unchanged when no curly braces are present', function () { + test('returns docblock string unchanged when no special keywords or curly braces are present', function () { $doc = '/** @param string $name */'; expect(DocblockNormalizer::normalize($doc))->toBe($doc); }); - test('auto-completes omitted return types for callable and Closure signatures', function () { - $doc1 = '/** @var callable(int[] $items) $callback */'; - $expected1 = '/** @var callable(int[] $items): mixed $callback */'; - expect(DocblockNormalizer::normalize($doc1))->toBe($expected1); + describe('Fast-Path String Guards', function () { + test('bypasses type alias regex when equals sign is absent', function () { + $doc = '/** @phpstan-type MetricTypeValues "histogram"|"gauge" */'; + expect(DocblockNormalizer::normalize($doc))->toBe($doc); + }); - $doc2 = '/** @param Closure(string $name) $closure */'; - $expected2 = '/** @param Closure(string $name): mixed $closure */'; - expect(DocblockNormalizer::normalize($doc2))->toBe($expected2); + test('bypasses callable regex when colon return type is already defined', function () { + $doc1 = '/** @param callable(int): string $cb */'; + expect(DocblockNormalizer::normalize($doc1))->toBe($doc1); - $doc3 = '/** @param callable() $emptyCallable */'; - $expected3 = '/** @param callable(): mixed $emptyCallable */'; - expect(DocblockNormalizer::normalize($doc3))->toBe($expected3); - }); + $doc2 = '/** @param Closure(int, string): bool $closure */'; + expect(DocblockNormalizer::normalize($doc2))->toBe($doc2); + }); - test('preserves existing return types on callable signatures untouched', function () { - $doc1 = '/** @param callable(int): string $cb */'; - expect(DocblockNormalizer::normalize($doc1))->toBe($doc1); + test('bypasses class constant regex when double colons or single colons are absent', function () { + $doc1 = '/** @param string $class User::class */'; + expect(DocblockNormalizer::normalize($doc1))->toBe($doc1); - $doc2 = '/** @param Closure(int, string): bool $closure */'; - expect(DocblockNormalizer::normalize($doc2))->toBe($doc2); + $doc2 = '/** @param array{id: int} $data */'; + expect(DocblockNormalizer::normalize($doc2))->toBe($doc2); + }); }); - test('strips optional equals sign from @phpstan-type and @psalm-type tags', function () { - $doc1 = '/** @phpstan-type MetricTypeValues = "histogram"|"gauge" */'; - $expected1 = '/** @phpstan-type MetricTypeValues "histogram"|"gauge" */'; - expect(DocblockNormalizer::normalize($doc1))->toBe($expected1); + describe('Callable and Closure Return Type Normalization', function () { + test('auto-completes omitted return types for callable and Closure signatures', function () { + $doc1 = '/** @var callable(int[] $items) $callback */'; + $expected1 = '/** @var callable(int[] $items): mixed $callback */'; + expect(DocblockNormalizer::normalize($doc1))->toBe($expected1); + + $doc2 = '/** @param Closure(string $name) $closure */'; + $expected2 = '/** @param Closure(string $name): mixed $closure */'; + expect(DocblockNormalizer::normalize($doc2))->toBe($expected2); - $doc2 = '/** @psalm-type UserRole = "admin"|"user" */'; - $expected2 = '/** @psalm-type UserRole "admin"|"user" */'; - expect(DocblockNormalizer::normalize($doc2))->toBe($expected2); + $doc3 = '/** @param callable() $emptyCallable */'; + $expected3 = '/** @param callable(): mixed $emptyCallable */'; + expect(DocblockNormalizer::normalize($doc3))->toBe($expected3); + }); }); - test('converts stdClass shapes into intersection shapes', function () { - $doc = '/** @param stdClass{id: int, name: string} $data */'; - $expected = '/** @param (stdClass&object{id: int, name: string}) $data */'; + describe('Type Alias Normalization (@phpstan-type and @psalm-type)', function () { + test('strips optional equals sign from @phpstan-type and @psalm-type tags', function () { + $doc1 = '/** @phpstan-type MetricTypeValues = "histogram"|"gauge" */'; + $expected1 = '/** @phpstan-type MetricTypeValues "histogram"|"gauge" */'; + expect(DocblockNormalizer::normalize($doc1))->toBe($expected1); - expect(DocblockNormalizer::normalize($doc))->toBe($expected); + $doc2 = '/** @psalm-type UserRole = "admin"|"user" */'; + $expected2 = '/** @psalm-type UserRole "admin"|"user" */'; + expect(DocblockNormalizer::normalize($doc2))->toBe($expected2); + }); }); - test('converts namespaced class shapes into intersection shapes', function () { - $doc1 = '/** @param \stdClass{id: int} $data */'; - $expected1 = '/** @param (\stdClass&object{id: int}) $data */'; - expect(DocblockNormalizer::normalize($doc1))->toBe($expected1); + describe('Class Constant Keys in Array Shapes', function () { + test('wraps class constant array shape keys in quotes for parser compatibility', function () { + $doc = '/** @param array{self::KEY_ID: int, App\Constants::ROLE: string, Config::OPTIONAL?: bool} $payload */'; + $expected = '/** @param array{"self::KEY_ID": int, "App\Constants::ROLE": string, "Config::OPTIONAL"?: bool} $payload */'; - $doc2 = '/** @param App\Models\User{id: positive-int} $user */'; - $expected2 = '/** @param (App\Models\User&object{id: positive-int}) $user */'; - expect(DocblockNormalizer::normalize($doc2))->toBe($expected2); + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); }); - test('preserves built-in PHPStan shape keywords (array, list, object, non-empty-array, non-empty-list)', function () { - $arrayDoc = '/** @param array{id: int, name: string} $data */'; - expect(DocblockNormalizer::normalize($arrayDoc))->toBe($arrayDoc); + describe('Custom Class Shapes to Intersection Shapes', function () { + test('converts stdClass shapes into intersection shapes', function () { + $doc = '/** @param stdClass{id: int, name: string} $data */'; + $expected = '/** @param (stdClass&object{id: int, name: string}) $data */'; - $listDoc = '/** @param list{int, string} $data */'; - expect(DocblockNormalizer::normalize($listDoc))->toBe($listDoc); + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); - $objectDoc = '/** @param object{id: int} $data */'; - expect(DocblockNormalizer::normalize($objectDoc))->toBe($objectDoc); + test('converts namespaced and leading backslash class shapes into intersection shapes', function () { + $doc1 = '/** @param \stdClass{id: int} $data */'; + $expected1 = '/** @param (\stdClass&object{id: int}) $data */'; + expect(DocblockNormalizer::normalize($doc1))->toBe($expected1); - $nonEmptyArrayDoc = '/** @param non-empty-array{id: int} $data */'; - expect(DocblockNormalizer::normalize($nonEmptyArrayDoc))->toBe($nonEmptyArrayDoc); + $doc2 = '/** @param App\Models\User{id: positive-int} $user */'; + $expected2 = '/** @param (App\Models\User&object{id: positive-int}) $user */'; + expect(DocblockNormalizer::normalize($doc2))->toBe($expected2); + }); - $nonEmptyListDoc = '/** @param non-empty-list{string} $data */'; - expect(DocblockNormalizer::normalize($nonEmptyListDoc))->toBe($nonEmptyListDoc); - }); + test('preserves built-in PHPStan shape keywords (array, list, object, non-empty-array, non-empty-list)', function () { + $arrayDoc = '/** @param array{id: int, name: string} $data */'; + expect(DocblockNormalizer::normalize($arrayDoc))->toBe($arrayDoc); - test('handles case-insensitive built-in keywords', function () { - $upperArrayDoc = '/** @param ARRAY{id: int} $data */'; - expect(DocblockNormalizer::normalize($upperArrayDoc))->toBe($upperArrayDoc); + $listDoc = '/** @param list{int, string} $data */'; + expect(DocblockNormalizer::normalize($listDoc))->toBe($listDoc); - $upperObjectDoc = '/** @param OBJECT{id: int} $data */'; - expect(DocblockNormalizer::normalize($upperObjectDoc))->toBe($upperObjectDoc); - }); + $objectDoc = '/** @param object{id: int} $data */'; + expect(DocblockNormalizer::normalize($objectDoc))->toBe($objectDoc); - test('normalizes custom class shapes inside generic containers and unions', function () { - $nestedDoc = '/** @param list $items */'; - $expected = '/** @param list<(stdClass&object{id: positive-int})> $items */'; + $nonEmptyArrayDoc = '/** @param non-empty-array{id: int} $data */'; + expect(DocblockNormalizer::normalize($nonEmptyArrayDoc))->toBe($nonEmptyArrayDoc); - expect(DocblockNormalizer::normalize($nestedDoc))->toBe($expected); - }); + $nonEmptyListDoc = '/** @param non-empty-list{string} $data */'; + expect(DocblockNormalizer::normalize($nonEmptyListDoc))->toBe($nonEmptyListDoc); + }); - test('normalizes inline @var annotations with class shapes', function () { - $varDoc = '/** @var stdClass{id: int, role: string} $user */'; - $expected = '/** @var (stdClass&object{id: int, role: string}) $user */'; + test('handles case-insensitive built-in keywords', function () { + $upperArrayDoc = '/** @param ARRAY{id: int} $data */'; + expect(DocblockNormalizer::normalize($upperArrayDoc))->toBe($upperArrayDoc); - expect(DocblockNormalizer::normalize($varDoc))->toBe($expected); - }); + $upperObjectDoc = '/** @param OBJECT{id: int} $data */'; + expect(DocblockNormalizer::normalize($upperObjectDoc))->toBe($upperObjectDoc); + }); - test('normalizes class shapes with weird spacing and newlines between name and brace', function () { - $doc = '/** @param stdClass {id: int} $data */'; - $expected = '/** @param (stdClass&object{id: int}) $data */'; + test('normalizes custom class shapes inside generic containers and unions', function () { + $nestedDoc = '/** @param list $items */'; + $expected = '/** @param list<(stdClass&object{id: positive-int})> $items */'; - expect(DocblockNormalizer::normalize($doc))->toBe($expected); - }); + expect(DocblockNormalizer::normalize($nestedDoc))->toBe($expected); + }); - test('normalizes standard multi-line parameter docblocks with class shapes', function () { - $doc = "/**\n * @param stdClass{id: positive-int, name: non-empty-string} \$payload\n */"; - $expected = "/**\n * @param (stdClass&object{id: positive-int, name: non-empty-string}) \$payload\n */"; + test('normalizes inline @var annotations with class shapes', function () { + $varDoc = '/** @var stdClass{id: int, role: string} $user */'; + $expected = '/** @var (stdClass&object{id: int, role: string}) $user */'; - expect(DocblockNormalizer::normalize($doc))->toBe($expected); - }); + expect(DocblockNormalizer::normalize($varDoc))->toBe($expected); + }); - test('normalizes multi-line class shapes with inner newlines and asterisks', function () { - $doc = "/**\n * @param stdClass{\n * id: positive-int,\n * name: non-empty-string\n * } \$data\n */"; - $expected = "/**\n * @param (stdClass&object{\n * id: positive-int,\n * name: non-empty-string\n * }) \$data\n */"; + test('normalizes class shapes with spacing and newlines between name and brace', function () { + $doc = '/** @param stdClass {id: int} $data */'; + $expected = '/** @param (stdClass&object{id: int}) $data */'; - expect(DocblockNormalizer::normalize($doc))->toBe($expected); - }); + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); + + test('normalizes standard multi-line parameter docblocks with class shapes', function () { + $doc = "/**\n * @param stdClass{id: positive-int, name: non-empty-string} \$payload\n */"; + $expected = "/**\n * @param (stdClass&object{id: positive-int, name: non-empty-string}) \$payload\n */"; - test('wraps class constant array shape keys in quotes for legacy phpdoc-parser compatibility', function () { - $doc = '/** @param array{self::KEY_ID: int, App\Constants::ROLE: string} $payload */'; + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); - $expected = '/** @param array{"self::KEY_ID": int, "App\Constants::ROLE": string} $payload */'; + test('normalizes multi-line class shapes with inner newlines and asterisks', function () { + $doc = "/**\n * @param stdClass{\n * id: positive-int,\n * name: non-empty-string\n * } \$data\n */"; + $expected = "/**\n * @param (stdClass&object{\n * id: positive-int,\n * name: non-empty-string\n * }) \$data\n */"; - expect(DocblockNormalizer::normalize($doc))->toBe($expected); + expect(DocblockNormalizer::normalize($doc))->toBe($expected); + }); }); -}); +}); \ No newline at end of file