From f4349d47f5cb86a4ddd7b56df3ce2e53008b2a88 Mon Sep 17 00:00:00 2001 From: Levi Klingler Date: Mon, 11 May 2026 18:04:39 -0400 Subject: [PATCH 1/2] resolve @template bindings when inferring Arrayable and reflector return types --- composer.lock | 16 +-- src/Analyzed/ClassLikeResult.php | 25 ++++ src/Analyzer/ArrayableResolver.php | 31 ++++- src/Concerns/SubstitutesTemplateBindings.php | 41 ++++++ .../Shared/ParsesClassLikeDocBlock.php | 4 + .../Shared/ResolvesMethodCalls.php | 11 ++ src/NodeResolvers/Stmt/Class_.php | 4 + src/Parser/DocBlockParser.php | 35 ++++- src/Reflector/Reflector.php | 38 +++++- tests/Unit/Analyzed/ClassLikeResultTest.php | 47 +++++++ tests/Unit/Analyzer/ArrayableResolverTest.php | 121 ++++++++++++++++++ tests/Unit/AnalyzerTest.php | 91 +++++++++++++ 12 files changed, 447 insertions(+), 17 deletions(-) create mode 100644 src/Concerns/SubstitutesTemplateBindings.php create mode 100644 tests/Unit/Analyzer/ArrayableResolverTest.php diff --git a/composer.lock b/composer.lock index bc9449a..13fd1f8 100644 --- a/composer.lock +++ b/composer.lock @@ -1055,16 +1055,16 @@ }, { "name": "laravel/framework", - "version": "v13.7.0", + "version": "v13.9.0", "source": { "type": "git", "url": "https://github.com/laravel/framework.git", - "reference": "f13b85b2cce7ef5e8f3bcdf2b6c6364bbdedae0b" + "reference": "a0c6ad03b380287015287d8d5a0fa2459e2332fd" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/laravel/framework/zipball/f13b85b2cce7ef5e8f3bcdf2b6c6364bbdedae0b", - "reference": "f13b85b2cce7ef5e8f3bcdf2b6c6364bbdedae0b", + "url": "https://api.github.com/repos/laravel/framework/zipball/a0c6ad03b380287015287d8d5a0fa2459e2332fd", + "reference": "a0c6ad03b380287015287d8d5a0fa2459e2332fd", "shasum": "" }, "require": { @@ -1105,8 +1105,8 @@ "symfony/http-kernel": "^7.4.0 || ^8.0.0", "symfony/mailer": "^7.4.0 || ^8.0.0", "symfony/mime": "^7.4.0 || ^8.0.0", - "symfony/polyfill-php84": "^1.33", - "symfony/polyfill-php85": "^1.33", + "symfony/polyfill-php84": "^1.36", + "symfony/polyfill-php85": "^1.36", "symfony/polyfill-php86": "^1.36", "symfony/process": "^7.4.5 || ^8.0.5", "symfony/routing": "^7.4.0 || ^8.0.0", @@ -1168,7 +1168,7 @@ "aws/aws-sdk-php": "^3.322.9", "ext-gmp": "*", "fakerphp/faker": "^1.24", - "guzzlehttp/psr7": "^2.4", + "guzzlehttp/psr7": "^2.9", "laravel/pint": "^1.18", "league/flysystem-aws-s3-v3": "^3.25.1", "league/flysystem-ftp": "^3.25.1", @@ -1275,7 +1275,7 @@ "issues": "https://github.com/laravel/framework/issues", "source": "https://github.com/laravel/framework" }, - "time": "2026-04-28T17:18:25+00:00" + "time": "2026-05-13T15:38:40+00:00" }, { "name": "laravel/prompts", diff --git a/src/Analyzed/ClassLikeResult.php b/src/Analyzed/ClassLikeResult.php index 54f699c..b153028 100644 --- a/src/Analyzed/ClassLikeResult.php +++ b/src/Analyzed/ClassLikeResult.php @@ -5,6 +5,7 @@ use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; use Laravel\Surveyor\Analysis\EntityType; +use Laravel\Surveyor\Types\TemplateTagType; use Laravel\Surveyor\Types\Type; class ClassLikeResult @@ -21,6 +22,9 @@ class ClassLikeResult /** @var array */ protected array $methods = []; + /** @var array */ + protected array $templateTags = []; + protected bool $arrayable = false; /** @@ -205,4 +209,25 @@ public function getUse(string $name): ?string { return $this->uses[$name] ?? null; } + + public function addTemplateTag(TemplateTagType $tag): void + { + $this->templateTags[$tag->name] = $tag; + } + + /** @return array */ + public function templateTags(): array + { + return $this->templateTags; + } + + public function hasTemplateTag(string $name): bool + { + return isset($this->templateTags[$name]); + } + + public function getTemplateTag(string $name): ?TemplateTagType + { + return $this->templateTags[$name] ?? null; + } } diff --git a/src/Analyzer/ArrayableResolver.php b/src/Analyzer/ArrayableResolver.php index ba6a6a8..22e0f8a 100644 --- a/src/Analyzer/ArrayableResolver.php +++ b/src/Analyzer/ArrayableResolver.php @@ -5,14 +5,18 @@ use Illuminate\Contracts\Support\Arrayable; use JsonSerializable; use Laravel\Surveyor\Analyzed\ClassLikeResult; +use Laravel\Surveyor\Concerns\SubstitutesTemplateBindings; use Laravel\Surveyor\Types\ArrayType; use Laravel\Surveyor\Types\ClassType; use Laravel\Surveyor\Types\Contracts\Type as TypeContract; +use Laravel\Surveyor\Types\StringType; use ReflectionClass; use Throwable; class ArrayableResolver { + use SubstitutesTemplateBindings; + public function __construct( protected Analyzer $analyzer, ) { @@ -67,7 +71,7 @@ public function resolve(TypeContract $type): ?TypeContract $returnType = $analyzed->getMethod('toArray')->returnType(); if ($returnType instanceof ArrayType) { - return $returnType; + return $this->substituteTemplateBindings($type, $analyzed, $returnType); } } @@ -75,10 +79,33 @@ public function resolve(TypeContract $type): ?TypeContract $returnType = $analyzed->getMethod('jsonSerialize')->returnType(); if ($returnType instanceof ArrayType) { - return $returnType; + return $this->substituteTemplateBindings($type, $analyzed, $returnType); } } return null; } + + protected function substituteTemplateBindings(ClassType $callerType, ClassLikeResult $analyzed, ArrayType $resolved): ArrayType + { + $templateTags = array_values($analyzed->templateTags()); + $genericTypes = array_values($callerType->genericTypes()); + + if (empty($templateTags) || empty($genericTypes)) { + return $resolved; + } + + $bindings = []; + foreach ($templateTags as $i => $tag) { + if (isset($genericTypes[$i]) && ! $genericTypes[$i] instanceof StringType) { + $bindings[$tag->name] = $genericTypes[$i]; + } + } + + if (empty($bindings)) { + return $resolved; + } + + return $this->substituteInArrayType($resolved, $bindings); + } } diff --git a/src/Concerns/SubstitutesTemplateBindings.php b/src/Concerns/SubstitutesTemplateBindings.php new file mode 100644 index 0000000..bb214ff --- /dev/null +++ b/src/Concerns/SubstitutesTemplateBindings.php @@ -0,0 +1,41 @@ +value]) => $bindings[$type->value], + $type instanceof ArrayShapeType => new ArrayShapeType( + $this->substituteInType($type->keyType, $bindings), + $this->substituteInType($type->valueType, $bindings), + ), + $type instanceof ArrayType => $this->substituteInArrayType($type, $bindings), + $type instanceof UnionType => Type::union(...array_map(fn ($t) => $this->substituteInType($t, $bindings), $type->types)), + $type instanceof ClassType && ! empty($type->genericTypes()) => (clone $type)->setGenericTypes( + array_map(fn ($g) => $this->substituteInType($g, $bindings), $type->genericTypes()) + ), + default => $type, + }; + } + + protected function substituteInArrayType(ArrayType $type, array $bindings): ArrayType + { + $newValues = []; + foreach ($type->value as $key => $value) { + $newValues[$key] = $this->substituteInType($value, $bindings); + } + + return new ArrayType($newValues); + } +} diff --git a/src/NodeResolvers/Shared/ParsesClassLikeDocBlock.php b/src/NodeResolvers/Shared/ParsesClassLikeDocBlock.php index 5e47de4..61dea33 100644 --- a/src/NodeResolvers/Shared/ParsesClassLikeDocBlock.php +++ b/src/NodeResolvers/Shared/ParsesClassLikeDocBlock.php @@ -51,5 +51,9 @@ protected function parseClassLikeDocBlock(Node\Stmt\ClassLike $node, ClassLikeRe $result->addMethod($methodResult); } + + foreach ($this->docBlockParser->resolveTemplateTags($node->getDocComment()) as $tag) { + $result->addTemplateTag($tag); + } } } diff --git a/src/NodeResolvers/Shared/ResolvesMethodCalls.php b/src/NodeResolvers/Shared/ResolvesMethodCalls.php index c56f56b..2a769b9 100644 --- a/src/NodeResolvers/Shared/ResolvesMethodCalls.php +++ b/src/NodeResolvers/Shared/ResolvesMethodCalls.php @@ -3,6 +3,7 @@ namespace Laravel\Surveyor\NodeResolvers\Shared; use Illuminate\Http\Request; +use Illuminate\Support\Collection; use Illuminate\Support\Facades\Request as RequestFacade; use Laravel\Surveyor\Concerns\LazilyLoadsDependencies; use Laravel\Surveyor\Types\ClassType; @@ -58,6 +59,16 @@ protected function resolveMethodCall(Node\Expr\MethodCall|Node\Expr\NullsafeMeth return $this->resolveResourceConditional($var, $methodName->value, $node); } + if ( + $methodName->value === 'toArray' + && count($var->genericTypes()) >= 2 + && is_a($this->scope->getUse($var->value), Collection::class, true) + ) { + $genericTypes = array_values($var->genericTypes()); + + return Type::arrayShape($genericTypes[0], $genericTypes[1]); + } + return Type::union( ...$this->reflector->methodReturnType( $this->scope->getUse($var->value), diff --git a/src/NodeResolvers/Stmt/Class_.php b/src/NodeResolvers/Stmt/Class_.php index 2ba1727..46462eb 100644 --- a/src/NodeResolvers/Stmt/Class_.php +++ b/src/NodeResolvers/Stmt/Class_.php @@ -50,6 +50,10 @@ public function resolve(Node\Stmt\Class_ $node) $this->parseClassLikeDocBlock($node, $result); + if (! empty($result->templateTags())) { + $this->scope->setTemplateTags(array_values($result->templateTags())); + } + if ($this->extendsResource()) { try { app(ResourceAnalyzer::class)->injectModelProperties($result->name(), $result, $this->scope); diff --git a/src/Parser/DocBlockParser.php b/src/Parser/DocBlockParser.php index f319a6f..ef134f3 100644 --- a/src/Parser/DocBlockParser.php +++ b/src/Parser/DocBlockParser.php @@ -5,6 +5,7 @@ use Illuminate\Support\Arr; use Laravel\Surveyor\Analysis\Scope; use Laravel\Surveyor\Resolvers\DocBlockResolver; +use Laravel\Surveyor\Types\TemplateTagType; use Laravel\Surveyor\Types\Type; // use Laravel\Surveyor\Types\Contracts\Type as TypeContract; // use Laravel\Surveyor\Types\Type as RangerType; @@ -108,11 +109,41 @@ public function parseTemplateTags(string $docBlock): array { $this->parse($docBlock); - $templateTags = array_map(fn ($tag) => $this->resolve($tag), $this->parsed->getTemplateTagValues()); + $allTags = array_merge( + $this->parsed->getTemplateTagValues('@template'), + $this->parsed->getTemplateTagValues('@template-covariant'), + $this->parsed->getTemplateTagValues('@template-contravariant'), + ); + + $templateTags = array_map(fn ($tag) => $this->resolve($tag), $allTags); $this->scope->setTemplateTags($templateTags); - return $this->parsed->getTemplateTagValues(); + return $allTags; + } + + /** + * @return array + */ + public function resolveTemplateTags(string $docBlock): array + { + $this->parse($docBlock); + + $allTags = array_merge( + $this->parsed->getTemplateTagValues('@template'), + $this->parsed->getTemplateTagValues('@template-covariant'), + $this->parsed->getTemplateTagValues('@template-contravariant'), + ); + + $result = []; + foreach ($allTags as $tag) { + $resolved = $this->resolve($tag); + if ($resolved instanceof TemplateTagType) { + $result[$resolved->name] = $resolved; + } + } + + return $result; } public function parseProperties(string $docBlock): array diff --git a/src/Reflector/Reflector.php b/src/Reflector/Reflector.php index 39f3800..2400833 100644 --- a/src/Reflector/Reflector.php +++ b/src/Reflector/Reflector.php @@ -10,7 +10,9 @@ use Illuminate\Database\Eloquent\Model; use Illuminate\Foundation\Application; use Laravel\Surveyor\Analysis\Scope; +use Laravel\Surveyor\Analyzed\ClassLikeResult; use Laravel\Surveyor\Concerns\LazilyLoadsDependencies; +use Laravel\Surveyor\Concerns\SubstitutesTemplateBindings; use Laravel\Surveyor\Debug\Debug; use Laravel\Surveyor\Support\Util; use Laravel\Surveyor\Types\ArrayType; @@ -30,7 +32,7 @@ class Reflector { - use LazilyLoadsDependencies; + use LazilyLoadsDependencies, SubstitutesTemplateBindings; protected Scope $scope; @@ -346,10 +348,20 @@ public function methodReturnType(ClassType|string $class, string $method, ?Node } if (count($returnTypes) === 0 && $reflection->isSubclassOf(Model::class)) { - array_push( - $returnTypes, - ...$this->methodReturnType(Builder::class, $method, $node), - ); + $builderTypes = $this->methodReturnType(Builder::class, $method, $node); + + $builderResult = $this->getAnalyzer() + ->analyzeClass(Builder::class) + ->result(); + + if ($builderResult instanceof ClassLikeResult && ! empty($builderResult->templateTags())) { + $builderTypes = array_map( + fn ($type) => $this->bindCallerTemplates($type, $className, $builderResult), + $builderTypes, + ); + } + + array_push($returnTypes, ...$builderTypes); } if (count($returnTypes) > 0) { @@ -470,4 +482,20 @@ protected function getAppBinding($key) return $this->appBindings[$key] ?? null; } + + protected function bindCallerTemplates(TypeContract $type, string $callerClass, ClassLikeResult $calleeResult): TypeContract + { + $bindings = []; + foreach ($calleeResult->templateTags() as $tag) { + if ($tag->bound instanceof ClassType && is_a($callerClass, $tag->bound->value, true)) { + $bindings[$tag->name] = new ClassType($callerClass); + } + } + + if (empty($bindings)) { + return $type; + } + + return $this->substituteInType($type, $bindings); + } } diff --git a/tests/Unit/Analyzed/ClassLikeResultTest.php b/tests/Unit/Analyzed/ClassLikeResultTest.php index bf1caa7..618e5b9 100644 --- a/tests/Unit/Analyzed/ClassLikeResultTest.php +++ b/tests/Unit/Analyzed/ClassLikeResultTest.php @@ -6,6 +6,7 @@ use Laravel\Surveyor\Analyzed\ClassLikeResult; use Laravel\Surveyor\Analyzed\MethodResult; use Laravel\Surveyor\Analyzed\PropertyResult; +use Laravel\Surveyor\Types\TemplateTagType; use Laravel\Surveyor\Types\Type; uses()->group('results'); @@ -173,6 +174,52 @@ function createClassLikeResult(array $overrides = []): ClassLikeResult }); }); +describe('template tags', function () { + it('starts with no template tags', function () { + $result = createClassLikeResult(); + expect($result->templateTags())->toBe([]); + expect($result->hasTemplateTag('T'))->toBeFalse(); + expect($result->getTemplateTag('T'))->toBeNull(); + }); + + it('stores and retrieves a template tag by name', function () { + $result = createClassLikeResult(); + $tag = new TemplateTagType(name: 'TValue', bound: null, default: null, lowerBound: null, description: null); + + $result->addTemplateTag($tag); + + expect($result->hasTemplateTag('TValue'))->toBeTrue(); + expect($result->getTemplateTag('TValue'))->toBe($tag); + }); + + it('returns all template tags keyed by name', function () { + $result = createClassLikeResult(); + $tKey = new TemplateTagType(name: 'TKey', bound: Type::string(), default: null, lowerBound: null, description: null); + $tValue = new TemplateTagType(name: 'TValue', bound: null, default: null, lowerBound: null, description: null); + + $result->addTemplateTag($tKey); + $result->addTemplateTag($tValue); + + $tags = $result->templateTags(); + expect($tags)->toHaveCount(2); + expect(array_keys($tags))->toBe(['TKey', 'TValue']); + expect($tags['TKey'])->toBe($tKey); + expect($tags['TValue'])->toBe($tValue); + }); + + it('overwrites an existing tag when added with the same name', function () { + $result = createClassLikeResult(); + $first = new TemplateTagType(name: 'T', bound: null, default: null, lowerBound: null, description: null); + $second = new TemplateTagType(name: 'T', bound: Type::int(), default: null, lowerBound: null, description: null); + + $result->addTemplateTag($first); + $result->addTemplateTag($second); + + expect($result->templateTags())->toHaveCount(1); + expect($result->getTemplateTag('T'))->toBe($second); + }); +}); + describe('serialization helpers', function () { it('detects JsonSerializable implementation', function () { $jsonSerializable = createClassLikeResult(['implements' => [JsonSerializable::class]]); diff --git a/tests/Unit/Analyzer/ArrayableResolverTest.php b/tests/Unit/Analyzer/ArrayableResolverTest.php new file mode 100644 index 0000000..c5d4d9c --- /dev/null +++ b/tests/Unit/Analyzer/ArrayableResolverTest.php @@ -0,0 +1,121 @@ +group('integration'); + +beforeAll(function () { + AnalyzedCache::clear(); +}); + +afterAll(function () { + AnalyzedCache::clear(); +}); + +describe('ArrayableResolver substituteTemplateBindings', function () { + it('substitutes @template placeholders in toArray() using caller ClassType generics', function () { + $type = (new ClassType(LengthAwarePaginator::class)) + ->setGenericTypes([new IntType, new ClassType(User::class)]); + + $result = app(ArrayableResolver::class)->resolve($type); + + expect($result)->toBeInstanceOf(ArrayType::class); + + $data = $result->value['data'] ?? null; + expect($data)->not->toBeNull(); + expect($data)->toBeInstanceOf(ArrayShapeType::class); + + // TValue (index 1, ClassType(User)): not the raw placeholder StringType('TValue') + expect($data->valueType)->toBeInstanceOf(ClassType::class); + expect($data->valueType->value)->toBe(User::class); + + // TKey (index 0, IntType): not the raw placeholder StringType('TKey') + expect($data->keyType)->toBeInstanceOf(IntType::class); + }); + + it('returns the resolved ArrayType unchanged when the caller provides no generics', function () { + $type = new ClassType(LengthAwarePaginator::class); + + $result = app(ArrayableResolver::class)->resolve($type); + + expect($result)->toBeInstanceOf(ArrayType::class); + + $data = $result->value['data'] ?? null; + expect($data)->not->toBeNull(); + expect($data)->toBeInstanceOf(ArrayShapeType::class); + expect($data->valueType)->toBeInstanceOf(StringType::class); + }); +}); + +describe('Reflector caller-side template binding', function () { + it('binds TModel to the concrete Model subclass when resolving Builder methods via Reflector', function () { + $fixture = createPhpFixture(' +namespace App\\Test; + +use App\\Models\\User; + +class UserPaginatorController +{ + public function index() + { + return User::paginate(15); + } +} +'); + + $result = app(Analyzer::class)->analyze($fixture)->result(); + $returnType = $result->getMethod('index')->returnType(); + + expect($returnType)->toBeInstanceOf(ClassType::class); + expect($returnType->value)->toBe(LengthAwarePaginator::class); + + $generics = $returnType->genericTypes(); + expect($generics)->toHaveCount(2); + + expect($generics[0])->toBeInstanceOf(IntType::class); + + expect($generics[1])->toBeInstanceOf(ClassType::class); + expect($generics[1]->value)->toBe(User::class); + + unlink($fixture); + }); + + it('produces a fully resolved data shape', function () { + $fixture = createPhpFixture(' +namespace App\\Test; + +use App\\Models\\User; + +class UserPaginatorController +{ + public function index() + { + return User::paginate(15); + } +} +'); + + $result = app(Analyzer::class)->analyze($fixture)->result(); + $returnType = $result->getMethod('index')->returnType(); + $resolved = app(ArrayableResolver::class)->resolve($returnType); + + expect($resolved)->toBeInstanceOf(ArrayType::class); + + $data = $resolved->value['data'] ?? null; + expect($data)->not->toBeNull(); + expect($data)->toBeInstanceOf(ArrayShapeType::class); + expect($data->valueType)->toBeInstanceOf(ClassType::class); + expect($data->valueType->value)->toBe(User::class); + + unlink($fixture); + }); +}); diff --git a/tests/Unit/AnalyzerTest.php b/tests/Unit/AnalyzerTest.php index 30af7cf..bfaaa84 100644 --- a/tests/Unit/AnalyzerTest.php +++ b/tests/Unit/AnalyzerTest.php @@ -10,6 +10,7 @@ use Laravel\Surveyor\Types\ClassType; use Laravel\Surveyor\Types\IntType; use Laravel\Surveyor\Types\StringType; +use Laravel\Surveyor\Types\TemplateTagType; use Laravel\Surveyor\Types\Type; use Laravel\Surveyor\Types\UnionType; use Laravel\Surveyor\Types\VoidType; @@ -624,3 +625,93 @@ class TestController unlink($fixture); }); }); + +describe('template tag storage in ClassLikeResult', function () { + it('stores @template declarations from a class docblock', function () { + $fixture = createPhpFixture(' +namespace App\\Test; + +/** + * @template TKey of array-key + * @template TValue + */ +class TemplatedCollection {} +'); + + $result = app(Analyzer::class)->analyze($fixture)->result(); + + expect($result)->toBeInstanceOf(ClassLikeResult::class); + expect($result->hasTemplateTag('TKey'))->toBeTrue(); + expect($result->hasTemplateTag('TValue'))->toBeTrue(); + + $tKey = $result->getTemplateTag('TKey'); + expect($tKey)->toBeInstanceOf(TemplateTagType::class); + expect($tKey->name)->toBe('TKey'); + expect($tKey->bound)->not->toBeNull(); + + $tValue = $result->getTemplateTag('TValue'); + expect($tValue)->toBeInstanceOf(TemplateTagType::class); + expect($tValue->name)->toBe('TValue'); + expect($tValue->bound)->toBeNull(); + + unlink($fixture); + }); + + it('returns empty templateTags() for a class with no @template annotations', function () { + $fixture = createPhpFixture(' +namespace App\\Test; + +class PlainClass {} +'); + + $result = app(Analyzer::class)->analyze($fixture)->result(); + + expect($result->templateTags())->toBe([]); + + unlink($fixture); + }); + + it('stores @template declarations from an interface docblock', function () { + $fixture = createPhpFixture(' +namespace App\\Test; + +/** + * @template TItem + */ +interface CollectionInterface {} +'); + + $result = app(Analyzer::class)->analyze($fixture)->result(); + + expect($result)->toBeInstanceOf(ClassLikeResult::class); + expect($result->hasTemplateTag('TItem'))->toBeTrue(); + expect($result->getTemplateTag('TItem')->name)->toBe('TItem'); + + unlink($fixture); + }); + + it('substitutes class template names with their bounds in property @var annotations', function () { + $fixture = createPhpFixture(' +namespace App\\Test; + +/** + * @template TKey of array-key + * @template TValue + */ +class TemplatedCollection +{ + /** @var array */ + public $data; +} +'); + + $result = app(Analyzer::class)->analyze($fixture)->result(); + $propType = $result->getProperty('data')->type; + + expect($propType)->toBeInstanceOf(ArrayShapeType::class); + expect($propType->keyType)->toBeInstanceOf(StringType::class); + expect($propType->keyType->value)->toBe('array-key'); + + unlink($fixture); + }); +}); From e7ba12477d196e97987fe04d15bb3e53e507c3cf Mon Sep 17 00:00:00 2001 From: Levi Klingler Date: Wed, 13 May 2026 15:31:12 -0400 Subject: [PATCH 2/2] fix: resolve unnamespaced ClassType method names instead of returning mixed --- .../Shared/ResolvesMethodCalls.php | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/NodeResolvers/Shared/ResolvesMethodCalls.php b/src/NodeResolvers/Shared/ResolvesMethodCalls.php index 2a769b9..7e1d715 100644 --- a/src/NodeResolvers/Shared/ResolvesMethodCalls.php +++ b/src/NodeResolvers/Shared/ResolvesMethodCalls.php @@ -27,19 +27,18 @@ protected function resolveMethodCall(Node\Expr\MethodCall|Node\Expr\NullsafeMeth $methodName = $this->from($node->name); if (! Type::is($methodName, StringType::class) || $methodName->value === null) { - // Method names that happen to match PHP function names resolve as ClassType - // due to Util::isClassOrInterface(). Handle resource conditionals here before - // returning mixed, since methods like when() collide with Laravel's when() helper. - if ( - $methodName instanceof ClassType - && $methodName->value !== null - && in_array($methodName->value, static::$conditionalMethods) - && $this->isJsonResource($var) - ) { - return $this->resolveResourceConditional($var, $methodName->value, $node); - } + // Method names that happen to match PHP function/class names resolve as ClassType + // due to Util::isClassOrInterface(). If the value has no namespace separator it's + // a simple identifier mis-identified as a class; treat it as the method name. + if ($methodName instanceof ClassType && $methodName->value !== null && ! str_contains($methodName->value, '\\')) { + if (in_array($methodName->value, static::$conditionalMethods) && $this->isJsonResource($var)) { + return $this->resolveResourceConditional($var, $methodName->value, $node); + } - return Type::mixed(); + $methodName = Type::string($methodName->value); + } else { + return Type::mixed(); + } } switch ($var->value) {