From ea2dddf410bc973cb4a2c80e5b6081be090b2757 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Fri, 17 Jul 2026 16:23:40 -0500 Subject: [PATCH] fix: resolve static/self/parent relative to @param-closure-this bound class When a closure or arrow function has its $this type overridden via @param-closure-this, the static/self/parent keywords in return type declarations were still resolving to the enclosing class instead of the bound class. Two fixes were needed: 1. Set inClosureBindScopeClasses when applying @param-closure-this so that resolveTypeByName and resolveName use the bound class for static/self resolution in expressions like static method calls. 2. Override the InitializerExprContext in getFunctionType when inClosureBindScopeClasses is set, so that static/self/parent in return type declarations (processed via ParserNodeTypeToPHPStanType) also resolve to the bound class instead of the enclosing class. --- src/Analyser/MutatingScope.php | 44 ++++++++++++++++++- src/Analyser/NodeScopeResolver.php | 6 ++- .../Analyser/nsrt/param-closure-this.php | 28 ++++++++++++ 3 files changed, 75 insertions(+), 3 deletions(-) diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 1f843048b25..b4da779f335 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2020,7 +2020,7 @@ public function restoreThis(self $restoreThisScope): self $expressionTypes, $nativeExpressionTypes, $this->conditionalExpressions, - $this->inClosureBindScopeClasses, + $restoreThisScope->inClosureBindScopeClasses, $this->anonymousFunctionReflection, $this->inFirstLevelStatement, [], @@ -2059,6 +2059,31 @@ public function isInClosureBind(): bool return $this->inClosureBindScopeClasses !== []; } + /** + * @param list $scopeClasses + */ + public function withClosureBindScopeClasses(array $scopeClasses): self + { + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $this->expressionTypes, + $this->nativeExpressionTypes, + $this->conditionalExpressions, + $scopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + /** * @api * @param ParameterReflection[]|null $callableParameters @@ -2397,6 +2422,23 @@ public function getFunctionType($type, bool $isNullable, bool $isVariadic): Type false, )), new AccessoryArrayListType()]); } + if ( + $type instanceof Name + && $this->inClosureBindScopeClasses !== [] + && $this->inClosureBindScopeClasses !== ['static'] + && in_array($type->toLowerString(), ['static', 'self', 'parent'], true) + && $this->reflectionProvider->hasClass($this->inClosureBindScopeClasses[0]) + ) { + return $this->initializerExprTypeResolver->getFunctionType( + $type, + $isNullable, + false, + InitializerExprContext::fromClassReflection( + $this->reflectionProvider->getClass($this->inClosureBindScopeClasses[0]), + ), + ); + } + return $this->initializerExprTypeResolver->getFunctionType($type, $isNullable, false, InitializerExprContext::fromScope($this)); } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index cb407c048ad..a4bbb443c6d 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3656,7 +3656,8 @@ public function processArgs( $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); if ($closureThisType !== null) { $restoreThisScope = $scopeToPass; - $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()); + $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()) + ->withClosureBindScopeClasses($closureThisType->getObjectClassNames()); } } @@ -3722,7 +3723,8 @@ public function processArgs( ) { $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); if ($closureThisType !== null) { - $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()); + $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()) + ->withClosureBindScopeClasses($closureThisType->getObjectClassNames()); } } diff --git a/tests/PHPStan/Analyser/nsrt/param-closure-this.php b/tests/PHPStan/Analyser/nsrt/param-closure-this.php index 96f9fb8f159..ec32bbde8df 100644 --- a/tests/PHPStan/Analyser/nsrt/param-closure-this.php +++ b/tests/PHPStan/Analyser/nsrt/param-closure-this.php @@ -316,3 +316,31 @@ public function doFoo(): void } } + +class StaticReturnClosureThis +{ + + /** + * @param-closure-this Foo $cb + */ + public function withFoo(callable $cb): void + { + + } + +} + +class TestStaticResolutionInParamClosureThis +{ + + public function test(StaticReturnClosureThis $o): void + { + $o->withFoo(function (): static { + assertType('ParamClosureThis\Foo', $this); + return $this; + }); + + $o->withFoo(fn (): static => $this); + } + +}