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
52 changes: 28 additions & 24 deletions src/Internal/Checker/InlineChecker.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, TypeNode>
*/
private static array $resolvedTypeNodeCache = [];

/**
* Resets internal type node caches. Useful for test isolation.
*/
public static function reset(): void
{
self::$parsedTypeNodeCache = [];
self::$resolvedTypeNodeCache = [];
}

/**
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -245,12 +235,25 @@ private static function resolveCallerContext(TypeNode $typeNode): TypeNode
}

try {
/** @var class-string<object> $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
Expand All @@ -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<object> $className */
$refClass = new \ReflectionClass($className);
$typeNode = SpecialTypeResolver::resolve($typeNode, $refClass);
} catch (\ReflectionException $e) {
Expand Down
13 changes: 9 additions & 4 deletions src/Internal/ContractVisitor.php
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
}
}
Expand Down
181 changes: 181 additions & 0 deletions tests/TypeChecking/Generics/ClassTemplateInlineVarTest.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
<?php

declare(strict_types=1);

namespace TypePHP\Tests\TypeChecking\Generics;

use TypePHP\Exception\TypeError;
use TypePHP\Internal\Config;
use TypePHP\Tests\Fixtures\Collections\ShopwareCollection;
use TypePHP\Tests\Fixtures\Collections\ShopwareEntityCollection;
use TypePHP\Tests\Fixtures\Collections\ShopwareEntitySearchResult;
use TypePHP\Tests\Fixtures\Collections\SpecificDogSearchResult;
use TypePHP\Tests\Fixtures\Domain\Car;
use TypePHP\Tests\Fixtures\Domain\Dog;

/**
* @template TEntityCollection of ShopwareCollection
*/
class ClassTemplateInlineVarRepository
{
/**
* Case 1: Exact Shopware Line 129 Pattern Named @var $result on a return expression constructing SearchResult
*/
public function searchConstructsResult(): object
{
$result = new ShopwareEntityCollection();

/** @var TEntityCollection $result */
return new ShopwareEntitySearchResult($result);
}

/**
* Case 2: Named @var $result where the return expression IS $result
*/
public function searchDirectReturn(): object
{
/** @var TEntityCollection $result */
$result = new ShopwareEntityCollection();

return $result;
}

/**
* Case 3: Unnamed @var on return statement
*/
public function searchUnnamedReturn(): object
{
/** @var TEntityCollection */
return new ShopwareEntityCollection();
}

/**
* Case 4: Template used inside Generic Container on inline variable
*/
public function searchWithGenericContainer(): object
{
$collection = new ShopwareEntityCollection();

/** @var ShopwareEntitySearchResult<TEntityCollection> $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<SpecificDogSearchResult>
*/
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();
}
});
});
Loading