Skip to content

Commit 47b83ee

Browse files
gnutixclaude
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 184d34e commit 47b83ee

3 files changed

Lines changed: 196 additions & 2 deletions

File tree

‎src/Rules/Classes/ClassConstantRule.php‎

Lines changed: 22 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,28 @@ 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+
$exprType = $scope->getType($exprToCheck);
190+
if ($exprType->isClassString()->yes() && !$exprType->hasConstant($constantName)->yes()) {
191+
// A constant class-name string or a class-string type describes the
192+
// object it names, so route the check through that object type. This
193+
// makes constant-string / class-string unions behave exactly like
194+
// object unions with regard to rule levels (member filtering when
195+
// checkUnionTypes is off, whole-union check when it is on).
196+
// The ::class pseudo-constant is excluded above because
197+
// $string::class is a runtime TypeError, handled below as a string.
198+
$objectType = $exprType->getClassStringObjectType();
199+
if (count($objectType->getObjectClassNames()) === 0) {
200+
// plain class-string resolves to ObjectWithoutClassType, keep silent
201+
return $messages;
202+
}
203+
$exprToCheck = new TypeExpr($objectType);
204+
}
205+
}
185206
$classTypeResult = $this->ruleLevelHelper->findTypeToCheck(
186207
$scope,
187-
NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class),
208+
$exprToCheck,
188209
sprintf('Access to constant %s on an unknown class %%s.', SprintfHelper::escapeFormatString($constantName)),
189210
static fn (Type $type): bool => $type->canAccessConstants()->yes() && $type->hasConstant($constantName)->yes(),
190211
);

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

Lines changed: 62 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,
@@ -604,4 +606,63 @@ public function testConditionallyExecutedClassConstantOnExpression(): void
604606
$this->analyse([__DIR__ . '/data/class-constant-on-expr-php-versions.php'], $errors);
605607
}
606608

609+
#[RequiresPhp('>= 8.1.0')]
610+
public function testClassConstantAccessOnStringUnions(): void
611+
{
612+
$this->analyse([__DIR__ . '/data/class-constant-string-unions.php'], [
613+
[
614+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
615+
39,
616+
],
617+
[
618+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
619+
45,
620+
],
621+
[
622+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
623+
57,
624+
],
625+
[
626+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
627+
65,
628+
],
629+
[
630+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
631+
73,
632+
],
633+
[
634+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
635+
87,
636+
],
637+
[
638+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
639+
95,
640+
],
641+
]);
642+
}
643+
644+
#[RequiresPhp('>= 8.1.0')]
645+
public function testClassConstantAccessOnStringUnionsWithoutCheckUnionTypes(): void
646+
{
647+
$this->checkUnionTypes = false;
648+
$this->analyse([__DIR__ . '/data/class-constant-string-unions.php'], [
649+
[
650+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
651+
39,
652+
],
653+
[
654+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
655+
73,
656+
],
657+
[
658+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
659+
87,
660+
],
661+
[
662+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
663+
95,
664+
],
665+
]);
666+
}
667+
607668
}
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
<?php declare(strict_types = 1); // lint >= 8.1
2+
3+
namespace ClassConstantStringUnions;
4+
5+
class Foo
6+
{
7+
8+
const BAR = 1;
9+
10+
}
11+
12+
class Bar
13+
{
14+
15+
const BAR = 2;
16+
17+
}
18+
19+
class Baz
20+
{
21+
22+
}
23+
24+
enum SomeEnum: string
25+
{
26+
27+
case A = 'a';
28+
29+
const K = 1;
30+
31+
}
32+
33+
class Runner
34+
{
35+
36+
public function singleConstantString(): void
37+
{
38+
$class = Baz::class;
39+
echo $class::BAR;
40+
}
41+
42+
public function unionOfConstantStrings(bool $flag): void
43+
{
44+
$class = $flag ? Foo::class : Baz::class;
45+
echo $class::BAR;
46+
}
47+
48+
public function unionOfConstantStringsAllValid(bool $flag): void
49+
{
50+
$class = $flag ? Foo::class : Bar::class;
51+
echo $class::BAR;
52+
}
53+
54+
public function getClassUnion(Foo|Baz $object): void
55+
{
56+
$class = get_class($object);
57+
echo $class::BAR;
58+
}
59+
60+
/**
61+
* @param class-string<Foo>|class-string<Baz> $class
62+
*/
63+
public function genericClassStringUnion(string $class): void
64+
{
65+
echo $class::BAR;
66+
}
67+
68+
/**
69+
* @param class-string<Baz> $class
70+
*/
71+
public function singleGenericClassString(string $class): void
72+
{
73+
echo $class::BAR;
74+
}
75+
76+
/**
77+
* @param class-string $class
78+
*/
79+
public function plainClassString(string $class): void
80+
{
81+
echo $class::BAR;
82+
}
83+
84+
public function classPseudoConstantOnConstantStringUnion(bool $flag): void
85+
{
86+
$class = $flag ? Foo::class : Baz::class;
87+
echo $class::class;
88+
}
89+
90+
/**
91+
* @param class-string<Foo> $class
92+
*/
93+
public function classPseudoConstantOnGenericClassString(string $class): void
94+
{
95+
echo $class::class;
96+
}
97+
98+
public function enumCaseAccessThroughConstantString(): void
99+
{
100+
$class = SomeEnum::class;
101+
echo $class::A->value;
102+
}
103+
104+
/**
105+
* @param class-string<SomeEnum> $class
106+
*/
107+
public function enumCaseAccessThroughGenericClassString(string $class): void
108+
{
109+
echo $class::A->value;
110+
}
111+
112+
}

0 commit comments

Comments
 (0)