Skip to content

Commit 4177ab7

Browse files
committed
Merge pull request #6249 from phpstan/loop-convergence
2 parents b38b410 + 5fc876b commit 4177ab7

9 files changed

Lines changed: 312 additions & 14 deletions

File tree

src/Analyser/ExpressionResultStorage.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,4 +41,17 @@ public function findBeforeScope(Expr $expr): ?Scope
4141
return $this->scopesById[spl_object_id($expr)] ?? null;
4242
}
4343

44+
/**
45+
* Adopts every before-scope the other storage stored - a finished
46+
* convergence pass's stores answer for the final walk its replay
47+
* replaces.
48+
*/
49+
public function mergeResults(self $other): void
50+
{
51+
foreach ($other->exprsById as $id => $expr) {
52+
$this->exprsById[$id] = $expr;
53+
$this->scopesById[$id] = $other->scopesById[$id];
54+
}
55+
}
56+
4457
}

src/Analyser/NodeScopeResolver.php

Lines changed: 126 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,7 @@
106106
use function array_values;
107107
use function count;
108108
use function in_array;
109+
use function is_array;
109110
use function is_int;
110111
use function is_string;
111112
use function max;
@@ -913,6 +914,91 @@ public function getAssignedVariables(Expr $expr): array
913914
return [];
914915
}
915916

917+
private const REPLAYABLE_BODY_ATTRIBUTE = 'convergenceReplayableBody';
918+
919+
/**
920+
* Whether a recorded convergence pass over the loop body can replace the
921+
* final walk. A pass runs at deep statement context, the final walk at top
922+
* level - constructs that analyse differently between the two (nested
923+
* loop/label fixpoints run only at top level, statement-level classes are
924+
* skipped at deep context) disqualify the body. Closure bodies process
925+
* context-independently and are not traversed.
926+
*
927+
* @param Node\Stmt[] $bodyStmts
928+
*/
929+
public function isReplayableConvergenceBody(Node $loopNode, array $bodyStmts): bool
930+
{
931+
$cached = $loopNode->getAttribute(self::REPLAYABLE_BODY_ATTRIBUTE);
932+
if ($cached !== null) {
933+
return $cached;
934+
}
935+
936+
$replayable = true;
937+
foreach ($bodyStmts as $bodyStmt) {
938+
if ($this->hasContextSensitiveConstruct($bodyStmt)) {
939+
$replayable = false;
940+
break;
941+
}
942+
}
943+
$loopNode->setAttribute(self::REPLAYABLE_BODY_ATTRIBUTE, $replayable);
944+
945+
return $replayable;
946+
}
947+
948+
private function hasContextSensitiveConstruct(Node $node): bool
949+
{
950+
if ($node instanceof Expr\Closure) {
951+
return false;
952+
}
953+
if (
954+
$node instanceof Node\Stmt\While_
955+
|| $node instanceof Node\Stmt\Do_
956+
|| $node instanceof Node\Stmt\For_
957+
|| $node instanceof Foreach_
958+
|| $node instanceof Node\Stmt\Label
959+
|| $node instanceof Node\Stmt\ClassLike
960+
) {
961+
return true;
962+
}
963+
964+
foreach ($node->getSubNodeNames() as $subNodeName) {
965+
$subNode = $node->$subNodeName;
966+
if ($subNode instanceof Node) {
967+
if ($this->hasContextSensitiveConstruct($subNode)) {
968+
return true;
969+
}
970+
} elseif (is_array($subNode)) {
971+
foreach ($subNode as $item) {
972+
if ($item instanceof Node && $this->hasContextSensitiveConstruct($item)) {
973+
return true;
974+
}
975+
}
976+
}
977+
}
978+
979+
return false;
980+
}
981+
982+
/**
983+
* Replays a recorded convergence pass's emissions through the real node
984+
* callback in place of the final loop walk. The pass's storage was merged
985+
* into $storage by the caller; binding it for the whole replay lets the
986+
* recorded scopes answer rule asks from the stored before-scopes, the
987+
* same way the repeated walk's per-emission binding would.
988+
*
989+
* @param callable(Node $node, Scope $scope): void $nodeCallback
990+
*/
991+
public function replayRecording(RecordingNodeCallback $recording, callable $nodeCallback, ExpressionResultStorage $storage): void
992+
{
993+
$stack = $this->getExpressionResultStorageStack();
994+
$stack->push($storage);
995+
try {
996+
$recording->replayThrough($nodeCallback);
997+
} finally {
998+
$stack->pop();
999+
}
1000+
}
1001+
9161002
/**
9171003
* @param callable(Node $node, Scope $scope): void $nodeCallback
9181004
*/
@@ -955,6 +1041,13 @@ public function callNodeCallback(
9551041
return;
9561042
}
9571043

1044+
if ($nodeCallback instanceof RecordingNodeCallback) {
1045+
// recording never asks about types - the pairs are wrapped and
1046+
// bound to the storage at replay time instead
1047+
$nodeCallback($node, $scope);
1048+
return;
1049+
}
1050+
9581051
// post-order emission means the node's own result and every subnode
9591052
// result are already stored when the callback fires - NodeCallbackScope
9601053
// answers every ask synchronously from the storage; the emitting
@@ -1126,15 +1219,29 @@ public function processClosureNode(
11261219

11271220
$count = 0;
11281221
$closureResultScope = null;
1222+
$replayBodyRecording = null;
1223+
$replayPassStorage = null;
1224+
$replayPassResult = null;
1225+
$replayEntryScope = null;
1226+
$bodyIsReplayable = $this->isReplayableConvergenceBody($expr, $expr->stmts);
11291227
do {
11301228
$prevScope = $closureScope;
11311229

11321230
$storage = $originalStorage->duplicate();
1231+
$bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback();
11331232
// deep context, like the loop handlers' own convergence passes: inner
11341233
// loops walk single-pass here and only the final walk below (top-level)
11351234
// runs their full convergence - otherwise every closure-convergence
11361235
// pass would re-converge every inner loop from scratch
1137-
$intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, new NoopNodeCallback(), StatementContext::createDeep());
1236+
$intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $bodyRecording, StatementContext::createDeep());
1237+
// the candidate to replace the final walk when this pass's entry
1238+
// turns out to be the fixpoint
1239+
if ($bodyRecording instanceof RecordingNodeCallback) {
1240+
$replayBodyRecording = $bodyRecording;
1241+
$replayPassStorage = $storage;
1242+
$replayPassResult = $intermediaryClosureScopeResult;
1243+
$replayEntryScope = $prevScope;
1244+
}
11381245
$intermediaryClosureScope = $intermediaryClosureScopeResult->getScope();
11391246
foreach ($intermediaryClosureScopeResult->getExitPoints() as $exitPoint) {
11401247
$intermediaryClosureScope = $intermediaryClosureScope->mergeWith($exitPoint->getScope());
@@ -1162,7 +1269,24 @@ public function processClosureNode(
11621269
}
11631270

11641271
$storage = $originalStorage;
1165-
$statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel());
1272+
if (
1273+
$replayBodyRecording !== null && $replayPassStorage !== null
1274+
&& $replayPassResult !== null && $replayEntryScope !== null
1275+
&& $closureScope->equals($replayEntryScope)
1276+
) {
1277+
// the final walk would repeat the recorded fixpoint pass exactly
1278+
// (same entry scope, deterministic walk) - adopt the pass's result
1279+
// and replay its emissions through the gathering callback instead.
1280+
// The pass's own entry scope takes over: the recorded pairs carry
1281+
// its anonymous-function reflection, which the gathering filter
1282+
// compares by identity (the state is equals-identical anyway).
1283+
$closureScope = $replayEntryScope;
1284+
$originalStorage->mergeResults($replayPassStorage);
1285+
$this->replayRecording($replayBodyRecording, $closureStmtsCallback, $originalStorage);
1286+
$statementResult = $replayPassResult;
1287+
} else {
1288+
$statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel());
1289+
}
11661290
$publicStatementResult = $statementResult->toPublic();
11671291
$closureReturnStatementsNodeScope = $this->refineClosureNodeScope($closureScope, $scope, $expr, $gatheredReturnStatementsWithScope, $gatheredYieldStatementsWithScope, $executionEnds, $statementResult->getThrowPoints(), array_merge($closureImpurePoints, $statementResult->getImpurePoints()), $invalidateExpressions);
11681292
$this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode(
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace PHPStan\Analyser;
4+
5+
use PhpParser\Node;
6+
use PHPStan\ShouldNotHappenException;
7+
8+
/**
9+
* Records every (node, scope) emission of a convergence pass in order. When
10+
* the pass turns out to be the fixpoint (the final walk's entry scope equals
11+
* the pass's entry), the final walk is replaced by replaying the recording
12+
* through the real node callback - the scopes are state-equal to what the
13+
* repeated walk would emit.
14+
*
15+
* Recording appends the raw walk scope - nothing asks about types until a
16+
* replay happens, so the pass pays no callback-scope construction and no
17+
* storage binding. replayThrough() wraps each pair the way
18+
* NodeScopeResolver::callNodeCallback() would have.
19+
*/
20+
final class RecordingNodeCallback
21+
{
22+
23+
/** @var list<array{Node, Scope}> */
24+
private array $pairs = [];
25+
26+
public function __invoke(Node $node, Scope $scope): void
27+
{
28+
$this->pairs[] = [$node, $scope];
29+
}
30+
31+
/**
32+
* @param callable(Node $node, Scope $scope): void $nodeCallback
33+
*/
34+
public function replayThrough(callable $nodeCallback): void
35+
{
36+
foreach ($this->pairs as [$node, $scope]) {
37+
if (!$scope instanceof MutatingScope) {
38+
throw new ShouldNotHappenException();
39+
}
40+
$nodeCallback($node, $scope->toNodeCallbackScope());
41+
}
42+
}
43+
44+
}

src/Analyser/StmtHandler/DoWhileHandler.php

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
use PHPStan\Analyser\MutatingScope;
1313
use PHPStan\Analyser\NodeScopeResolver;
1414
use PHPStan\Analyser\NoopNodeCallback;
15+
use PHPStan\Analyser\RecordingNodeCallback;
1516
use PHPStan\Analyser\StatementContext;
1617
use PHPStan\Analyser\StmtHandler;
1718
use PHPStan\DependencyInjection\AutowiredService;
@@ -48,8 +49,12 @@ public function processStmt(
4849
$impurePoints = [];
4950
$originalStorage = $storage;
5051

52+
$replayBodyRecording = null;
53+
$replayPassStorage = null;
54+
$replayPassResult = null;
55+
$prevEntryScope = null;
5156
if ($context->isTopLevel()) {
52-
$prevEntryScope = null;
57+
$bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts);
5358
do {
5459
$prevScope = $bodyScope;
5560
$bodyScope = $bodyScope->mergeWith($scope);
@@ -62,7 +67,8 @@ public function processStmt(
6267
}
6368
$prevEntryScope = $bodyScope;
6469
$storage = $originalStorage->duplicate();
65-
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints();
70+
$bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback();
71+
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints();
6672
$alwaysTerminating = $bodyScopeResult->isAlwaysTerminating();
6773
$bodyScope = $bodyScopeResult->getScope();
6874
foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
@@ -72,6 +78,13 @@ public function processStmt(
7278
foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) {
7379
$finalScope = $breakExitPoint->getScope()->mergeWith($finalScope);
7480
}
81+
// the candidate to replace the final body walk when this pass's
82+
// entry turns out to be the fixpoint
83+
if ($bodyRecording instanceof RecordingNodeCallback) {
84+
$replayBodyRecording = $bodyRecording;
85+
$replayPassStorage = $storage;
86+
$replayPassResult = $bodyScopeResult;
87+
}
7588
$bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope();
7689
if ($bodyScope->equals($prevScope)) {
7790
break;
@@ -87,7 +100,20 @@ public function processStmt(
87100
}
88101

89102
$storage = $originalStorage;
90-
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints();
103+
if (
104+
$replayBodyRecording !== null && $replayPassStorage !== null && $replayPassResult !== null
105+
&& $prevEntryScope !== null && $bodyScope->equals($prevEntryScope)
106+
) {
107+
// the final body walk would repeat the recorded fixpoint pass exactly
108+
// (same entry scope, deterministic walk) - adopt the pass's results
109+
// and replay its emissions through the real callback instead; the
110+
// condition walks below stay real
111+
$originalStorage->mergeResults($replayPassStorage);
112+
$nodeScopeResolver->replayRecording($replayBodyRecording, $nodeCallback, $originalStorage);
113+
$bodyScopeResult = $replayPassResult;
114+
} else {
115+
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints();
116+
}
91117
$bodyScope = $bodyScopeResult->getScope();
92118
foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
93119
$bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope());

src/Analyser/StmtHandler/ForeachHandler.php

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
use PHPStan\Analyser\MutatingScope;
2929
use PHPStan\Analyser\NodeScopeResolver;
3030
use PHPStan\Analyser\NoopNodeCallback;
31+
use PHPStan\Analyser\RecordingNodeCallback;
3132
use PHPStan\Analyser\Scope;
3233
use PHPStan\Analyser\StatementContext;
3334
use PHPStan\Analyser\StmtHandler;
@@ -135,6 +136,10 @@ public function processStmt(
135136
$iterateeScope = $nodeScopeResolver->shouldPolluteScopeWithAlwaysIterableForeach() ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope;
136137

137138
$originalStorage = $storage;
139+
$replayBodyRecording = null;
140+
$replayPassStorage = null;
141+
$replayPassResult = null;
142+
$replayEntryScope = null;
138143
$unrolledEndScope = null;
139144
$unrolledTotalKeys = null;
140145
if ($context->isTopLevel()) {
@@ -152,6 +157,7 @@ public function processStmt(
152157
$bodyScope = $this->enterForeach($nodeScopeResolver, $originalScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback);
153158
$count = 0;
154159
$prevEntryScope = null;
160+
$bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts);
155161
do {
156162
$prevScope = $bodyScope;
157163
$bodyScope = $bodyScope->mergeWith($iterateeScope);
@@ -164,11 +170,20 @@ public function processStmt(
164170
$prevEntryScope = $bodyScope;
165171
$storage = $originalStorage->duplicate();
166172
$bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback);
167-
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints();
173+
$bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback();
174+
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints();
168175
$bodyScope = $bodyScopeResult->getScope();
169176
foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
170177
$bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope());
171178
}
179+
// the candidate to replace the final walk when this pass's
180+
// entry turns out to be the fixpoint
181+
if ($bodyRecording instanceof RecordingNodeCallback) {
182+
$replayBodyRecording = $bodyRecording;
183+
$replayPassStorage = $storage;
184+
$replayPassResult = $bodyScopeResult;
185+
$replayEntryScope = $prevEntryScope;
186+
}
172187
if ($bodyScope->equals($prevScope)) {
173188
break;
174189
}
@@ -182,10 +197,24 @@ public function processStmt(
182197
}
183198

184199
$bodyScope = $bodyScope->mergeWith($iterateeScope);
200+
$finalEntryScope = $bodyScope;
185201
$storage = $originalStorage;
186202
$bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback);
187-
$finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context;
188-
$finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints();
203+
if (
204+
$replayBodyRecording !== null && $replayPassStorage !== null
205+
&& $replayPassResult !== null && $replayEntryScope !== null
206+
&& $unrolledTotalKeys === null && $finalEntryScope->equals($replayEntryScope)
207+
) {
208+
// the final walk would repeat the recorded fixpoint pass exactly
209+
// (same entry scope, deterministic walk) - adopt the pass's results
210+
// and replay its emissions through the real callback instead
211+
$originalStorage->mergeResults($replayPassStorage);
212+
$nodeScopeResolver->replayRecording($replayBodyRecording, $nodeCallback, $originalStorage);
213+
$finalScopeResult = $replayPassResult;
214+
} else {
215+
$finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context;
216+
$finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints();
217+
}
189218
$finalScope = $finalScopeResult->getScope();
190219
$scopesWithIterableValueType = [];
191220

0 commit comments

Comments
 (0)