Skip to content

Commit 7a6badc

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 424f880 commit 7a6badc

4 files changed

Lines changed: 101 additions & 54 deletions

File tree

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
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+
if (!$class->hasAttribute(ClosureBindArgVisitor::SCOPE_ATTRIBUTE_NAME)) {
36+
return null;
37+
}
38+
39+
$scopeArg = $class->getAttribute(ClosureBindArgVisitor::SCOPE_ATTRIBUTE_NAME);
40+
if (!$scopeArg instanceof Expr) {
41+
// A null attribute means the default "static" scope: keep the enclosing class.
42+
return null;
43+
}
44+
45+
$objectClassNames = $scope->getType($scopeArg)->getClassStringObjectType()->getObjectClassNames();
46+
if (count($objectClassNames) !== 1) {
47+
return null;
48+
}
49+
50+
$className = $objectClassNames[0];
51+
if (!$this->reflectionProvider->hasClass($className)) {
52+
return null;
53+
}
54+
55+
return $this->reflectionProvider->getClass($className);
56+
}
57+
58+
}

‎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\ExpressionResultStorage;
@@ -18,14 +19,10 @@
1819
use PHPStan\Analyser\TypeSpecifier;
1920
use PHPStan\Analyser\TypeSpecifierContext;
2021
use PHPStan\DependencyInjection\AutowiredService;
21-
use PHPStan\Parser\ClosureBindArgVisitor;
22-
use PHPStan\Reflection\ClassReflection;
2322
use PHPStan\Reflection\InitializerExprTypeResolver;
24-
use PHPStan\Reflection\ReflectionProvider;
2523
use PHPStan\Type\MixedType;
2624
use PHPStan\Type\Type;
2725
use function array_merge;
28-
use function count;
2926

3027
/**
3128
* @implements ExprHandler<ClassConstFetch>
@@ -36,7 +33,7 @@ final class ClassConstFetchHandler implements ExprHandler
3633

3734
public function __construct(
3835
private InitializerExprTypeResolver $initializerExprTypeResolver,
39-
private ReflectionProvider $reflectionProvider,
36+
private ClosureBindScopeResolver $closureBindScopeResolver,
4037
)
4138
{
4239
}
@@ -53,9 +50,11 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type
5350
}
5451

5552
$classReflection = $scope->isInClass() ? $scope->getClassReflection() : null;
56-
$bindScopeReflection = $this->resolveClosureBindScopeReflection($scope, $expr->class);
57-
if ($bindScopeReflection !== null) {
58-
$classReflection = $bindScopeReflection;
53+
if ($expr->class instanceof Name) {
54+
$bindScopeReflection = $this->closureBindScopeResolver->resolveScopeClass($scope, $expr->class);
55+
if ($bindScopeReflection !== null) {
56+
$classReflection = $bindScopeReflection;
57+
}
5958
}
6059

6160
return $this->initializerExprTypeResolver->getClassConstFetchTypeByReflection(
@@ -66,37 +65,6 @@ public function resolveType(MutatingScope $scope, Expr $expr): Type
6665
);
6766
}
6867

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

‎src/Rules/Classes/ClassConstantRule.php‎

Lines changed: 34 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
use PhpParser\Node\Expr\ClassConstFetch;
88
use PhpParser\Node\Name;
99
use PhpParser\Node\Scalar\String_;
10+
use PHPStan\Analyser\ClosureBindScopeResolver;
1011
use PHPStan\Analyser\NullsafeOperatorHelper;
1112
use PHPStan\Analyser\Scope;
1213
use PHPStan\DependencyInjection\AutowiredParameter;
@@ -22,6 +23,7 @@
2223
use PHPStan\Rules\RuleErrorBuilder;
2324
use PHPStan\Rules\RuleLevelHelper;
2425
use PHPStan\Type\ErrorType;
26+
use PHPStan\Type\ObjectType;
2527
use PHPStan\Type\StringType;
2628
use PHPStan\Type\ThisType;
2729
use PHPStan\Type\Type;
@@ -44,6 +46,7 @@ public function __construct(
4446
private RuleLevelHelper $ruleLevelHelper,
4547
private ClassNameCheck $classCheck,
4648
private PhpVersion $phpVersion,
49+
private ClosureBindScopeResolver $closureBindScopeResolver,
4750
#[AutowiredParameter(ref: '%featureToggles.checkNonStringableDynamicAccess%')]
4851
private bool $checkNonStringableDynamicAccess,
4952
)
@@ -110,35 +113,51 @@ private function processSingleClassConstFetch(Scope $scope, ClassConstFetch $nod
110113
if ($class instanceof Node\Name) {
111114
$className = (string) $class;
112115
$lowercasedClassName = strtolower($className);
116+
$bindScopeClassReflection = $this->closureBindScopeResolver->resolveScopeClass($scope, $class);
113117
if (in_array($lowercasedClassName, ['self', 'static'], true)) {
114-
if (!$scope->isInClass()) {
118+
if ($bindScopeClassReflection !== null) {
119+
$classType = new ObjectType($bindScopeClassReflection->getName());
120+
} elseif (!$scope->isInClass()) {
115121
return [
116122
RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $className))
117123
->identifier(sprintf('outOfClass.%s', $lowercasedClassName))
118124
->build(),
119125
];
126+
} else {
127+
$classType = $scope->resolveTypeByName($class);
120128
}
121-
122-
$classType = $scope->resolveTypeByName($class);
123129
} elseif ($lowercasedClassName === 'parent') {
124-
if (!$scope->isInClass()) {
130+
if ($bindScopeClassReflection !== null) {
131+
$parentClassReflection = $bindScopeClassReflection->getParentClass();
132+
if ($parentClassReflection === null) {
133+
return [
134+
RuleErrorBuilder::message(sprintf(
135+
'Access to parent::%s but %s does not extend any class.',
136+
$constantName,
137+
$bindScopeClassReflection->getDisplayName(),
138+
))->identifier('class.noParent')->build(),
139+
];
140+
}
141+
$classType = new ObjectType($parentClassReflection->getName());
142+
} elseif (!$scope->isInClass()) {
125143
return [
126144
RuleErrorBuilder::message(sprintf('Using %s outside of class scope.', $className))
127145
->identifier(sprintf('outOfClass.%s', $lowercasedClassName))
128146
->build(),
129147
];
148+
} else {
149+
$currentClassReflection = $scope->getClassReflection();
150+
if ($currentClassReflection->getParentClass() === null) {
151+
return [
152+
RuleErrorBuilder::message(sprintf(
153+
'Access to parent::%s but %s does not extend any class.',
154+
$constantName,
155+
$currentClassReflection->getDisplayName(),
156+
))->identifier('class.noParent')->build(),
157+
];
158+
}
159+
$classType = $scope->resolveTypeByName($class);
130160
}
131-
$currentClassReflection = $scope->getClassReflection();
132-
if ($currentClassReflection->getParentClass() === null) {
133-
return [
134-
RuleErrorBuilder::message(sprintf(
135-
'Access to parent::%s but %s does not extend any class.',
136-
$constantName,
137-
$currentClassReflection->getDisplayName(),
138-
))->identifier('class.noParent')->build(),
139-
];
140-
}
141-
$classType = $scope->resolveTypeByName($class);
142161
} else {
143162
if (!$this->reflectionProvider->hasClass($className)) {
144163
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\Php\PhpVersion;
67
use PHPStan\Rules\ClassCaseSensitivityCheck;
78
use PHPStan\Rules\ClassForbiddenNameCheck;
@@ -44,6 +45,7 @@ protected function getRule(): Rule
4445
$container,
4546
),
4647
new PhpVersion($this->phpVersion),
48+
new ClosureBindScopeResolver($reflectionProvider),
4749
checkNonStringableDynamicAccess: true,
4850
);
4951
}

0 commit comments

Comments
 (0)