diff --git a/src/Internal/Checker/ParamChecker.php b/src/Internal/Checker/ParamChecker.php index 3d57bf6..6cf75b3 100644 --- a/src/Internal/Checker/ParamChecker.php +++ b/src/Internal/Checker/ParamChecker.php @@ -18,6 +18,7 @@ use TypePHP\Internal\Diagnostic\ErrorFactory; use TypePHP\Internal\Diagnostic\ErrorMessage; use TypePHP\Internal\Diagnostic\TypeFormatter; +use TypePHP\Internal\Docblock\DocblockExtractor; use TypePHP\Internal\Docblock\DocblockParser; use TypePHP\Internal\Generics\TemplateManager; use TypePHP\Internal\Generics\TemplateSubstitutor; @@ -25,6 +26,7 @@ use TypePHP\Internal\Resolver\SpecialTypeResolver; use TypePHP\Internal\Util\ClassNameValidator; use TypePHP\Internal\Util\Config; +use TypePHP\Internal\Util\StubManager; use TypePHP\Internal\Validator\TypeValidatorRegistry; /** @@ -51,6 +53,13 @@ final class ParamChecker */ private static array $baseTypeCache = []; + /** + * Cache for declared class template names by class FQCN. + * + * @var array> + */ + private static array $classDeclaredTemplateNamesCache = []; + /** * Resets internal caches. Useful for test isolation. */ @@ -58,6 +67,7 @@ public static function reset(): void { self::$effectiveFunctionCache = []; self::$noParamContractCache = []; + self::$classDeclaredTemplateNamesCache = []; ClassNameValidator::reset(); self::$baseTypeCache = []; } @@ -803,6 +813,25 @@ private static function inferGenericObjectNode( return; } + $targetClassFqcn = SpecialTypeResolver::resolveFqcn($typeNode->type->name, new \ReflectionClass($value)); + $targetTemplateNames = self::$classDeclaredTemplateNamesCache[$targetClassFqcn] ?? null; + + if ($targetTemplateNames === null) { + $targetTemplateNames = []; + if (class_exists($targetClassFqcn, false) || class_exists($targetClassFqcn) || interface_exists($targetClassFqcn) || trait_exists($targetClassFqcn)) { + try { + $targetRef = new \ReflectionClass($targetClassFqcn); + $targetDoc = StubManager::getClassDoc($targetClassFqcn) ?? $targetRef->getDocComment(); + if ($targetDoc !== false && $targetDoc !== null) { + $targetPhpDocNode = DocblockExtractor::parseDocString($targetDoc); + $targetTemplateNames = array_keys(DocblockExtractor::extractTemplates($targetPhpDocNode)); + } + } catch (\Throwable $e) { + } + } + self::$classDeclaredTemplateNamesCache[$targetClassFqcn] = $targetTemplateNames; + } + $instanceBoundTypes = array_values($boundOnInstance); foreach ($typeNode->genericTypes as $idx => $gtNode) { @@ -812,7 +841,14 @@ private static function inferGenericObjectNode( $targetObj = $isClassLevel ? $thisObj : null; if (! TemplateManager::isBound($effectiveFunction, $targetObj, $tName)) { - $inferredCandidate = $instanceBoundTypes[$idx] ?? null; + $targetTemplateName = $targetTemplateNames[$idx] ?? null; + $inferredCandidate = null; + + if ($targetTemplateName !== null && isset($boundOnInstance[$targetTemplateName])) { + $inferredCandidate = $boundOnInstance[$targetTemplateName]; + } elseif (isset($instanceBoundTypes[$idx])) { + $inferredCandidate = $instanceBoundTypes[$idx]; + } if ($inferredCandidate !== null) { $templateTag = $templates[$tName]; diff --git a/tests/Fixtures/Forwarding/AbstractBox.php b/tests/Fixtures/Forwarding/AbstractBox.php new file mode 100644 index 0000000..9bb6f8f --- /dev/null +++ b/tests/Fixtures/Forwarding/AbstractBox.php @@ -0,0 +1,18 @@ + + */ +abstract class AbstractBox implements BoxInterface +{ +} \ No newline at end of file diff --git a/tests/Fixtures/Forwarding/BoxConsumer.php b/tests/Fixtures/Forwarding/BoxConsumer.php new file mode 100644 index 0000000..44e7171 --- /dev/null +++ b/tests/Fixtures/Forwarding/BoxConsumer.php @@ -0,0 +1,72 @@ + $box + */ + public function __construct(public readonly BoxInterface $box) + { + } + + /** + * @template T of ItemBase + * @template U of ItemBase + * + * @param BoxInterface $box + * + * @return T + */ + public function extractFirst(BoxInterface $box): ItemBase + { + return new ItemA(); + } + + /** + * @template T of ItemBase + * @template U of ItemBase + * + * @param BoxInterface $box + * + * @return U + */ + public function extractSecond(BoxInterface $box): ItemBase + { + return new ItemB(); + } + + /** + * Method returning invalid type (returns ItemB when T is ItemA) + * + * @template T of ItemBase + * @template U of ItemBase + * + * @param BoxInterface $box + * + * @return T + */ + public function extractBad(BoxInterface $box): ItemBase + { + return new ItemB(); + } + + /** + * @template T of ItemBase + * @template U of ItemBase + * + * @param BoxInterface $box + * + * @return BoxInterface + */ + public function passThrough(BoxInterface $box): BoxInterface + { + return $box; + } +} \ No newline at end of file diff --git a/tests/Fixtures/Forwarding/BoxInterface.php b/tests/Fixtures/Forwarding/BoxInterface.php new file mode 100644 index 0000000..ba67c3d --- /dev/null +++ b/tests/Fixtures/Forwarding/BoxInterface.php @@ -0,0 +1,13 @@ + + */ +final class ConcreteBox extends AbstractBox +{ +} \ No newline at end of file diff --git a/tests/Fixtures/Forwarding/ItemA.php b/tests/Fixtures/Forwarding/ItemA.php new file mode 100644 index 0000000..03be03a --- /dev/null +++ b/tests/Fixtures/Forwarding/ItemA.php @@ -0,0 +1,12 @@ +box)->toBe($concreteBox); + }); + + test('reifies bound generic types on concrete box instance matching both interface and class templates', function () { + $concreteBox = new ConcreteBox(); + + expect(TypePHP::getGenericType($concreteBox, 'T'))->toBe(ItemA::class) + ->and(TypePHP::getGenericType($concreteBox, 'U'))->toBe(ItemB::class) + ; + }); + + describe('Return Type Contracts with Forwarded Generics', function () { + test('validates @return T correctly uses inferred ItemA', function () { + $concreteBox = new ConcreteBox(); + $consumer = new BoxConsumer($concreteBox); + + $first = $consumer->extractFirst($concreteBox); + expect($first)->toBeInstanceOf(ItemA::class); + }); + + test('validates @return U correctly uses inferred ItemB', function () { + $concreteBox = new ConcreteBox(); + $consumer = new BoxConsumer($concreteBox); + + $second = $consumer->extractSecond($concreteBox); + expect($second)->toBeInstanceOf(ItemB::class); + }); + + test('throws TypeError when @return T returns ItemB instead of inferred ItemA', function () { + $concreteBox = new ConcreteBox(); + $consumer = new BoxConsumer($concreteBox); + + expect(fn () => $consumer->extractBad($concreteBox)) + ->toThrow( + TypeError::class, + 'Return value must be of type ' . ItemA::class . ', ' . ItemB::class . ' returned' + ) + ; + }); + + test('validates @return BoxInterface returning the forwarded concrete instance', function () { + $concreteBox = new ConcreteBox(); + $consumer = new BoxConsumer($concreteBox); + + $result = $consumer->passThrough($concreteBox); + expect($result)->toBe($concreteBox); + }); + }); +}); \ No newline at end of file