From 244b1d4c6cfb8b17b39719c5f309aaee47ec6a20 Mon Sep 17 00:00:00 2001 From: Caleb White Date: Sat, 18 Jul 2026 23:19:13 -0500 Subject: [PATCH] feat: support nullable and never types in @param-closure-this MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit @param-closure-this ?Foo $cb marks a closure parameter whose $this binding is optional — the closure may be invoked bound or unbound. PHPStan propagates $this into the closure body with Maybe certainty, so isset($this) correctly narrows to Foo and accessing $this without the guard triggers a 'Variable $this might not be defined' error. @param-closure-this never $cb marks a closure that is always invoked unbound. $this inside the body has type never, making any access a type error. Implementation: - PhpDocNodeResolver: for nullable union types (?Foo), strip null, intersect the object part with ObjectWithoutClassType, then re-add null as an optional-binding signal that threads through the parameter reflection chain - NodeScopeResolver: three-way dispatch on the resolved closureThisType — definitely null uses withoutThis(), nullable union uses assignVariable with Maybe certainty, non-nullable uses the original Yes certainty path - MutatingScope::withoutThis(): removes $this from scope for the always-unbound case - MutatingScope::enterAnonymousFunctionWithoutReflection: checks isset(expressionTypes['$this']) and propagates $this with its original certainty (Yes or Maybe) into the closure scope, enabling isset($this) narrowing through the standard variable-certainty path --- src/Analyser/MutatingScope.php | 60 +++++++++++++++---- src/Analyser/NodeScopeResolver.php | 11 +++- src/PhpDoc/PhpDocNodeResolver.php | 12 ++-- .../Analyser/nsrt/param-closure-this.php | 37 ++++++++++++ .../Variables/DefinedVariableRuleTest.php | 14 +++++ .../data/param-closure-this-optional.php | 34 +++++++++++ 6 files changed, 149 insertions(+), 19 deletions(-) create mode 100644 tests/PHPStan/Rules/Variables/data/param-closure-this-optional.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index b4da779f33..1086294ff8 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -2084,6 +2084,33 @@ public function withClosureBindScopeClasses(array $scopeClasses): self ); } + public function withoutThis(): self + { + $expressionTypes = $this->expressionTypes; + $nativeExpressionTypes = $this->nativeExpressionTypes; + unset($expressionTypes['$this']); + unset($nativeExpressionTypes['$this']); + + return $this->scopeFactory->create( + $this->context, + $this->isDeclareStrictTypes(), + $this->getFunction(), + $this->getNamespace(), + $expressionTypes, + $nativeExpressionTypes, + $this->conditionalExpressions, + $this->inClosureBindScopeClasses, + $this->anonymousFunctionReflection, + $this->isInFirstLevelStatement(), + $this->currentlyAssignedExpressions, + $this->currentlyAllowedUndefinedExpressions, + $this->inFunctionCallsStack, + $this->afterExtractCall, + $this->parentScope, + $this->nativeTypesPromoted, + ); + } + /** * @api * @param ParameterReflection[]|null $callableParameters @@ -2203,25 +2230,32 @@ public function enterAnonymousFunctionWithoutReflection( $expressionTypes[$exprString] = $typeHolder; } - if ($this->hasVariableType('this')->yes() && !$closure->static) { + if (isset($this->expressionTypes['$this']) && !$closure->static) { $node = new Variable('this'); - $expressionTypes['$this'] = ExpressionTypeHolder::createYes($node, $this->getType($node)); - $nativeTypes['$this'] = ExpressionTypeHolder::createYes($node, $this->getNativeType($node)); + $thisType = $this->getType($node); + $thisCertainty = $this->expressionTypes['$this']->getCertainty(); + if ($thisCertainty->yes()) { + $expressionTypes['$this'] = ExpressionTypeHolder::createYes($node, $thisType); + $nativeTypes['$this'] = ExpressionTypeHolder::createYes($node, $this->getNativeType($node)); - if ($this->phpVersion->supportsReadOnlyProperties()) { - foreach ($nonStaticExpressions as $exprString => $typeHolder) { - $expr = $typeHolder->getExpr(); + if ($this->phpVersion->supportsReadOnlyProperties()) { + foreach ($nonStaticExpressions as $exprString => $typeHolder) { + $expr = $typeHolder->getExpr(); - if (!$expr instanceof PropertyFetch) { - continue; - } + if (!$expr instanceof PropertyFetch) { + continue; + } - if (!$this->isReadonlyPropertyFetch($expr, true)) { - continue; - } + if (!$this->isReadonlyPropertyFetch($expr, true)) { + continue; + } - $expressionTypes[$exprString] = $typeHolder; + $expressionTypes[$exprString] = $typeHolder; + } } + } else { + $expressionTypes['$this'] = ExpressionTypeHolder::createMaybe($node, $thisType); + $nativeTypes['$this'] = ExpressionTypeHolder::createMaybe($node, $this->getNativeType($node)); } } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 72f087b400..4780239913 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -3656,8 +3656,15 @@ public function processArgs( $closureThisType = $this->resolveClosureThisType($callLike, $calleeReflection, $parameter, $scopeToPass); if ($closureThisType !== null) { $restoreThisScope = $scopeToPass; - $scopeToPass = $scopeToPass->assignVariable('this', $closureThisType, new ObjectWithoutClassType(), TrinaryLogic::createYes()) - ->withClosureBindScopeClasses($closureThisType->getObjectClassNames()); + if ($closureThisType->isNull()->yes()) { + $scopeToPass = $scopeToPass->withoutThis() + ->withClosureBindScopeClasses([]); + } else { + $isOptionalBinding = $closureThisType->isNull()->maybe(); + $objectThisType = TypeCombinator::removeNull($closureThisType); + $scopeToPass = $scopeToPass->assignVariable('this', $objectThisType, new ObjectWithoutClassType(), $isOptionalBinding ? TrinaryLogic::createMaybe() : TrinaryLogic::createYes()) + ->withClosureBindScopeClasses($objectThisType->getObjectClassNames()); + } } } diff --git a/src/PhpDoc/PhpDocNodeResolver.php b/src/PhpDoc/PhpDocNodeResolver.php index 2304edc149..9de18d6e5a 100644 --- a/src/PhpDoc/PhpDocNodeResolver.php +++ b/src/PhpDoc/PhpDocNodeResolver.php @@ -38,6 +38,7 @@ use PHPStan\Type\Generic\TemplateTypeScope; use PHPStan\Type\Generic\TemplateTypeVariance; use PHPStan\Type\MixedType; +use PHPStan\Type\NullType; use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -412,11 +413,14 @@ public function resolveParamClosureThisTags(PhpDocNode $phpDocNode, NameScope $n foreach (['@param-closure-this', '@phpstan-param-closure-this'] as $tagName) { foreach ($phpDocNode->getParamClosureThisTagValues($tagName) as $tagValue) { $parameterName = substr($tagValue->parameterName, 1); + $resolvedType = $this->typeNodeResolver->resolve($tagValue->type, $nameScope); + $isOptionalBinding = $resolvedType->isNull()->maybe(); + $objectType = TypeCombinator::intersect( + TypeCombinator::removeNull($resolvedType), + new ObjectWithoutClassType(), + ); $closureThisTypes[$parameterName] = new ParamClosureThisTag( - TypeCombinator::intersect( - $this->typeNodeResolver->resolve($tagValue->type, $nameScope), - new ObjectWithoutClassType(), - ), + $isOptionalBinding ? TypeCombinator::union($objectType, new NullType()) : $objectType, ); } } diff --git a/tests/PHPStan/Analyser/nsrt/param-closure-this.php b/tests/PHPStan/Analyser/nsrt/param-closure-this.php index 6486cf0952..59ede61859 100644 --- a/tests/PHPStan/Analyser/nsrt/param-closure-this.php +++ b/tests/PHPStan/Analyser/nsrt/param-closure-this.php @@ -317,6 +317,43 @@ public function doFoo(): void } +class WithOptionalThis +{ + + /** + * @param-closure-this ?Foo $cb + */ + public function runOptional(callable $cb): void + { + + } + + /** + * @param-closure-this never $cb + */ + public function runUnbound(callable $cb): void + { + + } + +} + +function testOptionalClosureThis(WithOptionalThis $o): void +{ + $o->runOptional(function () { + if (isset($this)) { + assertType(Foo::class, $this); + } + }); +} + +function testUnboundClosureThis(WithOptionalThis $o): void +{ + $o->runUnbound(function () { + assertType('never', $this); + }); +} + class FooParent { diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index ca23663401..abbecb8725 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -1701,4 +1701,18 @@ public function testBug10090(): void ]); } + public function testParamClosureThisOptional(): void + { + $this->cliArgumentsVariablesRegistered = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->polluteScopeWithAlwaysIterableForeach = false; + $this->analyse([__DIR__ . '/data/param-closure-this-optional.php'], [ + [ + 'Variable $this might not be defined.', + 29, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/param-closure-this-optional.php b/tests/PHPStan/Rules/Variables/data/param-closure-this-optional.php new file mode 100644 index 0000000000..4689d82361 --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/param-closure-this-optional.php @@ -0,0 +1,34 @@ +runOptional(function () { + $this->method(); // Variable $this might not be defined. + if (isset($this)) { + $this->method(); // no error + } + }); +}