Skip to content

Commit 38c1c81

Browse files
committed
Make ClassConstantRule aware of Closure::bind() scope
Extract the bind-scope resolution into a shared ClosureBindScopeResolver service and use it both in ClassConstFetchHandler (type) and ClassConstantRule (rules), so self::/parent::/static:: class constants inside a bound closure no longer report "Using self outside of class scope" and resolve accessibility against the bound class.
1 parent 146e887 commit 38c1c81

4 files changed

Lines changed: 98 additions & 54 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser;
4+
5+
use PhpParser\Node\Expr;
6+
use PhpParser\Node\Name;
7+
use PHPStan\DependencyInjection\AutowiredService;
8+
use PHPStan\Parser\ClosureBindArgVisitor;
9+
use PHPStan\Reflection\ClassReflection;
10+
use PHPStan\Reflection\ReflectionProvider;
11+
use function count;
12+
13+
/**
14+
* Resolves the class scope that a `self`/`parent`/`static` class-name node is bound to
15+
* by a surrounding `Closure::bind()` call. {@see ClosureBindArgVisitor} annotates such
16+
* nodes with the bind scope argument (the 3rd argument of `Closure::bind()`).
17+
*/
18+
#[AutowiredService]
19+
final class ClosureBindScopeResolver
20+
{
21+
22+
public function __construct(
23+
private ReflectionProvider $reflectionProvider,
24+
)
25+
{
26+
}
27+
28+
/**
29+
* Returns the class the given class-name node is bound to via `Closure::bind()`, or
30+
* null when the node is not inside a bound closure or the bind scope argument does not
31+
* resolve to a single known class (e.g. the default "static" scope).
32+
*/
33+
public function resolveScopeClass(Scope $scope, Name $class): ?ClassReflection
34+
{
35+
$scopeArg = $class->getAttribute(ClosureBindArgVisitor::SCOPE_ATTRIBUTE_NAME);
36+
if (!$scopeArg instanceof Expr) {
37+
// Either the node is not inside a bound closure, or the attribute is null for
38+
// the default "static" scope. Both keep the enclosing class.
39+
return null;
40+
}
41+
42+
$objectClassNames = $scope->getType($scopeArg)->getClassStringObjectType()->getObjectClassNames();
43+
if (count($objectClassNames) !== 1) {
44+
return null;
45+
}
46+
47+
$className = $objectClassNames[0];
48+
if (!$this->reflectionProvider->hasClass($className)) {
49+
return null;
50+
}
51+
52+
return $this->reflectionProvider->getClass($className);
53+
}
54+
55+
}

‎src/Analyser/ExprHandler/ClassConstFetchHandler.php‎

Lines changed: 7 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use PhpParser\Node\Identifier;
88
use PhpParser\Node\Name;
99
use PhpParser\Node\Stmt;
10+
use PHPStan\Analyser\ClosureBindScopeResolver;
1011
use PHPStan\Analyser\ExpressionContext;
1112
use PHPStan\Analyser\ExpressionResult;
1213
use PHPStan\Analyser\ExpressionResultFactory;
@@ -19,14 +20,10 @@
1920
use PHPStan\Analyser\TypeSpecifier;
2021
use PHPStan\Analyser\TypeSpecifierContext;
2122
use PHPStan\DependencyInjection\AutowiredService;
22-
use PHPStan\Parser\ClosureBindArgVisitor;
23-
use PHPStan\Reflection\ClassReflection;
2423
use PHPStan\Reflection\InitializerExprTypeResolver;
25-
use PHPStan\Reflection\ReflectionProvider;
2624
use PHPStan\Type\MixedType;
2725
use PHPStan\Type\Type;
2826
use function array_merge;
29-
use function count;
3027

3128
/**
3229
* @implements ExprHandler<ClassConstFetch>
@@ -38,7 +35,7 @@ final class ClassConstFetchHandler implements ExprHandler
3835
public function __construct(
3936
private InitializerExprTypeResolver $initializerExprTypeResolver,
4037
private ExpressionResultFactory $expressionResultFactory,
41-
private ReflectionProvider $reflectionProvider,
38+
private ClosureBindScopeResolver $closureBindScopeResolver,
4239
)
4340
{
4441
}
@@ -55,9 +52,11 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type
5552
}
5653

5754
$classReflection = $scope->isInClass() ? $scope->getClassReflection() : null;
58-
$bindScopeReflection = $this->resolveClosureBindScopeReflection($scope, $expr->class);
59-
if ($bindScopeReflection !== null) {
60-
$classReflection = $bindScopeReflection;
55+
if ($expr->class instanceof Name) {
56+
$bindScopeReflection = $this->closureBindScopeResolver->resolveScopeClass($scope, $expr->class);
57+
if ($bindScopeReflection !== null) {
58+
$classReflection = $bindScopeReflection;
59+
}
6160
}
6261

6362
return $this->initializerExprTypeResolver->getClassConstFetchTypeByReflection(
@@ -68,37 +67,6 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type
6867
);
6968
}
7069

71-
/**
72-
* Resolves the `Closure::bind()` scope class annotated on a `self`/`parent`/`static`
73-
* class name node by {@see ClosureBindArgVisitor}. Returns null when the node is not
74-
* inside a bound closure or the scope argument does not resolve to a single known class.
75-
*/
76-
private function resolveClosureBindScopeReflection(MutatingScope $scope, Expr|Name $class): ?ClassReflection
77-
{
78-
if (!$class instanceof Name || !$class->hasAttribute(ClosureBindArgVisitor::SCOPE_ATTRIBUTE_NAME)) {
79-
return null;
80-
}
81-
82-
$scopeArg = $class->getAttribute(ClosureBindArgVisitor::SCOPE_ATTRIBUTE_NAME);
83-
if (!$scopeArg instanceof Expr) {
84-
// null attribute means the default "static" scope: keep the enclosing class.
85-
return null;
86-
}
87-
88-
$scopeArgType = $scope->getType($scopeArg);
89-
$objectClassNames = $scopeArgType->getClassStringObjectType()->getObjectClassNames();
90-
if (count($objectClassNames) !== 1) {
91-
return null;
92-
}
93-
94-
$className = $objectClassNames[0];
95-
if (!$this->reflectionProvider->hasClass($className)) {
96-
return null;
97-
}
98-
99-
return $this->reflectionProvider->getClass($className);
100-
}
101-
10270
public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Expr $expr, MutatingScope $scope, ExpressionResultStorage $storage, callable $nodeCallback, ExpressionContext $context): ExpressionResult
10371
{
10472
$beforeScope = $scope;

‎src/Rules/Classes/ClassConstantRule.php‎

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
use PhpParser\Node\Expr\BinaryOp\Identical;
77
use PhpParser\Node\Expr\ClassConstFetch;
88
use PhpParser\Node\Scalar\String_;
9+
use PHPStan\Analyser\ClosureBindScopeResolver;
910
use PHPStan\Analyser\NullsafeOperatorHelper;
1011
use PHPStan\Analyser\Scope;
1112
use PHPStan\DependencyInjection\RegisteredRule;
@@ -21,6 +22,7 @@
2122
use PHPStan\Rules\RuleErrorBuilder;
2223
use PHPStan\Rules\RuleLevelHelper;
2324
use PHPStan\Type\ErrorType;
25+
use PHPStan\Type\ObjectType;
2426
use PHPStan\Type\StringType;
2527
use PHPStan\Type\ThisType;
2628
use PHPStan\Type\Type;
@@ -44,6 +46,7 @@ public function __construct(
4446
private ClassNameCheck $classCheck,
4547
private PhpVersion $phpVersion,
4648
private NonStringableDynamicAccessCheck $nonStringableDynamicAccessCheck,
49+
private ClosureBindScopeResolver $closureBindScopeResolver,
4750
)
4851
{
4952
}
@@ -96,35 +99,51 @@ private function processSingleClassConstFetch(Scope $scope, ClassConstFetch $nod
9699
if ($class instanceof Node\Name) {
97100
$className = (string) $class;
98101
$lowercasedClassName = strtolower($className);
102+
$bindScopeClassReflection = $this->closureBindScopeResolver->resolveScopeClass($scope, $class);
99103
if (in_array($lowercasedClassName, ['self', 'static'], true)) {
100-
if (!$scope->isInClass()) {
104+
if ($bindScopeClassReflection !== null) {
105+
$classType = new ObjectType($bindScopeClassReflection->getName());
106+
} elseif (!$scope->isInClass()) {
101107
return [
102108
RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $className))
103109
->identifier(sprintf('outOfClass.%s', $lowercasedClassName))
104110
->build(),
105111
];
112+
} else {
113+
$classType = $scope->resolveTypeByName($class);
106114
}
107-
108-
$classType = $scope->resolveTypeByName($class);
109115
} elseif ($lowercasedClassName === 'parent') {
110-
if (!$scope->isInClass()) {
116+
if ($bindScopeClassReflection !== null) {
117+
$parentClassReflection = $bindScopeClassReflection->getParentClass();
118+
if ($parentClassReflection === null) {
119+
return [
120+
RuleErrorBuilder::message(sprintf(
121+
'Access to parent::%s but %s does not extend any class.',
122+
$constantName,
123+
$bindScopeClassReflection->getDisplayName(),
124+
))->identifier('class.noParent')->build(),
125+
];
126+
}
127+
$classType = new ObjectType($parentClassReflection->getName());
128+
} elseif (!$scope->isInClass()) {
111129
return [
112130
RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $className))
113131
->identifier(sprintf('outOfClass.%s', $lowercasedClassName))
114132
->build(),
115133
];
134+
} else {
135+
$currentClassReflection = $scope->getClassReflection();
136+
if ($currentClassReflection->getParentClass() === null) {
137+
return [
138+
RuleErrorBuilder::message(sprintf(
139+
'Access to parent::%s but %s does not extend any class.',
140+
$constantName,
141+
$currentClassReflection->getDisplayName(),
142+
))->identifier('class.noParent')->build(),
143+
];
144+
}
145+
$classType = $scope->resolveTypeByName($class);
116146
}
117-
$currentClassReflection = $scope->getClassReflection();
118-
if ($currentClassReflection->getParentClass() === null) {
119-
return [
120-
RuleErrorBuilder::message(sprintf(
121-
'Access to parent::%s but %s does not extend any class.',
122-
$constantName,
123-
$currentClassReflection->getDisplayName(),
124-
))->identifier('class.noParent')->build(),
125-
];
126-
}
127-
$classType = $scope->resolveTypeByName($class);
128147
} else {
129148
if (!$this->reflectionProvider->hasClass($className)) {
130149
if ($scope->isInClassExists($className)) {

‎tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PHPStan\Rules\Classes;
44

5+
use PHPStan\Analyser\ClosureBindScopeResolver;
56
use PHPStan\Classes\ForbiddenClassNameExtension;
67
use PHPStan\Php\PhpVersion;
78
use PHPStan\Rules\ClassCaseSensitivityCheck;
@@ -51,6 +52,7 @@ protected function getRule(): Rule
5152
),
5253
new PhpVersion($this->phpVersion),
5354
new NonStringableDynamicAccessCheck($ruleLevelHelper, true),
55+
new ClosureBindScopeResolver($reflectionProvider),
5456
);
5557
}
5658

0 commit comments

Comments
 (0)