Skip to content

Commit 0e675d9

Browse files
committed
Improve impure tip
1 parent cf070ae commit 0e675d9

File tree

3 files changed

+96
-32
lines changed

3 files changed

+96
-32
lines changed

src/Analyser/MutatingScope.php

Lines changed: 44 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -787,8 +787,9 @@ public function getMaybeDefinedVariables(): array
787787
public function findPossiblyImpureCallDescriptions(Expr $expr): array
788788
{
789789
$nodeFinder = new NodeFinder();
790-
$descriptions = [];
790+
$callExprDescriptions = [];
791791
$foundCallExprMatch = false;
792+
$matchedCallExprKeys = [];
792793
foreach ($this->expressionTypes as $holder) {
793794
$holderExpr = $holder->getExpr();
794795
if (!$holderExpr instanceof PossiblyImpureCallExpr) {
@@ -810,6 +811,7 @@ public function findPossiblyImpureCallDescriptions(Expr $expr): array
810811
}
811812

812813
$foundCallExprMatch = true;
814+
$matchedCallExprKeys[$callExprKey] = true;
813815

814816
// Only show the tip when the scope's type for the call expression
815817
// differs from the declared return type, meaning control flow
@@ -821,58 +823,70 @@ public function findPossiblyImpureCallDescriptions(Expr $expr): array
821823
continue;
822824
}
823825

824-
$descriptions[] = $holderExpr->getCallDescription();
825-
}
826-
827-
if (count($descriptions) > 0) {
828-
return array_values(array_unique($descriptions));
826+
$callExprDescriptions[] = $holderExpr->getCallDescription();
829827
}
830828

831829
// If the first pass found a callExpr in the error expression but
832830
// filtered it out (return type wasn't narrowed), the error is
833831
// explained by the return type alone - skip the fallback.
834-
if ($foundCallExprMatch) {
832+
if ($foundCallExprMatch && count($callExprDescriptions) === 0) {
835833
return [];
836834
}
837835

838-
// Fallback: match by impactedExpr for cases where a maybe-impure method
836+
// Second pass: match by impactedExpr for cases where a maybe-impure method
839837
// on an object didn't invalidate it, but a different method's return
840838
// value was narrowed on that object.
841839
// Skip when the expression itself is a direct method/static call -
842840
// those are passed by ImpossibleCheckType rules where the error is
843841
// about the call's arguments, not about object state.
844-
if ($expr instanceof Expr\MethodCall || $expr instanceof Expr\StaticCall) {
845-
return [];
846-
}
847-
foreach ($this->expressionTypes as $holder) {
848-
$holderExpr = $holder->getExpr();
849-
if (!$holderExpr instanceof PossiblyImpureCallExpr) {
850-
continue;
851-
}
842+
if (!($expr instanceof Expr\MethodCall || $expr instanceof Expr\StaticCall)) {
843+
$impactedExprDescriptions = [];
844+
foreach ($this->expressionTypes as $holder) {
845+
$holderExpr = $holder->getExpr();
846+
if (!$holderExpr instanceof PossiblyImpureCallExpr) {
847+
continue;
848+
}
852849

853-
$impactedExprKey = $this->getNodeKey($holderExpr->impactedExpr);
850+
$impactedExprKey = $this->getNodeKey($holderExpr->impactedExpr);
854851

855-
// Skip if impactedExpr is the same as callExpr (function calls)
856-
if ($impactedExprKey === $this->getNodeKey($holderExpr->callExpr)) {
857-
continue;
858-
}
852+
// Skip if impactedExpr is the same as callExpr (function calls)
853+
if ($impactedExprKey === $this->getNodeKey($holderExpr->callExpr)) {
854+
continue;
855+
}
859856

860-
$found = $nodeFinder->findFirst([$expr], function (Node $node) use ($impactedExprKey): bool {
861-
if (!$node instanceof Expr) {
862-
return false;
857+
// Skip if this entry's callExpr was already matched in the first pass
858+
$callExprKey = $this->getNodeKey($holderExpr->callExpr);
859+
if (isset($matchedCallExprKeys[$callExprKey])) {
860+
continue;
863861
}
864862

865-
return $this->getNodeKey($node) === $impactedExprKey;
866-
});
863+
$found = $nodeFinder->findFirst([$expr], function (Node $node) use ($impactedExprKey): bool {
864+
if (!$node instanceof Expr) {
865+
return false;
866+
}
867867

868-
if ($found === null) {
869-
continue;
868+
return $this->getNodeKey($node) === $impactedExprKey;
869+
});
870+
871+
if ($found === null) {
872+
continue;
873+
}
874+
875+
$impactedExprDescriptions[] = $holderExpr->getCallDescription();
870876
}
871877

872-
$descriptions[] = $holderExpr->getCallDescription();
878+
// Prefer impactedExpr matches (intermediate calls that could have
879+
// invalidated the object) over callExpr matches
880+
if (count($impactedExprDescriptions) > 0) {
881+
return array_values(array_unique($impactedExprDescriptions));
882+
}
883+
}
884+
885+
if (count($callExprDescriptions) > 0) {
886+
return array_values(array_unique($callExprDescriptions));
873887
}
874888

875-
return array_values(array_unique($descriptions));
889+
return [];
876890
}
877891

878892
private function isGlobalVariable(string $variableName): bool

tests/PHPStan/Rules/Comparison/StrictComparisonOfDifferentTypesRuleTest.php

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1125,14 +1125,27 @@ public function testPossiblyImpureTip(): void
11251125
// Object invalidation: @phpstan-impure intermediate - no error ($this invalidated)
11261126
// Object invalidation: void intermediate - no error ($this invalidated)
11271127

1128+
// Intermediate maybe-impure call takes priority over direct call
1129+
[
1130+
'Strict comparison using === between 1 and 2 will always evaluate to false.',
1131+
294,
1132+
'If PossiblyImpureTip\IntermediateCallPriority::next() is impure, add <fg=cyan>@phpstan-impure</> PHPDoc tag above its declaration.',
1133+
],
1134+
// No intermediate call: tip points to fetch() itself
1135+
[
1136+
'Strict comparison using === between 1 and 2 will always evaluate to false.',
1137+
303,
1138+
'If PossiblyImpureTip\IntermediateCallPriority::fetch() is impure, add <fg=cyan>@phpstan-impure</> PHPDoc tag above its declaration.',
1139+
],
1140+
11281141
// No tip when return type alone explains the error
11291142
[
11301143
'Strict comparison using === between string and null will always evaluate to false.',
1131-
287,
1144+
324,
11321145
],
11331146
[
11341147
'Strict comparison using !== between string and null will always evaluate to true.',
1135-
291,
1148+
328,
11361149
],
11371150
]);
11381151
}

tests/PHPStan/Rules/Comparison/data/possibly-impure-tip.php

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -270,6 +270,43 @@ public function testVoidIntermediate(): void
270270

271271
}
272272

273+
// --- Intermediate maybe-impure call takes priority over direct call ---
274+
275+
class IntermediateCallPriority
276+
{
277+
278+
public function fetch(): int
279+
{
280+
return rand(1, 100);
281+
}
282+
283+
public function next(): bool
284+
{
285+
return true;
286+
}
287+
288+
public function testIntermediateCallTipPriority(): void
289+
{
290+
// fetch() narrowed to 1, next() is intermediate maybe-impure call
291+
// tip should point to next(), not fetch()
292+
if ($this->fetch() === 1) {
293+
$this->next();
294+
if ($this->fetch() === 2) { // always false, tip for next()
295+
}
296+
}
297+
}
298+
299+
public function testNoIntermediateCall(): void
300+
{
301+
// No intermediate call: tip should point to fetch() itself
302+
if ($this->fetch() === 1) {
303+
if ($this->fetch() === 2) { // always false, tip for fetch()
304+
}
305+
}
306+
}
307+
308+
}
309+
273310
// --- No tip when return type alone explains the error ---
274311

275312
class NoTipWhenReturnTypeExplains

0 commit comments

Comments
 (0)