Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 25 additions & 2 deletions src/Contract/ContractParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, array{templates: array<string, TemplateTagValueNode>, aliases: array<string, TypeNode>}>
*/
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();
Expand Down Expand Up @@ -517,13 +525,23 @@ 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<object> $declaringClass
* @param array<string, TemplateTagValueNode> $templates
* @param array<string, TypeNode> $aliases
*/
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) {
Expand All @@ -544,6 +562,11 @@ private static function parseClassLevelDocs(\ReflectionClass $declaringClass, ar
DocblockExtractor::extractAliases($classPhpDocNode, $aliases, $hierClass);
}
}

self::$classLevelDocCache[$className] = [
'templates' => $templates,
'aliases' => $aliases,
];
}

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

return $node;
}
}
}
21 changes: 15 additions & 6 deletions src/Internal/DocblockNormalizer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand All @@ -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;
Expand All @@ -52,4 +61,4 @@ function (array $matches): string {
$doc
) ?? $doc;
}
}
}
2 changes: 1 addition & 1 deletion src/Internal/StreamWrapper.php
Original file line number Diff line number Diff line change
Expand Up @@ -714,4 +714,4 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat

SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs);
}
}
}
188 changes: 105 additions & 83 deletions tests/Internal/DocblockNormalizerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<stdClass{id: positive-int}> $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<stdClass{id: positive-int}> $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);
});
});
});
});
Loading