Skip to content

Commit b1e9c87

Browse files
gnutixclaude
authored andcommitted
Check class constant access through constant class-name strings and class-string unions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 091c5a7 commit b1e9c87

5 files changed

Lines changed: 422 additions & 5 deletions

File tree

‎src/Rules/Classes/ClassConstantRule.php‎

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use PHPStan\Analyser\Scope;
1111
use PHPStan\DependencyInjection\RegisteredRule;
1212
use PHPStan\Internal\SprintfHelper;
13+
use PHPStan\Node\Expr\TypeExpr;
1314
use PHPStan\Reflection\ReflectionProvider;
1415
use PHPStan\Rules\ClassNameCheck;
1516
use PHPStan\Rules\ClassNameNodePair;
@@ -26,6 +27,7 @@
2627
use PHPStan\Type\TypeCombinator;
2728
use PHPStan\Type\VerbosityLevel;
2829
use function array_merge;
30+
use function count;
2931
use function in_array;
3032
use function sprintf;
3133
use function strtolower;
@@ -182,9 +184,25 @@ private function processSingleClassConstFetch(Scope $scope, ClassConstFetch $nod
182184
return $messages;
183185
}
184186
} else {
187+
$exprToCheck = NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class);
188+
if (strtolower($constantName) !== 'class') {
189+
// A constant class-name string or a class-string type describes the
190+
// object it names, so route the check through that object type. This
191+
// makes constant-string / class-string unions behave exactly like
192+
// object unions with regard to rule levels (member filtering when
193+
// checkUnionTypes is off, whole-union check when it is on).
194+
// Every other type (objects, or unions with members that are not
195+
// class-strings) yields no class name here and keeps the old path.
196+
// The ::class pseudo-constant is excluded above because
197+
// $string::class is a runtime TypeError, handled below as a string.
198+
$objectType = $scope->getType($exprToCheck)->getClassStringObjectType();
199+
if ($this->canCheckThroughObjectType($objectType)) {
200+
$exprToCheck = new TypeExpr($objectType);
201+
}
202+
}
185203
$classTypeResult = $this->ruleLevelHelper->findTypeToCheck(
186204
$scope,
187-
NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class),
205+
$exprToCheck,
188206
sprintf('Access to constant %s on an unknown class %%s.', SprintfHelper::escapeFormatString($constantName)),
189207
static fn (Type $type): bool => $type->canAccessConstants()->yes() && $type->hasConstant($constantName)->yes(),
190208
);
@@ -266,4 +284,25 @@ private function processSingleClassConstFetch(Scope $scope, ClassConstFetch $nod
266284
return $messages;
267285
}
268286

287+
/**
288+
* Class-strings without a class name (class-string, class-string<object>,
289+
* class-string<T of object>) and class-strings naming a trait keep the
290+
* string code path: RuleLevelHelper would filter the object type out or
291+
* report the trait as an unknown class.
292+
*/
293+
private function canCheckThroughObjectType(Type $objectType): bool
294+
{
295+
if (count($objectType->getObjectClassNames()) === 0) {
296+
return false;
297+
}
298+
299+
foreach ($objectType->getObjectClassReflections() as $classReflection) {
300+
if ($classReflection->isTrait()) {
301+
return false;
302+
}
303+
}
304+
305+
return true;
306+
}
307+
269308
}

‎src/Type/Php/DefinedConstantTypeSpecifyingExtension.php‎

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,13 @@
22

33
namespace PHPStan\Type\Php;
44

5+
use PhpParser\Node\Expr;
6+
use PhpParser\Node\Expr\BinaryOp\Concat;
7+
use PhpParser\Node\Expr\ClassConstFetch;
58
use PhpParser\Node\Expr\FuncCall;
9+
use PhpParser\Node\Identifier;
10+
use PhpParser\Node\InterpolatedStringPart;
11+
use PhpParser\Node\Scalar\InterpolatedString;
612
use PHPStan\Analyser\Scope;
713
use PHPStan\Analyser\SpecifiedTypes;
814
use PHPStan\Analyser\TypeSpecifier;
@@ -13,6 +19,8 @@
1319
use PHPStan\Type\FunctionTypeSpecifyingExtension;
1420
use PHPStan\Type\MixedType;
1521
use function count;
22+
use function preg_match;
23+
use function strtolower;
1624

1725
#[AutowiredService]
1826
final class DefinedConstantTypeSpecifyingExtension implements FunctionTypeSpecifyingExtension, TypeSpecifierAwareExtension
@@ -50,11 +58,16 @@ public function specifyTypes(
5058
$args = $node->getArgs();
5159
$constantNames = $scope->getType($args[0]->value)->getConstantStrings();
5260

53-
if (count($constantNames) !== 1 || $constantNames[0]->getValue() === '') {
54-
return new SpecifiedTypes([], []);
61+
if (count($constantNames) === 1) {
62+
if ($constantNames[0]->getValue() === '') {
63+
return new SpecifiedTypes([], []);
64+
}
65+
66+
$expr = $this->constantHelper->createExprFromConstantName($constantNames[0]->getValue());
67+
} else {
68+
$expr = $this->createClassConstFetchFromDynamicName($args[0]->value, $scope);
5569
}
5670

57-
$expr = $this->constantHelper->createExprFromConstantName($constantNames[0]->getValue());
5871
if ($expr === null) {
5972
return new SpecifiedTypes([], []);
6073
}
@@ -67,4 +80,44 @@ public function specifyTypes(
6780
);
6881
}
6982

83+
/**
84+
* Recognizes `$class . '::NAME'`, `"$class::NAME"` and `"{$class}::NAME"`
85+
* so that the guarded `$class::NAME` fetch is narrowed.
86+
*/
87+
private function createClassConstFetchFromDynamicName(Expr $nameExpr, Scope $scope): ?ClassConstFetch
88+
{
89+
if ($nameExpr instanceof Concat) {
90+
$classExpr = $nameExpr->left;
91+
$suffixes = $scope->getType($nameExpr->right)->getConstantStrings();
92+
if (count($suffixes) !== 1) {
93+
return null;
94+
}
95+
$suffix = $suffixes[0]->getValue();
96+
} elseif (
97+
$nameExpr instanceof InterpolatedString
98+
&& count($nameExpr->parts) === 2
99+
&& $nameExpr->parts[0] instanceof Expr
100+
&& $nameExpr->parts[1] instanceof InterpolatedStringPart
101+
) {
102+
$classExpr = $nameExpr->parts[0];
103+
$suffix = $nameExpr->parts[1]->value;
104+
} else {
105+
return null;
106+
}
107+
108+
if (!$scope->getType($classExpr)->isString()->yes()) {
109+
return null;
110+
}
111+
112+
if (preg_match('/^::([a-zA-Z_\x80-\xff][a-zA-Z0-9_\x80-\xff]*)$/', $suffix, $matches) !== 1) {
113+
return null;
114+
}
115+
116+
if (strtolower($matches[1]) === 'class') {
117+
return null;
118+
}
119+
120+
return new ClassConstFetch($classExpr, new Identifier($matches[1]));
121+
}
122+
70123
}
Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace DefinedDynamicClassConstant;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
final class WithConstant
8+
{
9+
10+
const FOO = 1;
11+
12+
}
13+
14+
class WithoutConstant
15+
{
16+
17+
}
18+
19+
class Foo
20+
{
21+
22+
/**
23+
* @param class-string<WithoutConstant> $class
24+
*/
25+
public function concat(string $class): void
26+
{
27+
if (defined($class . '::FOO')) {
28+
assertType('mixed', $class::FOO);
29+
}
30+
}
31+
32+
/**
33+
* @param class-string<WithoutConstant> $class
34+
*/
35+
public function interpolated(string $class): void
36+
{
37+
if (defined("$class::FOO")) {
38+
assertType('mixed', $class::FOO);
39+
}
40+
if (defined("{$class}::FOO")) {
41+
assertType('mixed', $class::FOO);
42+
}
43+
}
44+
45+
public function plainString(string $class): void
46+
{
47+
if (defined($class . '::FOO')) {
48+
assertType('mixed', $class::FOO);
49+
}
50+
}
51+
52+
/**
53+
* @param class-string<WithConstant> $class
54+
*/
55+
public function existingConstantKeepsItsType(string $class): void
56+
{
57+
assertType('1', $class::FOO);
58+
if (defined($class . '::FOO')) {
59+
assertType('1', $class::FOO);
60+
}
61+
}
62+
63+
}

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

Lines changed: 86 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ class ClassConstantRuleTest extends RuleTestCase
2323

2424
private bool $checkImportedClassNameCase = false;
2525

26+
private bool $checkUnionTypes = true;
27+
2628
protected function getRule(): Rule
2729
{
2830
$reflectionProvider = self::createReflectionProvider();
@@ -31,7 +33,7 @@ protected function getRule(): Rule
3133
$reflectionProvider,
3234
checkNullables: true,
3335
checkThisOnly: false,
34-
checkUnionTypes: true,
36+
checkUnionTypes: $this->checkUnionTypes,
3537
checkExplicitMixed: true,
3638
checkImplicitMixed: true,
3739
checkBenevolentUnionTypes: false,
@@ -636,4 +638,87 @@ public function testClosureAttributeArguments(): void
636638
]);
637639
}
638640

641+
#[RequiresPhp('>= 8.2.0')]
642+
public function testClassConstantAccessOnStringUnions(): void
643+
{
644+
$this->analyse([__DIR__ . '/data/class-constant-string-unions.php'], [
645+
[
646+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
647+
39,
648+
],
649+
[
650+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
651+
45,
652+
],
653+
[
654+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
655+
57,
656+
],
657+
[
658+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
659+
65,
660+
],
661+
[
662+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
663+
73,
664+
],
665+
[
666+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
667+
87,
668+
],
669+
[
670+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
671+
95,
672+
],
673+
[
674+
'Access to private constant PRIVATE_CONST of class ClassConstantStringUnions\WithNonPublic.',
675+
115,
676+
],
677+
[
678+
'Access to protected constant PROTECTED_CONST of class ClassConstantStringUnions\WithNonPublic.',
679+
116,
680+
],
681+
[
682+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
683+
140,
684+
],
685+
]);
686+
}
687+
688+
#[RequiresPhp('>= 8.2.0')]
689+
public function testClassConstantAccessOnStringUnionsWithoutCheckUnionTypes(): void
690+
{
691+
$this->checkUnionTypes = false;
692+
$this->analyse([__DIR__ . '/data/class-constant-string-unions.php'], [
693+
[
694+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
695+
39,
696+
],
697+
[
698+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
699+
73,
700+
],
701+
[
702+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
703+
87,
704+
],
705+
[
706+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
707+
95,
708+
],
709+
[
710+
'Access to private constant PRIVATE_CONST of class ClassConstantStringUnions\WithNonPublic.',
711+
115,
712+
],
713+
[
714+
'Access to protected constant PROTECTED_CONST of class ClassConstantStringUnions\WithNonPublic.',
715+
116,
716+
],
717+
[
718+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
719+
140,
720+
],
721+
]);
722+
}
723+
639724
}

0 commit comments

Comments
 (0)