diff --git a/src/Resolution/Scope.php b/src/Resolution/Scope.php index 768bfbf3..1eb5f3ba 100644 --- a/src/Resolution/Scope.php +++ b/src/Resolution/Scope.php @@ -19,8 +19,8 @@ /** * The lexical scope at a point in a document: the one answer to what encloses - * it — namespace, class-like, and innermost function-like — and what follows - * from that. + * it — namespace, class-like, and the innermost function-like inside that + * class-like — and what follows from that. * * A scope provides everything variable resolution needs: the parameters and * statements that can introduce variables, the class context for self/parent, @@ -62,9 +62,12 @@ public static function at(?Node $node, array $tree): self ) { $classLike ??= $n; } + // A function-like around the class-like is not this scope's: a class + // body sees nothing of the code that declares the class. if ( - $n instanceof Stmt\Function_ || $n instanceof Stmt\ClassMethod - || $n instanceof Closure || $n instanceof ArrowFunction || $n instanceof PropertyHook + $classLike === null + && ($n instanceof Stmt\Function_ || $n instanceof Stmt\ClassMethod + || $n instanceof Closure || $n instanceof ArrowFunction || $n instanceof PropertyHook) ) { $function ??= $n; } @@ -73,7 +76,8 @@ public static function at(?Node $node, array $tree): self } /** - * The scope holding this one's function-like, or null for a global scope. + * The scope holding this one's function-like, or null for a global scope or + * a class body. */ public function outer(): ?self { @@ -100,6 +104,8 @@ public function getParams(): array public function getStatements(): array { return match (true) { + // A class body holds declarations, whose values cannot read a variable. + $this->sourceNode === null && $this->classLike !== null => [], $this->sourceNode === null => $this->namespace->stmts ?? $this->tree, // Arrow functions have an expression body, not a statement list; their // captured variables come from the enclosing scope, not local assignments. diff --git a/tests/Fixtures/TopLevel/this_binding.php b/tests/Fixtures/TopLevel/this_binding.php index ce81a80b..a14290d2 100644 --- a/tests/Fixtures/TopLevel/this_binding.php +++ b/tests/Fixtures/TopLevel/this_binding.php @@ -19,6 +19,10 @@ public function instanceMethod(): void $inStaticClosure = 1; }; $staticArrow = static fn () => $inStaticArrow = 1; + // A class body sees neither the method's variables nor its `$this`. + $anonymous = new class { + public int $inAnonymousClassBody = 1; + }; } public static function staticMethod(\DateTime $param): void @@ -70,3 +74,11 @@ function freeFunction(): void $inClosureInFunction = 1; }; } + +$beforeTopLevelClass = 1; + +// A class body sees none of the file's variables. +class AfterTopLevelCode +{ + public int $inTopLevelClassBody = 1; +} diff --git a/tests/Resolution/VariableBindingsTest.php b/tests/Resolution/VariableBindingsTest.php index f7a3bcf9..3108c313 100644 --- a/tests/Resolution/VariableBindingsTest.php +++ b/tests/Resolution/VariableBindingsTest.php @@ -14,6 +14,7 @@ use PhpParser\Node\Stmt; use PhpParser\NodeFinder; use PHPUnit\Framework\Attributes\CoversClass; +use PHPUnit\Framework\Attributes\DataProvider; use PHPUnit\Framework\TestCase; #[CoversClass(VariableBindings::class)] @@ -178,6 +179,34 @@ public function testThisIsBoundWherePhpBindsIt(): void ); } + #[DataProvider('classBodyProperties')] + public function testAClassBodyBindsNoVariables(string $propertyName): void + { + $ast = self::parseWithParents($this->loadFixture('TopLevel/this_binding.php')); + $property = self::findFirst( + $ast, + fn (Node $n) => $n instanceof Node\PropertyItem && $n->name->toString() === $propertyName, + ); + self::assertInstanceOf(Node\PropertyItem::class, $property); + + self::assertSame( + [], + array_keys(VariableBindings::visible(Scope::at($property, $ast), $property->getEndFilePos())), + 'a property default sees no variables and no $this from what holds the class', + ); + } + + /** + * @return array + */ + public static function classBodyProperties(): array + { + return [ + 'an anonymous class in an instance method' => ['inAnonymousClassBody'], + 'a class after top-level code' => ['inTopLevelClassBody'], + ]; + } + public function testCodeNeverBindsThis(): void { $ast = self::parseWithParents($this->loadFixture('TopLevel/this_binding.php'));