diff --git a/src/Internal/Generics/TemplateManager.php b/src/Internal/Generics/TemplateManager.php index a41de6c1..37da6810 100644 --- a/src/Internal/Generics/TemplateManager.php +++ b/src/Internal/Generics/TemplateManager.php @@ -877,7 +877,7 @@ private static function computeClassInheritedBindings(string $actualClassName): /** @var array $bindings */ $bindings = []; - if (! class_exists($actualClassName) && ! interface_exists($actualClassName) && ! trait_exists($actualClassName)) { + if (! class_exists($actualClassName, false) && ! class_exists($actualClassName) && ! interface_exists($actualClassName) && ! trait_exists($actualClassName)) { return []; } diff --git a/src/Internal/Io/StreamWrapper.php b/src/Internal/Io/StreamWrapper.php index fd0fb8fb..7a0bdfcc 100644 --- a/src/Internal/Io/StreamWrapper.php +++ b/src/Internal/Io/StreamWrapper.php @@ -825,6 +825,7 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat $namespace = ''; $imports = []; $classTraitUseDocs = []; + $anonymousTraitUseDocs = []; $nodesToScan = $stmts; foreach ($stmts as $stmt) { @@ -872,6 +873,24 @@ private static function extractAndSeedFileMetadata(array $stmts, string $filePat } } - SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs); + $nodeFinder = new \PhpParser\NodeFinder(); + /** @var list<\PhpParser\Node\Stmt\Class_> $anonClasses */ + $anonClasses = $nodeFinder->find($stmts, function (\PhpParser\Node $node): bool { + return $node instanceof \PhpParser\Node\Stmt\Class_ && $node->name === null; + }); + + foreach ($anonClasses as $anonClass) { + $startLine = $anonClass->getStartLine(); + foreach ($anonClass->stmts as $classStmt) { + if ($classStmt instanceof \PhpParser\Node\Stmt\TraitUse) { + $doc = $classStmt->getDocComment(); + if ($doc !== null) { + $anonymousTraitUseDocs[$startLine][] = $doc->getText(); + } + } + } + } + + SpecialTypeResolver::seedFileMetadata($filePath, $namespace, $imports, $classTraitUseDocs, $anonymousTraitUseDocs); } } diff --git a/src/Internal/Resolver/HierarchyResolver.php b/src/Internal/Resolver/HierarchyResolver.php index 3f153e7a..2203e746 100644 --- a/src/Internal/Resolver/HierarchyResolver.php +++ b/src/Internal/Resolver/HierarchyResolver.php @@ -71,7 +71,8 @@ private static function typeExists(string $className): bool return self::$classExistsCache[$className]; } - $exists = class_exists($className) + $exists = class_exists($className, false) + || class_exists($className) || interface_exists($className) || trait_exists($className) || enum_exists($className); diff --git a/src/Internal/Resolver/SpecialTypeResolver.php b/src/Internal/Resolver/SpecialTypeResolver.php index 59085697..f38e4428 100644 --- a/src/Internal/Resolver/SpecialTypeResolver.php +++ b/src/Internal/Resolver/SpecialTypeResolver.php @@ -143,6 +143,13 @@ final class SpecialTypeResolver */ private static array $classTraitUseDocs = []; + /** + * In-memory cache of anonymous class trait use statement docblocks keyed by file and start line. + * + * @var array>> + */ + private static array $anonymousTraitUseDocs = []; + /** * Resets reflection context and dynamic caches. Preserves static file imports. */ @@ -150,6 +157,8 @@ public static function reset(): void { self::$reflectionContextCache = []; self::$fqcnCache = []; + self::$classTraitUseDocs = []; + self::$anonymousTraitUseDocs = []; } /** @@ -198,7 +207,7 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct if ($node instanceof GenericTypeNode) { $genericType = self::resolve($node->type, $context, $thisObj); - $innerTypes = array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->genericTypes); + $innerTypes = array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->genericTypes); return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -252,11 +261,11 @@ public static function resolve(TypeNode $node, \ReflectionClass|\ReflectionFunct } if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->types)); + return new UnionTypeNode(array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->types)); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn ($t) => self::resolve($t, $context, $thisObj), $node->types)); + return new IntersectionTypeNode(array_map(fn($t) => self::resolve($t, $context, $thisObj), $node->types)); } return $node; @@ -290,7 +299,7 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode if ($node instanceof GenericTypeNode) { $genericType = self::resolveForFile($node->type, $file); - $innerTypes = array_map(fn ($t) => self::resolveForFile($t, $file), $node->genericTypes); + $innerTypes = array_map(fn($t) => self::resolveForFile($t, $file), $node->genericTypes); return new GenericTypeNode( $genericType instanceof IdentifierTypeNode ? $genericType : $node->type, @@ -344,11 +353,11 @@ public static function resolveForFile(TypeNode $node, string $file): TypeNode } if ($node instanceof UnionTypeNode) { - return new UnionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types)); + return new UnionTypeNode(array_map(fn($t) => self::resolveForFile($t, $file), $node->types)); } if ($node instanceof IntersectionTypeNode) { - return new IntersectionTypeNode(array_map(fn ($t) => self::resolveForFile($t, $file), $node->types)); + return new IntersectionTypeNode(array_map(fn($t) => self::resolveForFile($t, $file), $node->types)); } return clone $node; @@ -817,9 +826,15 @@ private static function resolveConstantKeyValue(string $fqcn, string $constName) * * @param array $imports * @param array> $classTraitUseDocs + * @param array> $anonymousTraitUseDocs */ - public static function seedFileMetadata(string $fileName, string $namespace, array $imports, array $classTraitUseDocs = []): void - { + public static function seedFileMetadata( + string $fileName, + string $namespace, + array $imports, + array $classTraitUseDocs = [], + array $anonymousTraitUseDocs = [] + ): void { if ($fileName !== '') { $fileName = str_replace('\\', '/', $fileName); self::$fileNamespaces[$fileName] = $namespace; @@ -827,6 +842,9 @@ public static function seedFileMetadata(string $fileName, string $namespace, arr foreach ($classTraitUseDocs as $className => $docs) { self::$classTraitUseDocs[$className] = $docs; } + if ($anonymousTraitUseDocs !== []) { + self::$anonymousTraitUseDocs[$fileName] = $anonymousTraitUseDocs; + } } } @@ -841,17 +859,65 @@ public static function getClassTraitUseDocs(string $className): array return self::$classTraitUseDocs[$className]; } - if (! class_exists($className) && ! trait_exists($className)) { + if (! class_exists($className, false) && ! class_exists($className) && ! trait_exists($className)) { return self::$classTraitUseDocs[$className] = []; } try { $ref = new \ReflectionClass($className); $fileName = $ref->getFileName(); - if ($fileName !== false && file_exists($fileName)) { - $source = file_get_contents($fileName); - if ($source !== false) { - self::parseFileMetadata($fileName, $source); + if ($fileName !== false) { + $normalizedFile = str_replace('\\', '/', $fileName); + + if ($ref->isAnonymous()) { + $startLine = $ref->getStartLine(); + $endLine = $ref->getEndLine(); + + if ($startLine === false || $endLine === false) { + return self::$classTraitUseDocs[$className] = []; + } + + if (! isset(self::$anonymousTraitUseDocs[$normalizedFile]) && file_exists($fileName)) { + $source = file_get_contents($fileName); + if ($source !== false) { + self::parseFileMetadata($fileName, $source); + } + } + + if (isset(self::$anonymousTraitUseDocs[$normalizedFile][$startLine])) { + return self::$classTraitUseDocs[$className] = self::$anonymousTraitUseDocs[$normalizedFile][$startLine]; + } + + if (isset(self::$anonymousTraitUseDocs[$normalizedFile])) { + foreach (self::$anonymousTraitUseDocs[$normalizedFile] as $line => $docs) { + if ($line >= $startLine && $line <= $endLine) { + return self::$classTraitUseDocs[$className] = $docs; + } + } + + $bestMatch = null; + $minDiff = PHP_INT_MAX; + foreach (self::$anonymousTraitUseDocs[$normalizedFile] as $line => $docs) { + $diff = abs($line - $startLine); + if ($diff <= 5 && $diff < $minDiff) { + $minDiff = $diff; + $bestMatch = $docs; + } + } + + if ($bestMatch !== null) { + return self::$classTraitUseDocs[$className] = $bestMatch; + } + } + + return self::$classTraitUseDocs[$className] = []; + } + + if (file_exists($fileName)) { + $source = file_get_contents($fileName); + if ($source !== false) { + self::parseFileMetadata($fileName, $source); + } } } } catch (\Throwable $e) { @@ -1079,15 +1145,22 @@ public static function isBuiltInTypeKeyword(string $name): bool */ public static function parseFileMetadata(string $fileName, string $source): void { - self::$fileNamespaces[$fileName] = ''; - self::$fileUseImports[$fileName] = []; + $normalizedFile = str_replace('\\', '/', $fileName); + self::$fileNamespaces[$normalizedFile] = ''; + self::$fileUseImports[$normalizedFile] = []; + self::$anonymousTraitUseDocs[$normalizedFile] = []; try { $tokens = \PhpToken::tokenize($source); $count = \count($tokens); $namespace = ''; $imports = []; - $currentClass = null; + + /** @var list $classStack */ + $classStack = []; + /** @var array{type: 'named'|'anon', name?: string, line?: int}|null $pendingClass */ + $pendingClass = null; + $braceDepth = 0; for ($i = 0; $i < $count; $i++) { $token = $tokens[$i]; @@ -1105,35 +1178,115 @@ public static function parseFileMetadata(string $fileName, string $source): void } } $namespace = trim(implode('', $nsParts)); - self::$fileNamespaces[$fileName] = $namespace; + self::$fileNamespaces[$normalizedFile] = $namespace; continue; } - if (($token->id === T_CLASS || $token->id === T_INTERFACE || $token->id === T_TRAIT || (\defined('T_ENUM') && $token->id === T_ENUM)) && isset($tokens[$i + 2]) && $tokens[$i + 2]->id === T_STRING) { - $className = $tokens[$i + 2]->text; - $currentClass = $namespace !== '' ? $namespace . '\\' . $className : $className; - self::$classTraitUseDocs[$currentClass] = []; + $isClassLike = $token->id === T_CLASS || $token->id === T_INTERFACE || $token->id === T_TRAIT || (\defined('T_ENUM') && $token->id === T_ENUM); + + if ($isClassLike) { + $isClassConst = false; + for ($prev = $i - 1; $prev >= 0; $prev--) { + if ($tokens[$prev]->id === T_WHITESPACE || $tokens[$prev]->id === T_COMMENT) { + continue; + } + if ($tokens[$prev]->id === T_DOUBLE_COLON) { + $isClassConst = true; + } + + break; + } + + if ($isClassConst) { + continue; + } + + $nameToken = null; + for ($j = $i + 1; $j < $count; $j++) { + if ($tokens[$j]->id === T_WHITESPACE || $tokens[$j]->id === T_COMMENT || $tokens[$j]->id === T_DOC_COMMENT) { + continue; + } + if ($tokens[$j]->id === T_STRING) { + $nameToken = $tokens[$j]; + } + + break; + } + + if ($nameToken !== null) { + $className = $namespace !== '' ? $namespace . '\\' . $nameToken->text : $nameToken->text; + self::$classTraitUseDocs[$className] = []; + $pendingClass = ['type' => 'named', 'name' => $className]; + } elseif ($token->id === T_CLASS) { + $pendingClass = ['type' => 'anon', 'line' => $token->line]; + } continue; } - if ($token->id === T_USE && $currentClass !== null) { - for ($k = $i - 1; $k >= 0; $k--) { - if ($tokens[$k]->id === T_DOC_COMMENT) { - self::$classTraitUseDocs[$currentClass][] = $tokens[$k]->text; + if ($token->text === '{' || $token->id === T_CURLY_OPEN || $token->id === T_DOLLAR_OPEN_CURLY_BRACES) { + $braceDepth++; + if ($pendingClass !== null) { + $classStack[] = [ + 'type' => $pendingClass['type'], + 'name' => $pendingClass['name'] ?? null, + 'line' => $pendingClass['line'] ?? null, + 'depth' => $braceDepth, + ]; + $pendingClass = null; + } - break; + continue; + } + + if ($token->text === '}') { + if ($classStack !== []) { + $top = end($classStack); + if ($braceDepth === $top['depth']) { + array_pop($classStack); } - if ($tokens[$k]->id !== T_WHITESPACE) { - break; + } + $braceDepth--; + + continue; + } + + if ($token->id === T_USE && $classStack !== []) { + $isClosureUse = false; + for ($j = $i + 1; $j < $count; $j++) { + if ($tokens[$j]->id === T_WHITESPACE || $tokens[$j]->id === T_COMMENT) { + continue; + } + if ($tokens[$j]->text === '(') { + $isClosureUse = true; + } + + break; + } + + if (! $isClosureUse) { + for ($k = $i - 1; $k >= 0; $k--) { + if ($tokens[$k]->id === T_DOC_COMMENT) { + $top = end($classStack); + if ($top['type'] === 'named' && isset($top['name'])) { + self::$classTraitUseDocs[$top['name']][] = $tokens[$k]->text; + } elseif ($top['type'] === 'anon' && isset($top['line'])) { + self::$anonymousTraitUseDocs[$normalizedFile][$top['line']][] = $tokens[$k]->text; + } + + break; + } + if ($tokens[$k]->id !== T_WHITESPACE) { + break; + } } } continue; } - if ($token->id === T_USE && $currentClass === null) { + if ($token->id === T_USE && $classStack === []) { $useStatement = ''; for ($j = $i + 1; $j < $count; $j++) { if ($tokens[$j]->text === ';') { @@ -1189,9 +1342,9 @@ public static function parseFileMetadata(string $fileName, string $source): void } } - self::$fileUseImports[$fileName] = $imports; + self::$fileUseImports[$normalizedFile] = $imports; } catch (\Throwable $e) { - self::$fileUseImports[$fileName] = []; + self::$fileUseImports[$normalizedFile] = []; } } } diff --git a/src/Internal/Util/PathMatcher.php b/src/Internal/Util/PathMatcher.php index b06f39ce..44814802 100644 --- a/src/Internal/Util/PathMatcher.php +++ b/src/Internal/Util/PathMatcher.php @@ -291,7 +291,7 @@ public static function mayPathBeIncluded(string $normalizedPath): bool } if (str_starts_with($canon, 'var/') - || (!str_starts_with($canon, '/var/') && str_contains($canon, '/var/')) + || (! str_starts_with($canon, '/var/') && str_contains($canon, '/var/')) || (str_starts_with($canon, '/var/') && str_contains(substr($canon, 4), '/var/')) ) { if (! self::hasIncludeMatchingPrefix('var/', $includes)) { diff --git a/tests/Internal/Io/StreamWrapperTest.php b/tests/Internal/Io/StreamWrapperTest.php index 2140b335..f5b411f6 100644 --- a/tests/Internal/Io/StreamWrapperTest.php +++ b/tests/Internal/Io/StreamWrapperTest.php @@ -3,6 +3,7 @@ declare(strict_types=1); use TypePHP\Internal\Io\StreamWrapper; +use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Util\Config; use TypePHP\Internal\Util\FileFilter; @@ -577,4 +578,77 @@ function dedicatedStreamAction(int $id): int } }); }); + + describe('Anonymous Class Trait Metadata AST Extraction', function () { + test('extracts and seeds trait docblocks for anonymous classes located inside methods during transformSource', function () { + $source = <<<'PHP' + + */ + use LoggerTraitFixture; + }; + } +} +PHP; + StreamWrapper::transformSource($source, 'VirtualServiceFactory.php'); + + $obj = (new class () { + public function create(): object + { + return new class () { + /** @use \TypePHP\Tests\Fixtures\Generics\GenericItemLoggerTrait */ + use TypePHP\Tests\Fixtures\Generics\GenericItemLoggerTrait; + }; + } + })->create(); + + $docs = SpecialTypeResolver::getClassTraitUseDocs($obj::class); + expect($docs)->not()->toBeEmpty() + ->and($docs[0])->toContain('GenericItemLoggerTrait') + ; + }); + + test('handles multiple anonymous classes in the same file with different trait uses without collision', function () { + $source = <<<'PHP' + */ + use TraitA; + }; + } + + public function second() + { + return new class () { + /** @use TraitB */ + use TraitB; + }; + } +} +PHP; + $transformed = StreamWrapper::transformSource($source, 'VirtualMultiAnon.php'); + expect($transformed)->toContain('class MultiAnonFactory'); + }); + }); }); diff --git a/tests/Internal/Resolver/SpecialTypeResolverTest.php b/tests/Internal/Resolver/SpecialTypeResolverTest.php index 3da440b4..3dd078af 100644 --- a/tests/Internal/Resolver/SpecialTypeResolverTest.php +++ b/tests/Internal/Resolver/SpecialTypeResolverTest.php @@ -397,4 +397,109 @@ class SampleDemo expect($imports)->toBeEmpty(); }); }); + + describe('Anonymous Class Trait Use DocBlock Extraction & Scope Tracking', function () { + test('parses and extracts trait use docblocks for anonymous classes without polluting enclosing class', function () { + $source = <<<'PHP' + + */ + use SomeTraitFixture; + }; + } +} +PHP; + $virtualFile = 'VirtualAnonTraitTest.php'; + SpecialTypeResolver::parseFileMetadata($virtualFile, $source); + + expect(SpecialTypeResolver::getClassTraitUseDocs('App\Test\OuterNamedClass'))->toBeEmpty(); + + $anon = (new class () { + public function make(): object + { + return new class () { + /** @use \TypePHP\Tests\Fixtures\Generics\GenericItemLoggerTrait */ + use TypePHP\Tests\Fixtures\Generics\GenericItemLoggerTrait; + }; + } + })->make(); + + $docs = SpecialTypeResolver::getClassTraitUseDocs($anon::class); + expect($docs)->toHaveCount(1) + ->and($docs[0])->toContain('GenericItemLoggerTrait') + ; + }); + + test('ignores ::class constant fetches and does not push ghost anonymous classes to classStack', function () { + $source = <<<'PHP' + + */ + use MySampleTrait; +} +PHP; + $virtualFile = 'VirtualConstFetchTest.php'; + SpecialTypeResolver::parseFileMetadata($virtualFile, $source); + + $docs = SpecialTypeResolver::getClassTraitUseDocs('App\Test\SampleClassWithConstFetches'); + expect($docs)->toHaveCount(1) + ->and($docs[0])->toContain('@use MySampleTrait') + ; + }); + + test('resolves anonymous class trait use docs with proximity matching when line numbers shift', function () { + $anon = new class () { + /** + * @use \TypePHP\Tests\Fixtures\Generics\GenericItemLoggerTrait<\TypePHP\Tests\Fixtures\Domain\Dog> + */ + use TypePHP\Tests\Fixtures\Generics\GenericItemLoggerTrait; + }; + + $docs = SpecialTypeResolver::getClassTraitUseDocs($anon::class); + expect($docs)->toHaveCount(1) + ->and($docs[0])->toContain('GenericItemLoggerTrait<') + ->and($docs[0])->toContain('Dog>') + ; + }); + + test('returns empty array cleanly for anonymous class without any trait docblocks', function () { + $anonWithoutDoc = new class () { + use TypePHP\Tests\Fixtures\Generics\GenericItemLoggerTrait; + }; + + $docs = SpecialTypeResolver::getClassTraitUseDocs($anonWithoutDoc::class); + expect($docs)->toBeEmpty(); + }); + }); }); diff --git a/tests/Internal/Util/PathMatcherTest.php b/tests/Internal/Util/PathMatcherTest.php index 130c9bec..c35231fd 100644 --- a/tests/Internal/Util/PathMatcherTest.php +++ b/tests/Internal/Util/PathMatcherTest.php @@ -253,7 +253,7 @@ test('permits var when var is root directory using default config', function () { try { Config::set([ - 'include' => ['src/**', 'app/**', 'tests/**',], + 'include' => ['src/**', 'app/**', 'tests/**'], ]); expect(PathMatcher::mayPathBeIncluded('/var/www/app/src/index.php'))->toBeTrue(); diff --git a/tests/Pest.php b/tests/Pest.php index 6d1b5f39..d5e8621a 100644 --- a/tests/Pest.php +++ b/tests/Pest.php @@ -11,4 +11,5 @@ ->afterEach(function () { Config::reset(); }) - ->in('Contract', 'Internal', 'Feature'); \ No newline at end of file + ->in('Contract', 'Internal', 'Feature') +; diff --git a/tests/TypeChecking/Generics/AnonymousClassTraitUseTest.php b/tests/TypeChecking/Generics/AnonymousClassTraitUseTest.php new file mode 100644 index 00000000..4b864dbf --- /dev/null +++ b/tests/TypeChecking/Generics/AnonymousClassTraitUseTest.php @@ -0,0 +1,113 @@ + + */ + public array $tags = []; + + /** + * @param TTag $record + */ + public function addTag(BaseTagFixture $record): static + { + $this->tags[] = $record; + + return $this; + } +} + +class EnclosingNamedClassFixture +{ + public function createAnonymous(): object + { + return new class () { + /** + * @use HasManyTagsFixture + */ + use HasManyTagsFixture; + }; + } +} + +describe('Anonymous Class with Generic Trait @use Annotation', function () { + test('binds union template to anonymous class from @use docblock and accepts multiple union members', function () { + $subject = new class () { + /** + * @use HasManyTagsFixture + */ + use HasManyTagsFixture; + }; + $boundType = TypePHP::getGenericType($subject); + expect($boundType)->not()->toBeNull() + ->and($boundType)->toContain(TextTagFixture::class) + ->and($boundType)->toContain(ShippingMethodTagFixture::class) + ; + + $subject->addTag(new TextTagFixture()); + expect($subject->tags)->toHaveCount(1); + + $subject->addTag(new ShippingMethodTagFixture()); + expect($subject->tags)->toHaveCount(2); + }); + + test('anonymous class created inside a named class method resolves @use and accepts union members', function () { + $factory = new EnclosingNamedClassFixture(); + $subject = $factory->createAnonymous(); + + $subject->addTag(new TextTagFixture()); + $subject->addTag(new ShippingMethodTagFixture()); + + expect($subject->tags)->toHaveCount(2); + }); + + test('still rejects tag types outside the declared union on the anonymous class', function () { + $subject = new class () { + /** + * @use HasManyTagsFixture + */ + use HasManyTagsFixture; + }; + + expect(fn () => $subject->addTag(new UnrelatedTagFixture())) + ->toThrow(TypeError::class) + ; + }); + + test('does not leak anonymous class trait-use docblock into enclosing named class', function () { + $factory = new EnclosingNamedClassFixture(); + $anon = $factory->createAnonymous(); + expect($anon)->toBeObject(); + + $enclosingDocs = SpecialTypeResolver::getClassTraitUseDocs(EnclosingNamedClassFixture::class); + expect($enclosingDocs)->toBeEmpty(); + }); +});