diff --git a/src/Internal/Checker/InlineChecker.php b/src/Internal/Checker/InlineChecker.php index 8237fd56..a7d3eae5 100644 --- a/src/Internal/Checker/InlineChecker.php +++ b/src/Internal/Checker/InlineChecker.php @@ -42,20 +42,12 @@ final class InlineChecker */ private static array $parsedTypeNodeCache = []; - /** - * In-memory cache for fully resolved type nodes per type string and file. - * - * @var array - */ - private static array $resolvedTypeNodeCache = []; - /** * Resets internal type node caches. Useful for test isolation. */ public static function reset(): void { self::$parsedTypeNodeCache = []; - self::$resolvedTypeNodeCache = []; } /** @@ -119,21 +111,15 @@ public static function checkVariable(mixed $value, string $typeString, string $v } try { - $cacheKey = $typeString . '|' . $file; - if (isset(self::$resolvedTypeNodeCache[$cacheKey])) { - $typeNode = self::$resolvedTypeNodeCache[$cacheKey]; - } else { - $normalized = DocblockNormalizer::normalize($typeString); - $typeNode = self::parseTypeString($normalized); - - if ($file !== '') { - $typeNode = SpecialTypeResolver::resolveForFile($typeNode, $file); - } + $normalized = DocblockNormalizer::normalize($typeString); + $typeNode = self::parseTypeString($normalized); - $typeNode = self::resolveCallerContext($typeNode); - self::$resolvedTypeNodeCache[$cacheKey] = $typeNode; + if ($file !== '') { + $typeNode = SpecialTypeResolver::resolveForFile($typeNode, $file); } + $typeNode = self::resolveCallerContext($typeNode); + if (! self::shouldValidateType($typeNode, $config)) { return $value; } @@ -224,17 +210,21 @@ private static function hasActiveInlineChecks(array $config): bool } /** - * Resolves caller class context and applies class-level type aliases to the AST. + * Resolves caller class context and applies class-level and method-level templates & type aliases to the AST. */ private static function resolveCallerContext(TypeNode $typeNode): TypeNode { $className = null; - $trace = debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 7); + $methodName = null; + $thisObj = null; + $trace = debug_backtrace(DEBUG_BACKTRACE_PROVIDE_OBJECT, 7); foreach ($trace as $frame) { $classCandidate = $frame['class'] ?? null; if ($classCandidate !== null && ! str_starts_with($classCandidate, 'TypePHP\\Internal\\') && ! str_starts_with($classCandidate, 'TypePHP\\Wrapper\\')) { $className = $classCandidate; + $methodName = $frame['function']; + $thisObj = $frame['object'] ?? null; break; } @@ -245,12 +235,25 @@ private static function resolveCallerContext(TypeNode $typeNode): TypeNode } try { + /** @var class-string $className */ $refClass = new \ReflectionClass($className); $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass); $classAliases = ContractParser::parseClassAliases($className); - if (\count($classAliases) > 0) { - $typeNode = TemplateSubstitutor::substitute($typeNode, $classAliases); + + $targetFunc = ($methodName !== '{closure}') + ? $className . '::' . $methodName + : $className . '::__construct'; + + $contract = ContractParser::parse($targetFunc); + $declaredTemplates = $contract['allTemplates'] ?? ($contract['classTemplates'] ?? []); + $boundTemplates = TemplateManager::getBoundTemplates($targetFunc, $thisObj, $declaredTemplates); + + $activeBindings = array_merge($classAliases, $boundTemplates); + + if (\count($activeBindings) > 0 || \count($declaredTemplates) > 0) { + $typeNode = TemplateSubstitutor::substitute($typeNode, $activeBindings, $declaredTemplates); + $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass); } } catch (\ReflectionException $e) { // Silently continue if reflection fails @@ -276,6 +279,7 @@ private static function substitutePropertyGenerics(TypeNode $typeNode, object $o if (class_exists($className) || interface_exists($className) || trait_exists($className)) { try { + /** @var class-string $className */ $refClass = new \ReflectionClass($className); $typeNode = SpecialTypeResolver::resolve($typeNode, $refClass); } catch (\ReflectionException $e) { diff --git a/src/Internal/ContractVisitor.php b/src/Internal/ContractVisitor.php index 1c513976..75e19df8 100644 --- a/src/Internal/ContractVisitor.php +++ b/src/Internal/ContractVisitor.php @@ -75,10 +75,15 @@ public function enterNode(Node $node): array|int|null $extracted = DocblockExtractor::extractVarTagFromDoc($doc->getText()); if ($extracted !== null) { [$typeString, $varName] = $extracted; - $effectiveVarName = ($varName !== '') ? $varName : 'return'; - $checkCall = NodeBuilder::createVariableCheckCall($node->expr, $typeString, $effectiveVarName); - $node->expr = NodeBuilder::createTernaryThrowExpr($checkCall, $node->getStartLine()); - $node->setAttribute('typephp_var_wrapped', true); + $isApplicableToReturn = ($varName === '') + || ($node->expr instanceof Node\Expr\Variable && $node->expr->name === $varName); + + if ($isApplicableToReturn) { + $effectiveVarName = ($varName !== '') ? $varName : 'return'; + $checkCall = NodeBuilder::createVariableCheckCall($node->expr, $typeString, $effectiveVarName); + $node->expr = NodeBuilder::createTernaryThrowExpr($checkCall, $node->getStartLine()); + $node->setAttribute('typephp_var_wrapped', true); + } } } } diff --git a/tests/TypeChecking/Generics/ClassTemplateInlineVarTest.php b/tests/TypeChecking/Generics/ClassTemplateInlineVarTest.php new file mode 100644 index 00000000..0f161851 --- /dev/null +++ b/tests/TypeChecking/Generics/ClassTemplateInlineVarTest.php @@ -0,0 +1,181 @@ + $searchResult */ + $searchResult = new ShopwareEntitySearchResult($collection); + + return $searchResult; + } + + /** + * Case 5: Method-level generic template in inline variable + * + * @template TItem of object + * + * @param TItem $item + */ + public function processItem(object $item): object + { + /** @var TItem $localItem */ + $localItem = $item; + + return $localItem; + } + + /** + * Case 6: Assigning an invalid object violating the template upper bound + */ + public function assignInvalidObject(): void + { + /** @var TEntityCollection $bad */ + $bad = new Car(); // Car is not a ShopwareCollection! + } +} + +/** + * Subclass binding TEntityCollection to SpecificDogSearchResult + * + * @extends ClassTemplateInlineVarRepository + */ +class ConcreteDogRepository extends ClassTemplateInlineVarRepository +{ + public function searchSpecificDog(): object + { + /** @var TEntityCollection $dogResult */ + $dogResult = new SpecificDogSearchResult(); + + return $dogResult; + } + + public function searchWrongCollection(): object + { + /** @var TEntityCollection $wrongResult */ + $wrongResult = new ShopwareEntityCollection(); + + return $wrongResult; + } +} + +describe('Class-Level & Method-Level Template Resolution in Inline @var Annotations', function () { + test('resolves named @var on return expression constructing new object (Exact Shopware Line 129)', function () { + $repo = new ClassTemplateInlineVarRepository(); + expect($repo->searchConstructsResult())->toBeInstanceOf(ShopwareEntitySearchResult::class); + }); + + test('resolves named @var where return expression is the variable', function () { + $repo = new ClassTemplateInlineVarRepository(); + expect($repo->searchDirectReturn())->toBeInstanceOf(ShopwareEntityCollection::class); + }); + + test('resolves unnamed @var directly on return statement', function () { + $repo = new ClassTemplateInlineVarRepository(); + expect($repo->searchUnnamedReturn())->toBeInstanceOf(ShopwareEntityCollection::class); + }); + + test('resolves template used inside generic container on inline @var', function () { + $repo = new ClassTemplateInlineVarRepository(); + expect($repo->searchWithGenericContainer())->toBeInstanceOf(ShopwareEntitySearchResult::class); + }); + + test('resolves method-level template in inline @var variable', function () { + $repo = new ClassTemplateInlineVarRepository(); + $dog = new Dog(); + + expect($repo->processItem($dog))->toBe($dog); + }); + + test('throws TypeError when inline variable violates template upper bound', function () { + $repo = new ClassTemplateInlineVarRepository(); + + expect(fn () => $repo->assignInvalidObject()) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Collections\ShopwareCollection') + ; + }); + + test('resolves pre-bound template in subclass on inline @var variable', function () { + $repo = new ConcreteDogRepository(); + + expect($repo->searchSpecificDog())->toBeInstanceOf(SpecificDogSearchResult::class); + + expect(fn () => $repo->searchWrongCollection()) + ->toThrow(TypeError::class, 'must be of type TypePHP\Tests\Fixtures\Collections\SpecificDogSearchResult') + ; + }); + + test('respects inline_vars configuration toggles when disabled', function () { + try { + $repo = new ClassTemplateInlineVarRepository(); + + expect(fn () => $repo->assignInvalidObject()) + ->toThrow(TypeError::class) + ; + + Config::set([ + 'inline_vars' => [ + 'objects' => false, + 'generics' => false, + ], + ]); + + $repo->assignInvalidObject(); + expect(true)->toBeTrue(); + } finally { + Config::reset(); + } + }); +});