Skip to content

Commit 67e8bcc

Browse files
gnutixclaude
andcommitted
Check static method calls through constant class-name strings and class-string unions
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 411daa4 commit 67e8bcc

6 files changed

Lines changed: 325 additions & 3 deletions

File tree

src/Rules/Methods/StaticMethodCallCheck.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
use PHPStan\DependencyInjection\AutowiredParameter;
1212
use PHPStan\DependencyInjection\AutowiredService;
1313
use PHPStan\Internal\SprintfHelper;
14+
use PHPStan\Node\Expr\TypeExpr;
1415
use PHPStan\Reflection\ExtendedMethodReflection;
1516
use PHPStan\Reflection\MethodReflection;
1617
use PHPStan\Reflection\Native\NativeMethodReflection;
@@ -32,6 +33,7 @@
3233
use PHPStan\Type\TypeCombinator;
3334
use PHPStan\Type\VerbosityLevel;
3435
use function array_merge;
36+
use function count;
3537
use function in_array;
3638
use function sprintf;
3739
use function strtolower;
@@ -183,9 +185,24 @@ public function check(
183185
}
184186
}
185187
} else {
188+
$exprToCheck = NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class);
189+
$exprType = $scope->getType($exprToCheck);
190+
if ($exprType->isClassString()->yes() && !$exprType->hasMethod($methodName)->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+
$objectType = $exprType->getClassStringObjectType();
197+
if (count($objectType->getObjectClassNames()) === 0) {
198+
// plain class-string resolves to ObjectWithoutClassType, keep silent
199+
return [[], null];
200+
}
201+
$exprToCheck = new TypeExpr($objectType);
202+
}
186203
$classTypeResult = $this->ruleLevelHelper->findTypeToCheck(
187204
$scope,
188-
NullsafeOperatorHelper::getNullsafeShortcircuitedExprRespectingScope($scope, $class),
205+
$exprToCheck,
189206
sprintf('Call to static method %s() on an unknown class %%s.', SprintfHelper::escapeFormatString($methodName)),
190207
static fn (Type $type): bool => $type->canCallMethods()->yes() && $type->hasMethod($methodName)->yes(),
191208
);

tests/PHPStan/Rules/Methods/CallStaticMethodsRuleTest.php

Lines changed: 116 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,18 +25,22 @@ class CallStaticMethodsRuleTest extends RuleTestCase
2525

2626
private bool $checkThisOnly;
2727

28+
private bool $checkUnionTypes = true;
29+
2830
private bool $checkExplicitMixed = false;
2931

3032
private bool $checkImplicitMixed = false;
3133

34+
private bool $reportMagicMethods = true;
35+
3236
protected function getRule(): Rule
3337
{
3438
$reflectionProvider = self::createReflectionProvider();
3539
$ruleLevelHelper = new RuleLevelHelper(
3640
$reflectionProvider,
3741
checkNullables: true,
3842
checkThisOnly: $this->checkThisOnly,
39-
checkUnionTypes: true,
43+
checkUnionTypes: $this->checkUnionTypes,
4044
checkExplicitMixed: $this->checkExplicitMixed,
4145
checkImplicitMixed: $this->checkImplicitMixed,
4246
checkBenevolentUnionTypes: false,
@@ -58,7 +62,7 @@ protected function getRule(): Rule
5862
),
5963
checkFunctionNameCase: true,
6064
discoveringSymbolsTip: true,
61-
reportMagicMethods: true,
65+
reportMagicMethods: $this->reportMagicMethods,
6266
),
6367
new FunctionCallParametersCheck(
6468
$ruleLevelHelper,
@@ -1057,4 +1061,114 @@ public function testClassExistOnCall(): void
10571061
$this->analyse([__DIR__ . '/data/class-exists-on-static-call.php'], []);
10581062
}
10591063

1064+
#[RequiresPhp('>= 8.1.0')]
1065+
public function testBug11353(): void
1066+
{
1067+
$this->checkThisOnly = false;
1068+
$this->analyse([__DIR__ . '/data/bug-11353.php'], [
1069+
[
1070+
'Call to an undefined static method Bug11353\A|Bug11353\B|Bug11353\C::create().',
1071+
46,
1072+
],
1073+
]);
1074+
}
1075+
1076+
#[RequiresPhp('>= 8.0.0')]
1077+
public function testStaticCallsOnStringUnions(): void
1078+
{
1079+
$this->checkThisOnly = false;
1080+
$this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [
1081+
[
1082+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1083+
53,
1084+
],
1085+
[
1086+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1087+
59,
1088+
],
1089+
[
1090+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1091+
71,
1092+
],
1093+
[
1094+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1095+
79,
1096+
],
1097+
[
1098+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1099+
87,
1100+
],
1101+
[
1102+
'Static call to instance method StaticCallStringUnions\Foo::doBar().',
1103+
101,
1104+
],
1105+
[
1106+
'Call to an undefined static method StaticCallStringUnions\WithMagic::whatever().',
1107+
107,
1108+
],
1109+
[
1110+
'Call to an undefined static method StaticCallStringUnions\Foo|StaticCallStringUnions\WithMagic::create().',
1111+
115,
1112+
],
1113+
]);
1114+
}
1115+
1116+
#[RequiresPhp('>= 8.0.0')]
1117+
public function testStaticCallsOnStringUnionsWithoutCheckUnionTypes(): void
1118+
{
1119+
$this->checkThisOnly = false;
1120+
$this->checkUnionTypes = false;
1121+
$this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [
1122+
[
1123+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1124+
53,
1125+
],
1126+
[
1127+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1128+
87,
1129+
],
1130+
[
1131+
'Static call to instance method StaticCallStringUnions\Foo::doBar().',
1132+
101,
1133+
],
1134+
[
1135+
'Call to an undefined static method StaticCallStringUnions\WithMagic::whatever().',
1136+
107,
1137+
],
1138+
]);
1139+
}
1140+
1141+
#[RequiresPhp('>= 8.0.0')]
1142+
public function testStaticCallsOnStringUnionsWithoutReportMagicMethods(): void
1143+
{
1144+
$this->checkThisOnly = false;
1145+
$this->reportMagicMethods = false;
1146+
$this->analyse([__DIR__ . '/data/static-call-string-unions.php'], [
1147+
[
1148+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1149+
53,
1150+
],
1151+
[
1152+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1153+
59,
1154+
],
1155+
[
1156+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1157+
71,
1158+
],
1159+
[
1160+
'Call to an undefined static method StaticCallStringUnions\Baz|StaticCallStringUnions\Foo::create().',
1161+
79,
1162+
],
1163+
[
1164+
'Call to an undefined static method StaticCallStringUnions\Baz::create().',
1165+
87,
1166+
],
1167+
[
1168+
'Static call to instance method StaticCallStringUnions\Foo::doBar().',
1169+
101,
1170+
],
1171+
]);
1172+
}
1173+
10601174
}

tests/PHPStan/Rules/Methods/StaticMethodCallableRuleTest.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,10 @@ public function testRule(): void
105105
'Cannot call static method doFoo() on int.',
106106
22,
107107
],
108+
[
109+
'Call to an undefined static method StaticMethodCallable\Bar|StaticMethodCallable\Foo::doFoo().',
110+
77,
111+
],
108112
]);
109113
}
110114

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
<?php declare(strict_types = 1); // lint >= 8.1
2+
3+
namespace Bug11353;
4+
5+
enum Identifier: string
6+
{
7+
case A = 'identifier_a';
8+
case B = 'identifier_b';
9+
case C = 'identifier_c';
10+
}
11+
12+
class A
13+
{
14+
public static function create(): self
15+
{
16+
return new self();
17+
}
18+
}
19+
20+
class B
21+
{
22+
public static function create(): self
23+
{
24+
return new self();
25+
}
26+
}
27+
28+
class C
29+
{
30+
// missing static function create(): self
31+
}
32+
33+
class SomeCliCommand
34+
{
35+
public function execute(string $input): void
36+
{
37+
$identifier = Identifier::from($input);
38+
39+
$classFQCN = match ($identifier) {
40+
Identifier::A => A::class,
41+
Identifier::B => B::class,
42+
Identifier::C => C::class,
43+
};
44+
45+
// No warning that C::create() does not exist
46+
$class = $classFQCN::create();
47+
}
48+
}
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
<?php declare(strict_types = 1); // lint >= 8.0
2+
3+
namespace StaticCallStringUnions;
4+
5+
class Foo
6+
{
7+
8+
public static function create(): self
9+
{
10+
return new self();
11+
}
12+
13+
public function doBar(): void
14+
{
15+
}
16+
17+
}
18+
19+
class Bar
20+
{
21+
22+
public static function create(): self
23+
{
24+
return new self();
25+
}
26+
27+
}
28+
29+
class Baz
30+
{
31+
32+
}
33+
34+
class WithMagic
35+
{
36+
37+
/**
38+
* @param array<mixed> $args
39+
*/
40+
public static function __callStatic(string $name, array $args): mixed
41+
{
42+
return null;
43+
}
44+
45+
}
46+
47+
class Runner
48+
{
49+
50+
public function singleConstantString(): void
51+
{
52+
$class = Baz::class;
53+
$class::create();
54+
}
55+
56+
public function unionOfConstantStrings(bool $flag): void
57+
{
58+
$class = $flag ? Foo::class : Baz::class;
59+
$class::create();
60+
}
61+
62+
public function unionOfConstantStringsAllValid(bool $flag): void
63+
{
64+
$class = $flag ? Foo::class : Bar::class;
65+
$class::create();
66+
}
67+
68+
public function getClassUnion(Foo|Baz $object): void
69+
{
70+
$class = get_class($object);
71+
$class::create();
72+
}
73+
74+
/**
75+
* @param class-string<Foo>|class-string<Baz> $class
76+
*/
77+
public function genericClassStringUnion(string $class): void
78+
{
79+
$class::create();
80+
}
81+
82+
/**
83+
* @param class-string<Baz> $class
84+
*/
85+
public function singleGenericClassString(string $class): void
86+
{
87+
$class::create();
88+
}
89+
90+
/**
91+
* @param class-string $class
92+
*/
93+
public function plainClassString(string $class): void
94+
{
95+
$class::create();
96+
}
97+
98+
public function instanceMethodThroughConstantString(): void
99+
{
100+
$class = Foo::class;
101+
$class::doBar();
102+
}
103+
104+
public function magicCallStaticThroughConstantString(): void
105+
{
106+
$class = WithMagic::class;
107+
$class::whatever();
108+
}
109+
110+
/**
111+
* @param class-string<Foo>|class-string<WithMagic> $class
112+
*/
113+
public function unionWithMagicMember(string $class): void
114+
{
115+
$class::create();
116+
}
117+
118+
/**
119+
* @param class-string<Foo> $class
120+
*/
121+
public function methodExistsNarrowing(string $class): void
122+
{
123+
if (method_exists($class, 'doSomethingElse')) {
124+
$class::doSomethingElse();
125+
}
126+
}
127+
128+
}

tests/PHPStan/Rules/Methods/data/static-method-callable.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,3 +67,14 @@ public function doFoo()
6767
}
6868

6969
}
70+
71+
class Dolor
72+
{
73+
74+
public function doFoo(bool $flag): void
75+
{
76+
$class = $flag ? Foo::class : Bar::class;
77+
$class::doFoo(...);
78+
}
79+
80+
}

0 commit comments

Comments
 (0)