From 6394b9bbc8d217fb4fd6284dab4c06ce2a74dea8 Mon Sep 17 00:00:00 2001 From: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Date: Sun, 26 Apr 2026 18:30:24 +0000 Subject: [PATCH] Make `$this` available as `object` in non-static closures and arrow functions outside class context - In `MutatingScope::enterAnonymousFunctionWithoutReflection()`, add `$this` with type `object` when the closure is non-static and the enclosing scope does not have `$this` and is not inside a `Closure::bind()` call. This removes the false positive "Undefined variable: $this" for closures that will later be bound via `Closure::bind()`. - Apply the same fix in `MutatingScope::enterArrowFunctionWithoutReflection()` for non-static arrow functions outside class context. - Fix `MutatingScope::restoreThis()` to preserve `$this` from the restore scope when not in a class but `$this` is defined (e.g. as `object` in a closure outside a class). Previously it unconditionally removed `$this` when `isInClass()` was false, which caused `$this` to disappear after method calls with `@param-closure-this` inside non-class closures. - Update existing tests that asserted the old buggy behavior. --- src/Analyser/MutatingScope.php | 14 +++++++++ tests/PHPStan/Analyser/nsrt/bug-1348.php | 31 +++++++++++++++++++ .../Analyser/nsrt/param-closure-this.php | 18 +++++------ .../Rules/Debug/DebugScopeRuleTest.php | 6 ++++ .../Variables/DefinedVariableRuleTest.php | 22 ++++++++++--- .../PHPStan/Rules/Variables/data/bug-1348.php | 28 +++++++++++++++++ 6 files changed, 106 insertions(+), 13 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-1348.php create mode 100644 tests/PHPStan/Rules/Variables/data/bug-1348.php diff --git a/src/Analyser/MutatingScope.php b/src/Analyser/MutatingScope.php index 79d4be017bf..6f0dd162e17 100644 --- a/src/Analyser/MutatingScope.php +++ b/src/Analyser/MutatingScope.php @@ -88,6 +88,7 @@ use PHPStan\Type\NeverType; use PHPStan\Type\NullType; use PHPStan\Type\ObjectType; +use PHPStan\Type\ObjectWithoutClassType; use PHPStan\Type\StaticType; use PHPStan\Type\StaticTypeFactory; use PHPStan\Type\StringType; @@ -1911,6 +1912,13 @@ public function restoreThis(self $restoreThisScope): self $nativeExpressionTypes[$exprString] = $expressionTypeHolder; } + } elseif (isset($restoreThisScope->expressionTypes['$this'])) { + $expressionTypes['$this'] = $restoreThisScope->expressionTypes['$this']; + if (isset($restoreThisScope->nativeExpressionTypes['$this'])) { + $nativeExpressionTypes['$this'] = $restoreThisScope->nativeExpressionTypes['$this']; + } else { + unset($nativeExpressionTypes['$this']); + } } else { unset($expressionTypes['$this']); unset($nativeExpressionTypes['$this']); @@ -2107,6 +2115,10 @@ public function enterAnonymousFunctionWithoutReflection( $expressionTypes[$exprString] = $typeHolder; } } + } elseif (!$closure->static && !$this->isInClosureBind()) { + $node = new Variable('this'); + $expressionTypes['$this'] = ExpressionTypeHolder::createYes($node, new ObjectWithoutClassType()); + $nativeTypes['$this'] = ExpressionTypeHolder::createYes($node, new ObjectWithoutClassType()); } $filteredConditionalExpressions = []; @@ -2251,6 +2263,8 @@ public function enterArrowFunctionWithoutReflection(Expr\ArrowFunction $arrowFun if ($arrowFunction->static) { $arrowFunctionScope = $arrowFunctionScope->invalidateExpression(new Variable('this')); + } elseif (!$this->hasVariableType('this')->yes() && !$this->isInClosureBind()) { + $arrowFunctionScope = $arrowFunctionScope->assignVariable('this', new ObjectWithoutClassType(), new ObjectWithoutClassType(), TrinaryLogic::createYes()); } return $this->scopeFactory->create( diff --git a/tests/PHPStan/Analyser/nsrt/bug-1348.php b/tests/PHPStan/Analyser/nsrt/bug-1348.php new file mode 100644 index 00000000000..4a0192c8a6d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-1348.php @@ -0,0 +1,31 @@ + assertType('object', $this); + +class Foo +{ + public function doFoo(): void + { + $closure = function () { + assertType('$this(Bug1348Types\Foo)', $this); + }; + + $arrow = fn() => assertType('$this(Bug1348Types\Foo)', $this); + } +} + +$bound = \Closure::bind( + function () { + assertType('stdClass', $this); + }, + new \stdClass(), + \stdClass::class +); diff --git a/tests/PHPStan/Analyser/nsrt/param-closure-this.php b/tests/PHPStan/Analyser/nsrt/param-closure-this.php index 96f9fb8f159..befa419ce59 100644 --- a/tests/PHPStan/Analyser/nsrt/param-closure-this.php +++ b/tests/PHPStan/Analyser/nsrt/param-closure-this.php @@ -153,36 +153,36 @@ public function interplayWithProcessImmediatelyCalledCallable2(): void } function (Foo $f): void { - assertType('*ERROR*', $this); + assertType('object', $this); $f->paramClosureClass(function () { assertType(Some::class, $this); }); - assertType('*ERROR*', $this); + assertType('object', $this); $f->paramClosureClass(static function () { assertType('*ERROR*', $this); }); - assertType('*ERROR*', $this); + assertType('object', $this); }; function (Foo $f): void { $a = 1; - assertType('*ERROR*', $this); + assertType('object', $this); $f->paramClosureClass(function () use (&$a) { assertType(Some::class, $this); }); - assertType('*ERROR*', $this); + assertType('object', $this); $f->paramClosureClass(static function () use (&$a) { assertType('*ERROR*', $this); }); - assertType('*ERROR*', $this); + assertType('object', $this); }; function (Foo $f): void { - assertType('*ERROR*', $this); + assertType('object', $this); $f->paramClosureClass(fn () => assertType(Some::class, $this)); - assertType('*ERROR*', $this); + assertType('object', $this); $f->paramClosureClass(static fn () => assertType('*ERROR*', $this)); - assertType('*ERROR*', $this); + assertType('object', $this); }; class Bar extends Foo diff --git a/tests/PHPStan/Rules/Debug/DebugScopeRuleTest.php b/tests/PHPStan/Rules/Debug/DebugScopeRuleTest.php index f1bcd81285e..478763c1785 100644 --- a/tests/PHPStan/Rules/Debug/DebugScopeRuleTest.php +++ b/tests/PHPStan/Rules/Debug/DebugScopeRuleTest.php @@ -29,9 +29,11 @@ public function testRuleInPhpStanNamespace(): void '$a (Yes): int', '$b (Yes): int', '$debug (Yes): bool', + '$this (Yes): object', 'native $a (Yes): int', 'native $b (Yes): int', 'native $debug (Yes): bool', + 'native $this (Yes): object', ]), 10, ], @@ -40,10 +42,12 @@ public function testRuleInPhpStanNamespace(): void '$a (Yes): int', '$b (Yes): int', '$debug (Yes): bool', + '$this (Yes): object', '$c (Maybe): 1', 'native $a (Yes): int', 'native $b (Yes): int', 'native $debug (Yes): bool', + 'native $this (Yes): object', 'native $c (Maybe): 1', 'condition about $c #1: if $debug=false then $c is *ERROR* (No)', 'condition about $c #2: if $debug=true then $c is 1 (Yes)', @@ -58,7 +62,9 @@ public function testPr4663(): void $this->analyse([__DIR__ . '/data/pr-4663.php'], [ [ implode("\n", [ + '$this (Yes): object', "\$result (Yes): 'no matches!'", + 'native $this (Yes): object', "native \$result (Yes): 'no matches!'", ]), 11, diff --git a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php index daf1eec4f64..8494dc765c5 100644 --- a/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php +++ b/tests/PHPStan/Rules/Variables/DefinedVariableRuleTest.php @@ -703,10 +703,6 @@ public function testFormerThisVariableRule(): void 'Undefined variable: $this', 20, ], - [ - 'Undefined variable: $this', - 26, - ], [ 'Undefined variable: $this', 38, @@ -1562,4 +1558,22 @@ public function testBug10729(): void ]); } + public function testBug1348(): void + { + $this->cliArgumentsVariablesRegistered = true; + $this->polluteScopeWithLoopInitialAssignments = false; + $this->checkMaybeUndefinedVariables = true; + $this->polluteScopeWithAlwaysIterableForeach = true; + $this->analyse([__DIR__ . '/data/bug-1348.php'], [ + [ + 'Undefined variable: $this', + 25, + ], + [ + 'Undefined variable: $this', + 28, + ], + ]); + } + } diff --git a/tests/PHPStan/Rules/Variables/data/bug-1348.php b/tests/PHPStan/Rules/Variables/data/bug-1348.php new file mode 100644 index 00000000000..e43e5564e7b --- /dev/null +++ b/tests/PHPStan/Rules/Variables/data/bug-1348.php @@ -0,0 +1,28 @@ +foo = 'bar'; +}; + +$object = new \stdClass(); + +\Closure::bind($closure, $object, $object)(); +\Closure::bind( + function () { + $this->foo = 'bar'; + }, + $object, + $object +)(); + +// arrow function case +$arrow = fn() => $this; + +// static closures should still report $this as undefined +static function () { + $this->foo = 'bar'; +}; + +static fn() => $this;