Skip to content

Commit 1fb897e

Browse files
committed
Resolve self/parent/static in Closure::bind() scope for static calls, properties and instantiation
Extend the Closure::bind() scope binding beyond class constants to the natural remaining surface: - MutatingScope::resolveName()/resolveTypeByName() now honour the bind scope annotated on self/parent/static class-name nodes, so static method-call return types, static property types and `new self/parent/static` resolve against the bound class even outside a class context (the closure body's type is inferred in the enclosing scope where the closure-bind scope classes are otherwise absent). - StaticMethodCallCheck, AccessStaticPropertiesCheck and InstantiationRule no longer report "outside of class scope" for self/parent/static inside a bound closure, resolving accessibility against the bound class. - ClosureBindArgVisitor now only rescopes the closure body (1st argument), not the $newThis/$newScope arguments, avoiding a self-referential scope annotation on a `self::class` scope argument. Continues the reconstruction of PR #4081 on 2.2.x.
1 parent feaa4b0 commit 1fb897e

12 files changed

Lines changed: 269 additions & 104 deletions

‎src/Analyser/MutatingScope.php‎

Lines changed: 59 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@
4040
use PHPStan\Node\Printer\ExprPrinter;
4141
use PHPStan\Node\VirtualNode;
4242
use PHPStan\Parser\ArrayMapArgVisitor;
43+
use PHPStan\Parser\ClosureBindArgVisitor;
4344
use PHPStan\Parser\Parser;
4445
use PHPStan\Php\PhpVersion;
4546
use PHPStan\Php\PhpVersionFactory;
@@ -1309,8 +1310,22 @@ private function promoteNativeTypes(): self
13091310
public function resolveName(Name $name): string
13101311
{
13111312
$originalClass = (string) $name;
1313+
$lowerClass = strtolower($originalClass);
1314+
1315+
$bindScopeClassName = $this->resolveClosureBindScopeClassName($name);
1316+
if ($bindScopeClassName !== null) {
1317+
if (in_array($lowerClass, ['self', 'static'], true)) {
1318+
return $bindScopeClassName;
1319+
}
1320+
if ($lowerClass === 'parent' && $this->reflectionProvider->hasClass($bindScopeClassName)) {
1321+
$parentClassReflection = $this->reflectionProvider->getClass($bindScopeClassName)->getParentClass();
1322+
if ($parentClassReflection !== null) {
1323+
return $parentClassReflection->getName();
1324+
}
1325+
}
1326+
}
1327+
13121328
if ($this->isInClass()) {
1313-
$lowerClass = strtolower($originalClass);
13141329
if (in_array($lowerClass, [
13151330
'self',
13161331
'static',
@@ -1333,17 +1348,26 @@ public function resolveName(Name $name): string
13331348
/** @api */
13341349
public function resolveTypeByName(Name $name): TypeWithClassName
13351350
{
1336-
if ($name->toLowerString() === 'static' && $this->isInClass()) {
1337-
if ($this->inClosureBindScopeClasses !== [] && $this->inClosureBindScopeClasses !== ['static']) {
1338-
if ($this->reflectionProvider->hasClass($this->inClosureBindScopeClasses[0])) {
1339-
return new StaticType($this->reflectionProvider->getClass($this->inClosureBindScopeClasses[0]));
1340-
}
1351+
$bindScopeClassName = $this->resolveClosureBindScopeClassName($name);
1352+
if ($name->toLowerString() === 'static') {
1353+
if ($bindScopeClassName !== null && $this->reflectionProvider->hasClass($bindScopeClassName)) {
1354+
return new StaticType($this->reflectionProvider->getClass($bindScopeClassName));
13411355
}
1356+
if ($this->isInClass()) {
1357+
if ($this->inClosureBindScopeClasses !== [] && $this->inClosureBindScopeClasses !== ['static']) {
1358+
if ($this->reflectionProvider->hasClass($this->inClosureBindScopeClasses[0])) {
1359+
return new StaticType($this->reflectionProvider->getClass($this->inClosureBindScopeClasses[0]));
1360+
}
1361+
}
13421362

1343-
return new StaticType($this->getClassReflection());
1363+
return new StaticType($this->getClassReflection());
1364+
}
13441365
}
13451366

13461367
$originalClass = $this->resolveName($name);
1368+
if ($bindScopeClassName !== null && $this->reflectionProvider->hasClass($originalClass)) {
1369+
return new ObjectType($originalClass);
1370+
}
13471371
if ($this->isInClass()) {
13481372
if ($this->inClosureBindScopeClasses === [$originalClass]) {
13491373
if ($this->reflectionProvider->hasClass($originalClass)) {
@@ -1362,6 +1386,34 @@ public function resolveTypeByName(Name $name): TypeWithClassName
13621386
return new ObjectType($originalClass);
13631387
}
13641388

1389+
/**
1390+
* Resolves the class name that a `self`/`parent`/`static` node is bound to by a
1391+
* surrounding `Closure::bind()` call, annotated by {@see ClosureBindArgVisitor}. Returns
1392+
* null when the node is not inside a bound closure or the bind scope argument does not
1393+
* resolve to a single known class (e.g. the default "static" scope). This works even
1394+
* outside a class context, because the type of a bound closure's body is inferred in the
1395+
* enclosing scope where the closure-bind scope classes are not otherwise available.
1396+
*/
1397+
/** @return non-empty-string|null */
1398+
private function resolveClosureBindScopeClassName(Name $name): ?string
1399+
{
1400+
if (!$name->hasAttribute(ClosureBindArgVisitor::SCOPE_ATTRIBUTE_NAME)) {
1401+
return null;
1402+
}
1403+
1404+
$scopeArg = $name->getAttribute(ClosureBindArgVisitor::SCOPE_ATTRIBUTE_NAME);
1405+
if (!$scopeArg instanceof Expr) {
1406+
return null;
1407+
}
1408+
1409+
$objectClassNames = $this->getType($scopeArg)->getClassStringObjectType()->getObjectClassNames();
1410+
if (count($objectClassNames) !== 1) {
1411+
return null;
1412+
}
1413+
1414+
return $objectClassNames[0];
1415+
}
1416+
13651417
/**
13661418
* @api
13671419
* @param mixed $value

‎src/Parser/ClosureBindArgVisitor.php‎

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
use function array_shift;
1414
use function array_unshift;
1515
use function count;
16+
use function spl_object_id;
1617

1718
#[AutowiredService]
1819
final class ClosureBindArgVisitor extends NodeVisitorAbstract
@@ -25,6 +26,15 @@ final class ClosureBindArgVisitor extends NodeVisitorAbstract
2526
/** @var list<?Expr> */
2627
private array $scopeStack = [];
2728

29+
/**
30+
* Maps the object id of an inline closure/arrow function passed as the 1st argument of
31+
* `Closure::bind()` to that call's scope argument (the 3rd argument, or null for the
32+
* default "static" scope). Only the closure body is rescoped, not the other arguments.
33+
*
34+
* @var array<int, ?Expr>
35+
*/
36+
private array $boundClosures = [];
37+
2838
#[Override]
2939
public function enterNode(Node $node): ?Node
3040
{
@@ -39,10 +49,17 @@ public function enterNode(Node $node): ?Node
3949
$args = $node->getArgs();
4050
if (count($args) > 1) {
4151
$args[0]->setAttribute(self::ATTRIBUTE_NAME, true);
52+
53+
$closure = $args[0]->value;
54+
if ($closure instanceof Expr\Closure || $closure instanceof Expr\ArrowFunction) {
55+
// null means default scope "static"
56+
$this->boundClosures[spl_object_id($closure)] = $args[2]->value ?? null;
57+
}
4258
}
59+
}
4360

44-
// null means default scope "static"
45-
array_unshift($this->scopeStack, $args[2]->value ?? null);
61+
if (array_key_exists(spl_object_id($node), $this->boundClosures)) {
62+
array_unshift($this->scopeStack, $this->boundClosures[spl_object_id($node)]);
4663
}
4764

4865
if ($node instanceof Name
@@ -58,14 +75,7 @@ public function enterNode(Node $node): ?Node
5875
#[Override]
5976
public function leaveNode(Node $node): ?Node
6077
{
61-
if (
62-
$node instanceof Node\Expr\StaticCall
63-
&& $node->class instanceof Node\Name
64-
&& $node->class->toLowerString() === 'closure'
65-
&& $node->name instanceof Identifier
66-
&& $node->name->toLowerString() === 'bind'
67-
&& !$node->isFirstClassCallable()
68-
) {
78+
if (array_key_exists(spl_object_id($node), $this->boundClosures)) {
6979
array_shift($this->scopeStack);
7080
}
7181

‎src/Rules/Classes/InstantiationRule.php‎

Lines changed: 44 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
use PhpParser\Node;
66
use PhpParser\Node\Expr\New_;
7+
use PHPStan\Analyser\ClosureBindScopeResolver;
78
use PHPStan\Analyser\CollectedDataEmitter;
89
use PHPStan\Analyser\NodeCallbackInvoker;
910
use PHPStan\Analyser\Scope;
@@ -57,6 +58,7 @@ public function __construct(
5758
private ClassNameCheck $classCheck,
5859
private RuleLevelHelper $ruleLevelHelper,
5960
private ConsistentConstructorHelper $consistentConstructorHelper,
61+
private ClosureBindScopeResolver $closureBindScopeResolver,
6062
#[AutowiredParameter(ref: '%featureToggles.newOnNonObject%')]
6163
private bool $newOnNonObject,
6264
#[AutowiredParameter(ref: '%tips.discoveringSymbols%')]
@@ -126,8 +128,15 @@ private function checkClassName(string $class, bool $isName, Node $node, Scope&N
126128
$lowercasedClass = strtolower($class);
127129
$messages = [];
128130
$isStatic = false;
131+
$bindScopeClassReflection = $node->class instanceof Node\Name
132+
? $this->closureBindScopeResolver->resolveScopeClass($scope, $node->class)
133+
: null;
129134
if ($lowercasedClass === 'static') {
130-
if (!$scope->isInClass()) {
135+
if ($bindScopeClassReflection !== null) {
136+
$classReflection = $bindScopeClassReflection;
137+
} elseif ($scope->isInClass()) {
138+
$classReflection = $scope->getClassReflection();
139+
} else {
131140
return [
132141
RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $class))
133142
->identifier('outOfClass.static')
@@ -136,7 +145,6 @@ private function checkClassName(string $class, bool $isName, Node $node, Scope&N
136145
}
137146

138147
$isStatic = true;
139-
$classReflection = $scope->getClassReflection();
140148
if (!$classReflection->isFinal()) {
141149
if (!$classReflection->hasConstructor()) {
142150
return [];
@@ -154,33 +162,48 @@ private function checkClassName(string $class, bool $isName, Node $node, Scope&N
154162
}
155163
}
156164
} elseif ($lowercasedClass === 'self') {
157-
if (!$scope->isInClass()) {
165+
if ($bindScopeClassReflection !== null) {
166+
$classReflection = $bindScopeClassReflection;
167+
} elseif ($scope->isInClass()) {
168+
$classReflection = $scope->getClassReflection();
169+
} else {
158170
return [
159171
RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $class))
160172
->identifier('outOfClass.self')
161173
->build(),
162174
];
163175
}
164-
$classReflection = $scope->getClassReflection();
165176
} elseif ($lowercasedClass === 'parent') {
166-
if (!$scope->isInClass()) {
167-
return [
168-
RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $class))
169-
->identifier('outOfClass.parent')
170-
->build(),
171-
];
172-
}
173-
if ($scope->getClassReflection()->getParentClass() === null) {
174-
return [
175-
RuleErrorBuilder::message(sprintf(
176-
'%s::%s() calls new parent but %s does not extend any class.',
177-
$scope->getClassReflection()->getDisplayName(),
178-
$scope->getFunctionName(),
179-
$scope->getClassReflection()->getDisplayName(),
180-
))->identifier('class.noParent')->build(),
181-
];
177+
if ($bindScopeClassReflection !== null) {
178+
if ($bindScopeClassReflection->getParentClass() === null) {
179+
return [
180+
RuleErrorBuilder::message(sprintf(
181+
'Using new parent but %s does not extend any class.',
182+
$bindScopeClassReflection->getDisplayName(),
183+
))->identifier('class.noParent')->build(),
184+
];
185+
}
186+
$classReflection = $bindScopeClassReflection->getParentClass();
187+
} else {
188+
if (!$scope->isInClass()) {
189+
return [
190+
RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $class))
191+
->identifier('outOfClass.parent')
192+
->build(),
193+
];
194+
}
195+
if ($scope->getClassReflection()->getParentClass() === null) {
196+
return [
197+
RuleErrorBuilder::message(sprintf(
198+
'%s::%s() calls new parent but %s does not extend any class.',
199+
$scope->getClassReflection()->getDisplayName(),
200+
$scope->getFunctionName(),
201+
$scope->getClassReflection()->getDisplayName(),
202+
))->identifier('class.noParent')->build(),
203+
];
204+
}
205+
$classReflection = $scope->getClassReflection()->getParentClass();
182206
}
183-
$classReflection = $scope->getClassReflection()->getParentClass();
184207
} else {
185208
if (!$this->reflectionProvider->hasClass($class)) {
186209
if ($scope->isInClassExists($class)) {

‎src/Rules/Methods/StaticMethodCallCheck.php‎

Lines changed: 57 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PhpParser\Node\Expr;
77
use PhpParser\Node\Identifier;
88
use PhpParser\Node\Name;
9+
use PHPStan\Analyser\ClosureBindScopeResolver;
910
use PHPStan\Analyser\NullsafeOperatorHelper;
1011
use PHPStan\Analyser\Scope;
1112
use PHPStan\DependencyInjection\AutowiredParameter;
@@ -44,6 +45,7 @@ public function __construct(
4445
private ReflectionProvider $reflectionProvider,
4546
private RuleLevelHelper $ruleLevelHelper,
4647
private ClassNameCheck $classCheck,
48+
private ClosureBindScopeResolver $closureBindScopeResolver,
4749
#[AutowiredParameter]
4850
private bool $checkFunctionNameCase,
4951
#[AutowiredParameter(ref: '%tips.discoveringSymbols%')]
@@ -75,8 +77,9 @@ public function check(
7577

7678
$className = (string) $class;
7779
$lowercasedClassName = strtolower($className);
80+
$bindScopeClassReflection = $this->closureBindScopeResolver->resolveScopeClass($scope, $class);
7881
if (in_array($lowercasedClassName, ['self', 'static'], true)) {
79-
if (!$scope->isInClass()) {
82+
if ($bindScopeClassReflection === null && !$scope->isInClass()) {
8083
return [
8184
[
8285
RuleErrorBuilder::message(sprintf(
@@ -92,42 +95,60 @@ public function check(
9295
}
9396
$classType = $scope->resolveTypeByName($class);
9497
} elseif ($lowercasedClassName === 'parent') {
95-
if (!$scope->isInClass()) {
96-
return [
97-
[
98-
RuleErrorBuilder::message(sprintf(
99-
'Calling %s::%s() outside of class scope.',
100-
$className,
101-
$methodName,
102-
))
103-
->line($astName->getStartLine())
104-
->identifier(sprintf('outOfClass.parent'))
105-
->build(),
106-
],
107-
null,
108-
];
109-
}
110-
$currentClassReflection = $scope->getClassReflection();
111-
if ($currentClassReflection->getParentClass() === null) {
112-
return [
113-
[
114-
RuleErrorBuilder::message(sprintf(
115-
'%s::%s() calls parent::%s() but %s does not extend any class.',
116-
$scope->getClassReflection()->getDisplayName(),
117-
$scope->getFunctionName(),
118-
$methodName,
119-
$scope->getClassReflection()->getDisplayName(),
120-
))
121-
->line($astName->getStartLine())
122-
->identifier('class.noParent')
123-
->build(),
124-
],
125-
null,
126-
];
127-
}
98+
if ($bindScopeClassReflection !== null) {
99+
if ($bindScopeClassReflection->getParentClass() === null) {
100+
return [
101+
[
102+
RuleErrorBuilder::message(sprintf(
103+
'Calling parent::%s() but %s does not extend any class.',
104+
$methodName,
105+
$bindScopeClassReflection->getDisplayName(),
106+
))
107+
->line($astName->getStartLine())
108+
->identifier('class.noParent')
109+
->build(),
110+
],
111+
null,
112+
];
113+
}
114+
} else {
115+
if (!$scope->isInClass()) {
116+
return [
117+
[
118+
RuleErrorBuilder::message(sprintf(
119+
'Calling %s::%s() outside of class scope.',
120+
$className,
121+
$methodName,
122+
))
123+
->line($astName->getStartLine())
124+
->identifier(sprintf('outOfClass.parent'))
125+
->build(),
126+
],
127+
null,
128+
];
129+
}
130+
$currentClassReflection = $scope->getClassReflection();
131+
if ($currentClassReflection->getParentClass() === null) {
132+
return [
133+
[
134+
RuleErrorBuilder::message(sprintf(
135+
'%s::%s() calls parent::%s() but %s does not extend any class.',
136+
$scope->getClassReflection()->getDisplayName(),
137+
$scope->getFunctionName(),
138+
$methodName,
139+
$scope->getClassReflection()->getDisplayName(),
140+
))
141+
->line($astName->getStartLine())
142+
->identifier('class.noParent')
143+
->build(),
144+
],
145+
null,
146+
];
147+
}
128148

129-
if ($scope->getFunctionName() === null) {
130-
throw new ShouldNotHappenException();
149+
if ($scope->getFunctionName() === null) {
150+
throw new ShouldNotHappenException();
151+
}
131152
}
132153

133154
$classType = $scope->resolveTypeByName($class);

0 commit comments

Comments
 (0)