Skip to content

Commit a4ab8f8

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 411daa4 commit a4ab8f8

3 files changed

Lines changed: 200 additions & 2 deletions

File tree

src/Rules/Classes/ClassConstantRule.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use PHPStan\DependencyInjection\AutowiredParameter;
1313
use PHPStan\DependencyInjection\RegisteredRule;
1414
use PHPStan\Internal\SprintfHelper;
15+
use PHPStan\Node\Expr\TypeExpr;
1516
use PHPStan\Php\PhpVersion;
1617
use PHPStan\Reflection\ReflectionProvider;
1718
use PHPStan\Rules\ClassNameCheck;
@@ -28,6 +29,7 @@
2829
use PHPStan\Type\TypeCombinator;
2930
use PHPStan\Type\VerbosityLevel;
3031
use function array_merge;
32+
use function count;
3133
use function in_array;
3234
use function sprintf;
3335
use function strtolower;
@@ -198,9 +200,28 @@ private function processSingleClassConstFetch(Scope $scope, ClassConstFetch $nod
198200
return $messages;
199201
}
200202
} else {
203+
$exprToCheck = NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class);
204+
if (strtolower($constantName) !== 'class') {
205+
$exprType = $scope->getType($exprToCheck);
206+
if ($exprType->isClassString()->yes() && !$exprType->hasConstant($constantName)->yes()) {
207+
// A constant class-name string or a class-string type describes the
208+
// object it names, so route the check through that object type. This
209+
// makes constant-string / class-string unions behave exactly like
210+
// object unions with regard to rule levels (member filtering when
211+
// checkUnionTypes is off, whole-union check when it is on).
212+
// The ::class pseudo-constant is excluded above because
213+
// $string::class is a runtime TypeError, handled below as a string.
214+
$objectType = $exprType->getClassStringObjectType();
215+
if (count($objectType->getObjectClassNames()) === 0) {
216+
// plain class-string resolves to ObjectWithoutClassType, keep silent
217+
return $messages;
218+
}
219+
$exprToCheck = new TypeExpr($objectType);
220+
}
221+
}
201222
$classTypeResult = $this->ruleLevelHelper->findTypeToCheck(
202223
$scope,
203-
NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class),
224+
$exprToCheck,
204225
sprintf('Access to constant %s on an unknown class %%s.', SprintfHelper::escapeFormatString($constantName)),
205226
static fn (Type $type): bool => $type->canAccessConstants()->yes() && $type->hasConstant($constantName)->yes(),
206227
);

tests/PHPStan/Rules/Classes/ClassConstantRuleTest.php

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@ class ClassConstantRuleTest extends RuleTestCase
2121

2222
private int $phpVersion;
2323

24+
private bool $checkUnionTypes = true;
25+
2426
protected function getRule(): Rule
2527
{
2628
$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,
@@ -567,4 +569,65 @@ public function testStringableDynamicAccess(): void
567569
]);
568570
}
569571

572+
#[RequiresPhp('>= 8.1.0')]
573+
public function testClassConstantAccessOnStringUnions(): void
574+
{
575+
$this->phpVersion = PHP_VERSION_ID;
576+
$this->analyse([__DIR__ . '/data/class-constant-string-unions.php'], [
577+
[
578+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
579+
39,
580+
],
581+
[
582+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
583+
45,
584+
],
585+
[
586+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
587+
57,
588+
],
589+
[
590+
'Access to undefined constant ClassConstantStringUnions\Baz|ClassConstantStringUnions\Foo::BAR.',
591+
65,
592+
],
593+
[
594+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
595+
73,
596+
],
597+
[
598+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
599+
87,
600+
],
601+
[
602+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
603+
95,
604+
],
605+
]);
606+
}
607+
608+
#[RequiresPhp('>= 8.1.0')]
609+
public function testClassConstantAccessOnStringUnionsWithoutCheckUnionTypes(): void
610+
{
611+
$this->phpVersion = PHP_VERSION_ID;
612+
$this->checkUnionTypes = false;
613+
$this->analyse([__DIR__ . '/data/class-constant-string-unions.php'], [
614+
[
615+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
616+
39,
617+
],
618+
[
619+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
620+
73,
621+
],
622+
[
623+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
624+
87,
625+
],
626+
[
627+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
628+
95,
629+
],
630+
]);
631+
}
632+
570633
}
Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,114 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace ClassConstantStringUnions;
6+
7+
class Foo
8+
{
9+
10+
const BAR = 1;
11+
12+
}
13+
14+
class Bar
15+
{
16+
17+
const BAR = 2;
18+
19+
}
20+
21+
class Baz
22+
{
23+
24+
}
25+
26+
enum SomeEnum: string
27+
{
28+
29+
case A = 'a';
30+
31+
const K = 1;
32+
33+
}
34+
35+
class Runner
36+
{
37+
38+
public function singleConstantString(): void
39+
{
40+
$class = Baz::class;
41+
echo $class::BAR;
42+
}
43+
44+
public function unionOfConstantStrings(bool $flag): void
45+
{
46+
$class = $flag ? Foo::class : Baz::class;
47+
echo $class::BAR;
48+
}
49+
50+
public function unionOfConstantStringsAllValid(bool $flag): void
51+
{
52+
$class = $flag ? Foo::class : Bar::class;
53+
echo $class::BAR;
54+
}
55+
56+
public function getClassUnion(Foo|Baz $object): void
57+
{
58+
$class = get_class($object);
59+
echo $class::BAR;
60+
}
61+
62+
/**
63+
* @param class-string<Foo>|class-string<Baz> $class
64+
*/
65+
public function genericClassStringUnion(string $class): void
66+
{
67+
echo $class::BAR;
68+
}
69+
70+
/**
71+
* @param class-string<Baz> $class
72+
*/
73+
public function singleGenericClassString(string $class): void
74+
{
75+
echo $class::BAR;
76+
}
77+
78+
/**
79+
* @param class-string $class
80+
*/
81+
public function plainClassString(string $class): void
82+
{
83+
echo $class::BAR;
84+
}
85+
86+
public function classPseudoConstantOnConstantStringUnion(bool $flag): void
87+
{
88+
$class = $flag ? Foo::class : Baz::class;
89+
echo $class::class;
90+
}
91+
92+
/**
93+
* @param class-string<Foo> $class
94+
*/
95+
public function classPseudoConstantOnGenericClassString(string $class): void
96+
{
97+
echo $class::class;
98+
}
99+
100+
public function enumCaseAccessThroughConstantString(): void
101+
{
102+
$class = SomeEnum::class;
103+
echo $class::A->value;
104+
}
105+
106+
/**
107+
* @param class-string<SomeEnum> $class
108+
*/
109+
public function enumCaseAccessThroughGenericClassString(string $class): void
110+
{
111+
echo $class::A->value;
112+
}
113+
114+
}

0 commit comments

Comments
 (0)