Skip to content

Commit 166f460

Browse files
phpstan-botVincentLangletclaude
authored
Resolve method reflection for dynamic static calls ($var::method()) to enable purity and side-effect checking (#5572)
Co-authored-by: Vincent Langlet <vincentlanglet@hotmail.fr> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 12ad9fd commit 166f460

9 files changed

Lines changed: 247 additions & 7 deletions

File tree

src/Analyser/ExprHandler/StaticCallHandler.php

Lines changed: 18 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,18 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
144144
} else {
145145
$throwPoints[] = InternalThrowPoint::createImplicit($scope, $expr);
146146
}
147+
} elseif ($expr->class instanceof Expr) {
148+
$classType = $scope->getType($expr->class)->getObjectTypeOrClassStringObjectType();
149+
$methodName = $expr->name->name;
150+
$methodReflection = $scope->getMethodReflection($classType, $methodName);
151+
if ($methodReflection !== null) {
152+
$parametersAcceptor = ParametersAcceptorSelector::selectFromArgs(
153+
$scope,
154+
$expr->getArgs(),
155+
$methodReflection->getVariants(),
156+
$methodReflection->getNamedArgumentsVariants(),
157+
);
158+
}
147159
}
148160
} else {
149161
$nameResult = $nodeScopeResolver->processExprNode($stmt, $expr->name, $scope, $storage, $nodeCallback, $context->enterDeep());
@@ -202,7 +214,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
202214
}
203215

204216
if (
205-
$methodReflection !== null
217+
$expr->class instanceof Name
218+
&& $methodReflection !== null
206219
&& (
207220
(
208221
!$methodReflection->isStatic()
@@ -215,7 +228,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
215228
) {
216229
$scope = $scope->invalidateExpression(new Variable('this'), true, $methodReflection->getDeclaringClass());
217230
} elseif (
218-
$methodReflection !== null
231+
$expr->class instanceof Name
232+
&& $methodReflection !== null
219233
&& $this->rememberPossiblyImpureFunctionValues
220234
&& $scope->isInClass()
221235
&& $scope->getClassReflection()->is($methodReflection->getDeclaringClass()->getName())
@@ -230,7 +244,8 @@ public function processExpr(NodeScopeResolver $nodeScopeResolver, Stmt $stmt, Ex
230244
}
231245

232246
if (
233-
$methodReflection !== null
247+
$expr->class instanceof Name
248+
&& $methodReflection !== null
234249
&& !$methodReflection->isStatic()
235250
&& $methodReflection->getName() === '__construct'
236251
&& $scopeFunction instanceof MethodReflection

tests/PHPStan/Rules/Comparison/IfConstantConditionRuleTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,4 +237,10 @@ public function testBug6822(): void
237237
$this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-6822.php'], []);
238238
}
239239

240+
public function testBug5020(): void
241+
{
242+
$this->treatPhpDocTypesAsCertain = true;
243+
$this->analyse([__DIR__ . '/data/bug-5020.php'], []);
244+
}
245+
240246
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
<?php
2+
3+
namespace Bug5020;
4+
5+
interface ITransformer
6+
{
7+
public static function Transform(string $theInput, bool &$theErrorEncountered): string;
8+
}
9+
10+
class Transformer implements ITransformer
11+
{
12+
public static function Transform(string $theInput, bool &$theErrorEncountered): string
13+
{
14+
if ($theInput === 'invalid') {
15+
$theErrorEncountered = true;
16+
return '';
17+
}
18+
return strtoupper(trim($theInput));
19+
}
20+
}
21+
22+
/**
23+
* @param class-string<Transformer> $transformer
24+
*/
25+
function foo(string $transformer): void
26+
{
27+
$input = ' asdasda asdasd ';
28+
$error = false;
29+
$output = $transformer::Transform($input, $error);
30+
if ($error) {
31+
32+
}
33+
}

tests/PHPStan/Rules/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,10 @@ public function testRulePhp7(): void
5151
'Call to static method DateTimeImmutable::createFromFormat() on a separate line has no effect.',
5252
12,
5353
],
54+
[
55+
'Call to static method DateTimeImmutable::createFromFormat() on a separate line has no effect.',
56+
13,
57+
],
5458
[
5559
'Call to method DateTime::format() on a separate line has no effect.',
5660
23,
@@ -132,7 +136,20 @@ public function testBug10819(): void
132136

133137
public function testDynamicStaticCall(): void
134138
{
135-
$this->analyse([__DIR__ . '/data/dynamic-static-call.php'], []);
139+
$this->analyse([__DIR__ . '/data/dynamic-static-call.php'], [
140+
[
141+
'Call to static method DynamicStaticCall\Foo::doFoo() on a separate line has no effect.',
142+
32,
143+
],
144+
[
145+
'Call to static method DynamicStaticCall\FinalFoo::doFoo() on a separate line has no effect.',
146+
33,
147+
],
148+
[
149+
'Call to static method DynamicStaticCall\Bar::finalFoo() on a separate line has no effect.',
150+
34,
151+
],
152+
]);
136153
}
137154

138155
#[RequiresPhp('>= 8.5.0')]

tests/PHPStan/Rules/Methods/data/dynamic-static-call.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,8 +29,8 @@ final static public function finalFoo():int
2929

3030
class Baz {
3131
function doBaz(Foo $foo, FinalFoo $finalFoo, Bar $bar):void {
32-
$foo::doFoo(); // no error, subclass could override static method with impure impl
33-
$finalFoo::doFoo(); // could be "Call to static method .. on a separate line has no effect", because final class
34-
$bar::finalFoo(); // could be "Call to static method .. on a separate line has no effect", because final method
32+
$foo::doFoo();
33+
$finalFoo::doFoo();
34+
$bar::finalFoo();
3535
}
3636
}

tests/PHPStan/Rules/Pure/PureFunctionRuleTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,4 +219,10 @@ public function testBug14511(): void
219219
$this->analyse([__DIR__ . '/data/bug-14511.php'], []);
220220
}
221221

222+
#[RequiresPhp('>= 8.1.0')]
223+
public function testBug14557(): void
224+
{
225+
$this->analyse([__DIR__ . '/data/bug-14557-function.php'], []);
226+
}
227+
222228
}

tests/PHPStan/Rules/Pure/PureMethodRuleTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -389,4 +389,16 @@ public function testBug14511(): void
389389
$this->analyse([__DIR__ . '/data/bug-14511-method.php'], []);
390390
}
391391

392+
#[RequiresPhp('>= 8.1.0')]
393+
public function testBug14557(): void
394+
{
395+
$this->treatPhpDocTypesAsCertain = true;
396+
$this->analyse([__DIR__ . '/data/bug-14557.php'], [
397+
[
398+
'Impure call to method Bug14557\SomeClass::impureStaticMethod() in pure method Bug14557\Foo::impureViaClassString().',
399+
93,
400+
],
401+
]);
402+
}
403+
392404
}
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14557Function;
6+
7+
enum MyEnum: string
8+
{
9+
case Foo = 'foo';
10+
case Bar = 'bar';
11+
}
12+
13+
/**
14+
* @param enum-string<MyEnum> $enum
15+
* @phpstan-pure
16+
*/
17+
function fromEnumString(string $enum): MyEnum
18+
{
19+
return $enum::from('foo');
20+
}
21+
22+
/**
23+
* @param enum-string<MyEnum> $enum
24+
* @phpstan-pure
25+
*/
26+
function tryFromEnumString(string $enum): ?MyEnum
27+
{
28+
return $enum::tryFrom('foo');
29+
}
30+
31+
/**
32+
* @param class-string<MyEnum> $enum
33+
* @phpstan-pure
34+
*/
35+
function fromClassString(string $enum): MyEnum
36+
{
37+
return $enum::from('foo');
38+
}
39+
40+
/**
41+
* @param class-string<MyEnum> $enum
42+
* @phpstan-pure
43+
*/
44+
function tryFromClassString(string $enum): ?MyEnum
45+
{
46+
return $enum::tryFrom('foo');
47+
}
48+
49+
/**
50+
* @phpstan-pure
51+
*/
52+
function fromEnum(MyEnum $enum): MyEnum
53+
{
54+
return $enum::from('foo');
55+
}
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14557;
6+
7+
enum MyEnum: string
8+
{
9+
case Foo = 'foo';
10+
case Bar = 'bar';
11+
}
12+
13+
class SomeClass
14+
{
15+
16+
/** @phpstan-pure */
17+
public static function pureStaticMethod(): int
18+
{
19+
return 1;
20+
}
21+
22+
/** @phpstan-impure */
23+
public static function impureStaticMethod(): int
24+
{
25+
echo 'hello';
26+
return 1;
27+
}
28+
29+
}
30+
31+
class Foo
32+
{
33+
34+
/**
35+
* @param enum-string<MyEnum> $enum
36+
* @phpstan-pure
37+
*/
38+
public function doFoo(string $enum): MyEnum
39+
{
40+
return $enum::from('foo');
41+
}
42+
43+
/**
44+
* @param enum-string<MyEnum> $enum
45+
* @phpstan-pure
46+
*/
47+
public function doBar(string $enum): ?MyEnum
48+
{
49+
return $enum::tryFrom('foo');
50+
}
51+
52+
/**
53+
* @param class-string<MyEnum> $enum
54+
* @phpstan-pure
55+
*/
56+
public function doBaz(string $enum): MyEnum
57+
{
58+
return $enum::from('foo');
59+
}
60+
61+
/**
62+
* @param class-string<MyEnum> $enum
63+
* @phpstan-pure
64+
*/
65+
public function doLorem(string $enum): ?MyEnum
66+
{
67+
return $enum::tryFrom('foo');
68+
}
69+
70+
/**
71+
* @phpstan-pure
72+
*/
73+
public function fromEnum(MyEnum $enum): MyEnum
74+
{
75+
return $enum::from('foo');
76+
}
77+
78+
/**
79+
* @param class-string<SomeClass> $class
80+
* @phpstan-pure
81+
*/
82+
public function pureViaClassString(string $class): int
83+
{
84+
return $class::pureStaticMethod();
85+
}
86+
87+
/**
88+
* @param class-string<SomeClass> $class
89+
* @phpstan-pure
90+
*/
91+
public function impureViaClassString(string $class): int
92+
{
93+
return $class::impureStaticMethod(); // error
94+
}
95+
96+
}

0 commit comments

Comments
 (0)