diff --git a/src/Contract/HierarchyResolver.php b/src/Contract/HierarchyResolver.php index 8cd8080..e258463 100644 --- a/src/Contract/HierarchyResolver.php +++ b/src/Contract/HierarchyResolver.php @@ -4,6 +4,9 @@ namespace TypePHP\Contract; +use ReflectionClass; +use ReflectionMethod; + /** * @internal Resolves class, interface, trait, and method inheritance hierarchies from child to root. */ @@ -12,14 +15,14 @@ final class HierarchyResolver /** * In-memory cache for resolved ReflectionMethod hierarchy arrays. * - * @var array> + * @var array> */ private static array $methodHierarchyCache = []; /** * In-memory cache for resolved ReflectionClass hierarchy arrays. * - * @var array>> + * @var array>> */ private static array $classHierarchyCache = []; @@ -35,16 +38,9 @@ public static function reset(): void /** * Builds an array of ReflectionMethods representing the inheritance hierarchy from child to root. * - * Resolution Flow: - * 1. Target Class: Uses $ref->class to inspect the target executing class (ensures interface - * contracts are discovered even when method bodies are fulfilled by a Trait). - * 2. Parent Classes: Traverses up the parent class chain to inherit parent method docblocks. - * 3. Interfaces: Traverses implemented interfaces to inherit interface contract docblocks. - * 4. Traits: Traverses used traits to inherit trait method docblocks. - * - * @return array + * @return array */ - public static function getMethodHierarchy(\ReflectionMethod $ref): array + public static function getMethodHierarchy(ReflectionMethod $ref): array { $cacheKey = $ref->class . '::' . $ref->getName(); if (isset(self::$methodHierarchyCache[$cacheKey])) { @@ -55,7 +51,18 @@ public static function getMethodHierarchy(\ReflectionMethod $ref): array $methodName = $ref->getName(); $targetClassName = $ref->class; - $targetClass = new \ReflectionClass($targetClassName); + $targetClass = new ReflectionClass($targetClassName); + + $traitAliases = $targetClass->getTraitAliases(); + if (isset($traitAliases[$methodName])) { + [$traitName, $originalMethodName] = explode('::', $traitAliases[$methodName], 2); + if (trait_exists($traitName)) { + $traitRef = new ReflectionClass($traitName); + if ($traitRef->hasMethod($originalMethodName)) { + $hierarchy[] = $traitRef->getMethod($originalMethodName); + } + } + } $parent = $targetClass->getParentClass(); while ($parent !== false) { @@ -81,40 +88,46 @@ public static function getMethodHierarchy(\ReflectionMethod $ref): array } /** - * Builds an array of ReflectionClasses representing the class inheritance hierarchy from child to root. + * Builds an array of ReflectionClasses representing the complete inheritance hierarchy from child to root. + * Recursively traverses parent classes, implemented interfaces, and used traits across all levels. * - * Resolution Flow: - * 1. Target Class: Includes the primary reflection class. - * 2. Parent Classes: Traverses parent classes up the inheritance tree. - * 3. Interfaces: Collects all implemented interfaces. - * 4. Traits: Collects all used traits. + * @param ReflectionClass $ref * - * @param \ReflectionClass $ref - * - * @return array> + * @return array> */ - public static function getClassHierarchy(\ReflectionClass $ref): array + public static function getClassHierarchy(ReflectionClass $ref): array { $cacheKey = $ref->getName(); if (isset(self::$classHierarchyCache[$cacheKey])) { return self::$classHierarchyCache[$cacheKey]; } - $hierarchy = [$ref]; + $hierarchy = []; + $visited = []; - $parent = $ref->getParentClass(); - while ($parent !== false) { - $hierarchy[] = $parent; - $parent = $parent->getParentClass(); - } + $collect = function (ReflectionClass $class) use (&$collect, &$hierarchy, &$visited): void { + $name = $class->getName(); + if (isset($visited[$name])) { + return; + } + $visited[$name] = true; + $hierarchy[] = $class; - foreach ($ref->getInterfaces() as $interface) { - $hierarchy[] = $interface; - } + $parent = $class->getParentClass(); + if ($parent !== false) { + $collect($parent); + } - foreach ($ref->getTraits() as $trait) { - $hierarchy[] = $trait; - } + foreach ($class->getInterfaces() as $interface) { + $collect($interface); + } + + foreach ($class->getTraits() as $trait) { + $collect($trait); + } + }; + + $collect($ref); return self::$classHierarchyCache[$cacheKey] = $hierarchy; } diff --git a/src/Resolver/TemplateManager.php b/src/Resolver/TemplateManager.php index 2a699d7..9b7233f 100644 --- a/src/Resolver/TemplateManager.php +++ b/src/Resolver/TemplateManager.php @@ -18,9 +18,11 @@ use PHPStan\PhpDocParser\Parser\TokenIterator; use PHPStan\PhpDocParser\Parser\TypeParser; use PHPStan\PhpDocParser\ParserConfig; +use TypePHP\Contract\HierarchyResolver; use TypePHP\Internal\ClassNameValidator; use TypePHP\Internal\ErrorFactory; use TypePHP\Internal\ErrorMessage; +use WeakMap; /** * @internal Manages generic template bindings for object instances (via WeakMap) and static call stack frames. @@ -30,9 +32,9 @@ final class TemplateManager /** * WeakMap storing generic template bindings per object instance. * - * @var \WeakMap>|null + * @var WeakMap>|null */ - private static ?\WeakMap $instanceTemplateBindings = null; + private static ?WeakMap $instanceTemplateBindings = null; /** * Call stack frames storing template bindings per function or method call. @@ -87,7 +89,6 @@ public static function clearCallBindings(string $function, array $templates): vo /** * Retrieves currently bound template types for a function call or object instance. - * Automatically resolves pending clone sources. * * @param array $templates * @@ -120,7 +121,6 @@ public static function getBoundTemplates(string $function, ?object $thisObj, arr /** * Retrieves all bound template TypeNodes for a specific object instance. - * Automatically resolves @extends and @implements template mappings if unbound. * * @return array */ @@ -130,7 +130,6 @@ public static function getBoundTemplatesForInstance(object $instance): array self::copyInstanceBindings(self::$pendingCloneSource, $instance); } - // Auto-resolve @extends and @implements generic template mappings if (self::$instanceTemplateBindings === null || ! isset(self::$instanceTemplateBindings[$instance])) { self::resolveInheritedTemplates($instance, \get_class($instance)); } @@ -244,7 +243,7 @@ public static function bindTemplate(string $function, ?object $thisObj, string $ { if ($thisObj !== null) { if (self::$instanceTemplateBindings === null) { - self::$instanceTemplateBindings = new \WeakMap(); + self::$instanceTemplateBindings = new WeakMap(); } $bindings = self::$instanceTemplateBindings[$thisObj] ?? []; $bindings[$templateName] = $inferredType; @@ -280,68 +279,77 @@ public static function bindInstanceFromNode(object $instance, GenericTypeNode $t try { $ref = new \ReflectionClass($className); - $classDoc = $ref->getDocComment(); - - if ($classDoc !== false) { - [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); - - $classTokens = new TokenIterator($lexer->tokenize($classDoc)); - $classPhpDocNode = $phpDocParser->parse($classTokens); - - $templates = []; - $classVariances = []; - - foreach ($classPhpDocNode->getTags() as $tagNode) { - if ($tagNode->value instanceof TemplateTagValueNode) { - $templates[] = $tagNode->value; - $tagName = strtolower($tagNode->name); - - if (str_contains($tagName, 'covariant')) { - $classVariances[$tagNode->value->name] = GenericTypeNode::VARIANCE_COVARIANT; - } elseif (str_contains($tagName, 'contravariant')) { - $classVariances[$tagNode->value->name] = GenericTypeNode::VARIANCE_CONTRAVARIANT; - } else { - $classVariances[$tagNode->value->name] = GenericTypeNode::VARIANCE_INVARIANT; + $classHierarchy = HierarchyResolver::getClassHierarchy($ref); + + [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); + + $templates = []; + $classVariances = []; + + // Collect template parameters across the entire class/interface hierarchy! + foreach ($classHierarchy as $hierClass) { + $classDoc = $hierClass->getDocComment(); + if ($classDoc !== false) { + $classTokens = new TokenIterator($lexer->tokenize($classDoc)); + $classPhpDocNode = $phpDocParser->parse($classTokens); + + foreach ($classPhpDocNode->getTags() as $tagNode) { + if ($tagNode->value instanceof TemplateTagValueNode) { + $tName = $tagNode->value->name; + if (! isset($templates[$tName])) { + $templates[$tName] = $tagNode->value; + $tagName = strtolower($tagNode->name); + + if (str_contains($tagName, 'covariant')) { + $classVariances[$tName] = GenericTypeNode::VARIANCE_COVARIANT; + } elseif (str_contains($tagName, 'contravariant')) { + $classVariances[$tName] = GenericTypeNode::VARIANCE_CONTRAVARIANT; + } else { + $classVariances[$tName] = GenericTypeNode::VARIANCE_INVARIANT; + } + } } } } + } - if (self::$instanceTemplateBindings === null) { - self::$instanceTemplateBindings = new \WeakMap(); - } + if (self::$instanceTemplateBindings === null) { + self::$instanceTemplateBindings = new WeakMap(); + } - foreach ($templates as $index => $templateTag) { - if (isset($typeNode->genericTypes[$index])) { - $expectedTypeNode = $typeNode->genericTypes[$index]; + $templateList = array_values($templates); - $usageVariance = $typeNode->variances[$index] ?? GenericTypeNode::VARIANCE_INVARIANT; - $declaredVariance = $classVariances[$templateTag->name] ?? GenericTypeNode::VARIANCE_INVARIANT; + foreach ($templateList as $index => $templateTag) { + if (isset($typeNode->genericTypes[$index])) { + $expectedTypeNode = $typeNode->genericTypes[$index]; - $variance = ($usageVariance !== GenericTypeNode::VARIANCE_INVARIANT) - ? $usageVariance - : $declaredVariance; + $usageVariance = $typeNode->variances[$index] ?? GenericTypeNode::VARIANCE_INVARIANT; + $declaredVariance = $classVariances[$templateTag->name] ?? GenericTypeNode::VARIANCE_INVARIANT; - $templateName = $templateTag->name; - $existingBindings = self::$instanceTemplateBindings[$instance] ?? []; + $variance = ($usageVariance !== GenericTypeNode::VARIANCE_INVARIANT) + ? $usageVariance + : $declaredVariance; - if (isset($existingBindings[$templateName])) { - $existingTypeNode = $existingBindings[$templateName]; + $templateName = $templateTag->name; + $existingBindings = self::$instanceTemplateBindings[$instance] ?? []; - $valid = self::checkVariance($existingTypeNode, $expectedTypeNode, $variance); + if (isset($existingBindings[$templateName])) { + $existingTypeNode = $existingBindings[$templateName]; - if (! $valid) { - return ErrorFactory::createError( - $context . " expects {$className}<{$variance} {$expectedTypeNode}>, but {$className}<{$existingTypeNode}> was given" - ); - } - } + $valid = self::checkVariance($existingTypeNode, $expectedTypeNode, $variance); - if ($forceBind || ! isset($existingBindings[$templateName])) { - $bindings = self::$instanceTemplateBindings[$instance] ?? []; - $bindings[$templateName] = $expectedTypeNode; - self::$instanceTemplateBindings[$instance] = $bindings; + if (! $valid) { + return ErrorFactory::createError( + $context . " expects {$className}<{$variance} {$expectedTypeNode}>, but {$className}<{$existingTypeNode}> was given" + ); } } + + if ($forceBind || ! isset($existingBindings[$templateName])) { + $bindings = self::$instanceTemplateBindings[$instance] ?? []; + $bindings[$templateName] = $expectedTypeNode; + self::$instanceTemplateBindings[$instance] = $bindings; + } } } } catch (\Throwable $e) { @@ -360,68 +368,71 @@ public static function resolveInheritedTemplates(object $instance, string $targe try { $ref = new \ReflectionClass($actualClassName); - $classDoc = $ref->getDocComment(); + $classHierarchy = HierarchyResolver::getClassHierarchy($ref); - if ($classDoc !== false) { - [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); + [$phpDocParser, $lexer] = self::getPhpDocParserComponents(); - $classTokens = new TokenIterator($lexer->tokenize($classDoc)); - $classPhpDocNode = $phpDocParser->parse($classTokens); + foreach ($classHierarchy as $hierClass) { + $classDoc = $hierClass->getDocComment(); + + if ($classDoc !== false) { + $classTokens = new TokenIterator($lexer->tokenize($classDoc)); + $classPhpDocNode = $phpDocParser->parse($classTokens); - $declaredTemplateNames = []; - foreach ($classPhpDocNode->getTags() as $tag) { - if ($tag->value instanceof TemplateTagValueNode) { - $declaredTemplateNames[$tag->value->name] = true; + $declaredTemplateNames = []; + foreach ($classPhpDocNode->getTags() as $tag) { + if ($tag->value instanceof TemplateTagValueNode) { + $declaredTemplateNames[$tag->value->name] = true; + } } - } - $inheritedTags = array_merge( - $classPhpDocNode->getExtendsTagValues(), - $classPhpDocNode->getImplementsTagValues() - ); + $inheritedTags = array_merge( + $classPhpDocNode->getExtendsTagValues(), + $classPhpDocNode->getImplementsTagValues() + ); - foreach ($inheritedTags as $inheritedTag) { - $genericTypeNode = $inheritedTag->type; - if ($genericTypeNode instanceof GenericTypeNode) { - $parentName = SpecialTypeResolver::resolveFqcn($genericTypeNode->type->name, $ref); + foreach ($inheritedTags as $inheritedTag) { + $genericTypeNode = $inheritedTag->type; + if ($genericTypeNode instanceof GenericTypeNode) { + $parentName = SpecialTypeResolver::resolveFqcn($genericTypeNode->type->name, $hierClass); - if (ClassNameValidator::isValid($parentName) && is_a($actualClassName, $parentName, true)) { - if (! class_exists($parentName) && ! interface_exists($parentName)) { - continue; - } + if (ClassNameValidator::isValid($parentName) && is_a($actualClassName, $parentName, true)) { + if (! class_exists($parentName) && ! interface_exists($parentName)) { + continue; + } - $parentRef = new \ReflectionClass($parentName); - $parentDoc = $parentRef->getDocComment(); + $parentRef = new \ReflectionClass($parentName); + $parentDoc = $parentRef->getDocComment(); - if ($parentDoc !== false) { - $parentTokens = new TokenIterator($lexer->tokenize($parentDoc)); - $parentPhpDocNode = $phpDocParser->parse($parentTokens); + if ($parentDoc !== false) { + $parentTokens = new TokenIterator($lexer->tokenize($parentDoc)); + $parentPhpDocNode = $phpDocParser->parse($parentTokens); - $parentTemplateNames = []; - foreach ($parentPhpDocNode->getTags() as $tag) { - if ($tag->value instanceof TemplateTagValueNode) { - $parentTemplateNames[] = $tag->value->name; + $parentTemplateNames = []; + foreach ($parentPhpDocNode->getTags() as $tag) { + if ($tag->value instanceof TemplateTagValueNode) { + $parentTemplateNames[] = $tag->value->name; + } } - } - $bindings = self::$instanceTemplateBindings[$instance] ?? []; - foreach ($parentTemplateNames as $idx => $templateName) { - if (isset($genericTypeNode->genericTypes[$idx])) { - $resolved = self::resolveTypeNodeAst($genericTypeNode->genericTypes[$idx], $ref); + $bindings = self::$instanceTemplateBindings[$instance] ?? []; + foreach ($parentTemplateNames as $idx => $templateName) { + if (isset($genericTypeNode->genericTypes[$idx])) { + $resolved = self::resolveTypeNodeAst($genericTypeNode->genericTypes[$idx], $hierClass); - // Skip binding if it's just an inherited placeholder pointing to our own declared templates - if ($resolved instanceof IdentifierTypeNode && isset($declaredTemplateNames[$resolved->name])) { - continue; - } + if ($resolved instanceof IdentifierTypeNode && isset($declaredTemplateNames[$resolved->name])) { + continue; + } - $bindings[$templateName] = $resolved; + $bindings[$templateName] = $resolved; + } } - } - if (self::$instanceTemplateBindings === null) { - self::$instanceTemplateBindings = new \WeakMap(); + if (self::$instanceTemplateBindings === null) { + self::$instanceTemplateBindings = new WeakMap(); + } + self::$instanceTemplateBindings[$instance] = $bindings; } - self::$instanceTemplateBindings[$instance] = $bindings; } } } diff --git a/tests/Fixtures/Generics/ChildComboService.php b/tests/Fixtures/Generics/ChildComboService.php new file mode 100644 index 0000000..d9bf95c --- /dev/null +++ b/tests/Fixtures/Generics/ChildComboService.php @@ -0,0 +1,13 @@ + + */ + private array $items = []; + + public function push(mixed $item): bool + { + $this->items[] = $item; + + return true; + } + + public function all(): array + { + return $this->items; + } +} diff --git a/tests/Fixtures/Generics/ComboAbstractParent.php b/tests/Fixtures/Generics/ComboAbstractParent.php new file mode 100644 index 0000000..9f67e73 --- /dev/null +++ b/tests/Fixtures/Generics/ComboAbstractParent.php @@ -0,0 +1,21 @@ + + */ + public function all(): array; +} diff --git a/tests/Fixtures/Generics/SingleAbstractGenericParent.php b/tests/Fixtures/Generics/SingleAbstractGenericParent.php new file mode 100644 index 0000000..8c4c392 --- /dev/null +++ b/tests/Fixtures/Generics/SingleAbstractGenericParent.php @@ -0,0 +1,19 @@ + "bound_{$id}"; expect(fn () => testStaticClosureParam($nonStaticClosure)) - ->toThrow(TypeError::class, 'must be a static Closure'); + ->toThrow(TypeError::class, 'must be a static Closure') + ; }); }); diff --git a/tests/TypeChecking/Generics/InheritedInterfaceTemplateBindingTest.php b/tests/TypeChecking/Generics/InheritedInterfaceTemplateBindingTest.php new file mode 100644 index 0000000..bd8ff01 --- /dev/null +++ b/tests/TypeChecking/Generics/InheritedInterfaceTemplateBindingTest.php @@ -0,0 +1,80 @@ + $container */ + $container = new ChildWithoutTemplateDocblock(); + + $container->push(new Dog()); + + expect($container->push(new Cat()))->toBeTrue(); + expect(TypePHP::getGenericType($container))->toBe('(' . Dog::class . ' | ' . Cat::class . ')'); + }); + + test('throws TypeError when item violates pre-bound union template on child inheriting interface', function () { + /** @var ChildWithoutTemplateDocblock $container */ + $container = new ChildWithoutTemplateDocblock(); + + expect(fn () => $container->push(new Car())) + ->toThrow(TypeError::class, 'must be of type (' . Dog::class . ' | ' . Cat::class . ')'); + }); + }); + + describe('Single Abstract Class Generic Inheritance', function () { + test('pre-binds template inherited from single abstract parent without child docblock', function () { + /** @var ChildSingleAbstractService $service */ + $service = new ChildSingleAbstractService(); + + expect($service->setItem(new Dog()))->toBeTrue(); + expect($service->setItem(new Cat()))->toBeTrue(); + + expect(fn () => $service->setItem(new Car())) + ->toThrow(TypeError::class, 'must be of type (' . Dog::class . ' | ' . Cat::class . ')'); + }); + }); + + describe('Multi-Nested Deep Generic Inheritance (Root -> Mid -> Child)', function () { + test('pre-binds template inherited across 3-tier deep abstract inheritance chain without child docblock', function () { + /** @var DeepGenericChildService $service */ + $service = new DeepGenericChildService(); + + expect($service->processElement(100))->toBeTrue(); + + expect(fn () => $service->processElement(-50)) + ->toThrow(TypeError::class, 'must be of type positive-int'); + }); + }); + + describe('Abstract Class + Interface + Trait Combo Hierarchy', function () { + test('pre-binds templates inherited across abstract class, interface, and trait combo without local child docblock', function () { + /** @var ChildComboService $service */ + $service = new ChildComboService(); + + expect($service->processData(42))->toBeTrue(); + expect(fn () => $service->processData(-5)) + ->toThrow(TypeError::class, 'must be of type positive-int'); + + expect($service->setKey('valid_key'))->toBeTrue(); + expect(fn () => $service->setKey('')) + ->toThrow(TypeError::class, 'must be of type non-empty-string'); + + expect($service->setVal(new Dog()))->toBeTrue(); + expect($service->setVal(new Cat()))->toBeTrue(); + expect(fn () => $service->setVal(new Car())) + ->toThrow(TypeError::class, 'must be of type (' . Dog::class . ' | ' . Cat::class . ')'); + }); + }); +}); \ No newline at end of file diff --git a/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php b/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php index 6fa1c21..6c45504 100644 --- a/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php +++ b/tests/TypeChecking/InheritanceAndAttributes/ParameterShiftInheritanceTest.php @@ -149,6 +149,7 @@ ; expect(fn () => $service->execute(authToken: '', statusCode: 200)) - ->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string'); + ->toThrow(TypeError::class, 'Argument $authToken must be of type non-empty-string') + ; }); });