Skip to content

Commit 53f4c75

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 53f4c75

5 files changed

Lines changed: 423 additions & 5 deletions

File tree

‎src/Rules/Classes/ClassConstantRule.php‎

Lines changed: 41 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,26 @@ 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()) {
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 ($this->canCheckThroughObjectType($objectType)) {
200+
$exprToCheck = new TypeExpr($objectType);
201+
}
202+
}
203+
}
185204
$classTypeResult = $this->ruleLevelHelper->findTypeToCheck(
186205
$scope,
187-
NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class),
206+
$exprToCheck,
188207
sprintf('Access to constant %s on an unknown class %%s.', SprintfHelper::escapeFormatString($constantName)),
189208
static fn (Type $type): bool => $type->canAccessConstants()->yes() && $type->hasConstant($constantName)->yes(),
190209
);
@@ -266,4 +285,25 @@ private function processSingleClassConstFetch(Scope $scope, ClassConstFetch $nod
266285
return $messages;
267286
}
268287

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

‎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,
@@ -604,4 +606,87 @@ 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+
'Access to private constant PRIVATE_CONST of class ClassConstantStringUnions\WithNonPublic.',
643+
115,
644+
],
645+
[
646+
'Access to protected constant PROTECTED_CONST of class ClassConstantStringUnions\WithNonPublic.',
647+
116,
648+
],
649+
[
650+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
651+
140,
652+
],
653+
]);
654+
}
655+
656+
#[RequiresPhp('>= 8.1.0')]
657+
public function testClassConstantAccessOnStringUnionsWithoutCheckUnionTypes(): void
658+
{
659+
$this->checkUnionTypes = false;
660+
$this->analyse([__DIR__ . '/data/class-constant-string-unions.php'], [
661+
[
662+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
663+
39,
664+
],
665+
[
666+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
667+
73,
668+
],
669+
[
670+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
671+
87,
672+
],
673+
[
674+
'Accessing ::class constant on a dynamic string is not supported in PHP.',
675+
95,
676+
],
677+
[
678+
'Access to private constant PRIVATE_CONST of class ClassConstantStringUnions\WithNonPublic.',
679+
115,
680+
],
681+
[
682+
'Access to protected constant PROTECTED_CONST of class ClassConstantStringUnions\WithNonPublic.',
683+
116,
684+
],
685+
[
686+
'Access to undefined constant ClassConstantStringUnions\Baz::BAR.',
687+
140,
688+
],
689+
]);
690+
}
691+
607692
}

0 commit comments

Comments
 (0)