Skip to content

Commit e4522ee

Browse files
committed
Resolve method reflection for dynamic static calls ($var::method()) to enable purity and side-effect checking
- In `StaticCallHandler::processExpr()`, when the class part of a static call is an expression (e.g. `$enum::from()`), resolve the method reflection via `getObjectTypeOrClassStringObjectType()` — the same approach already used in `resolveType()` for return type computation. - Previously, `$methodReflection` was always null for `$expr->class instanceof Expr`, causing a false "call to unknown method" impure point for every dynamic static call. - Guard `$this`-invalidation and promoted-property initialization logic with `$expr->class instanceof Name` to prevent incorrect scope effects when the call target is an expression (e.g. `$other::__construct()`). - Update `CallToStaticMethodStatementWithoutSideEffectsRuleTest::testDynamicStaticCall` expectations: pure dynamic static calls are now correctly detected as having no effect.
1 parent 22923ac commit e4522ee

6 files changed

Lines changed: 185 additions & 4 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 = TypeCombinator::removeNull($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/Methods/CallToStaticMethodStatementWithoutSideEffectsRuleTest.php

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,7 +132,20 @@ public function testBug10819(): void
132132

133133
public function testDynamicStaticCall(): void
134134
{
135-
$this->analyse([__DIR__ . '/data/dynamic-static-call.php'], []);
135+
$this->analyse([__DIR__ . '/data/dynamic-static-call.php'], [
136+
[
137+
'Call to static method DynamicStaticCall\Foo::doFoo() on a separate line has no effect.',
138+
32,
139+
],
140+
[
141+
'Call to static method DynamicStaticCall\FinalFoo::doFoo() on a separate line has no effect.',
142+
33,
143+
],
144+
[
145+
'Call to static method DynamicStaticCall\Bar::finalFoo() on a separate line has no effect.',
146+
34,
147+
],
148+
]);
136149
}
137150

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

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+
85,
400+
],
401+
]);
402+
}
403+
392404
}
Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
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+
* @param class-string<SomeClass> $class
72+
* @phpstan-pure
73+
*/
74+
public function pureViaClassString(string $class): int
75+
{
76+
return $class::pureStaticMethod();
77+
}
78+
79+
/**
80+
* @param class-string<SomeClass> $class
81+
* @phpstan-pure
82+
*/
83+
public function impureViaClassString(string $class): int
84+
{
85+
return $class::impureStaticMethod(); // error
86+
}
87+
88+
}

0 commit comments

Comments
 (0)