From f20111da81816fd456c46e8e53c3e552df2a49d2 Mon Sep 17 00:00:00 2001 From: Ondrej Mirtes Date: Sun, 23 Aug 2026 23:14:33 +0200 Subject: [PATCH] Consume unchanged effect-free subtrees during convergence passes A fixpoint re-walk (loop body convergence, backward-goto replay, by-ref closure convergence) re-walked the whole body every round. Each pass now chains the previous pass's storage as its baseline and keeps the full ExpressionResult of every expression it walks in a per-site frame: an expression whose stored result is effect-free (the walk derived no scope and carries no throw/impure points whose recorded scopes would replay stale state into catch merges) and whose read state - variables and tracked expression holders - did not change since the pass that walked it is consumed instead of re-walked. Only the loop-carried parts of the body re-walk. The final walk is untouched: it runs outside the pass mode on the outer storage. A consumption skips the subtree's recorded emissions, which would leave the fixpoint pass's recording incomplete and stop it from replacing the final walk. So every walked result also tags its emission segment [recording, start, end) and a consumption splices that segment from the pass that last walked the subtree into the consuming pass's recording - only a segment-less consumption gaps the pass and clears its replay candidacy. The spliced scopes agree with the replay on everything the consumed subtree reads - exactly what the consume gate certifies - and the chained pass storages carry the matching before-scopes. Consumption, tagging and frame stores apply only to walks carrying the pass's own storage: the old-world pricing walks that resolveType() starts for short-circuit operators re-enter processExprNode() with a fresh throwaway storage - they emit nowhere, so they may consume stored results but must not splice segments (a mispriced splice duplicated emissions in the pass recording), store results, or tag brackets. The site and pass frames live on NodeScopeResolver like the storage stack; the public processNodes()/processStmtNodes() entries suspend them, so a fresh walk an extension starts mid-analysis does not inherit the interrupted walk's convergence state. Unlike the originating branch, no recording clearing is needed: full results live only in site-scoped frames that pop when the site's convergence loop finishes, so a segment can never outlive its recording. (adapted from commits 4a25082c9731ce6e863b9532084ccb9d99b85e97 and 3e9d22eb5b2099be33ea3209ca651178c745f243) Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GnwgpaeUXRkgSDyg95tfK8 --- src/Analyser/ExpressionResult.php | 284 ++++++++++++ src/Analyser/NodeScopeResolver.php | 457 ++++++++++++++++---- src/Analyser/RecordingNodeCallback.php | 20 + src/Analyser/StmtHandler/DoWhileHandler.php | 111 +++-- src/Analyser/StmtHandler/ForHandler.php | 83 ++-- src/Analyser/StmtHandler/ForeachHandler.php | 152 ++++--- src/Analyser/StmtHandler/WhileHandler.php | 105 +++-- 7 files changed, 959 insertions(+), 253 deletions(-) diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 0ec7eb15979..48ab1406a48 100644 --- a/src/Analyser/ExpressionResult.php +++ b/src/Analyser/ExpressionResult.php @@ -2,14 +2,34 @@ namespace PHPStan\Analyser; +use PhpParser\Node; use PhpParser\Node\Expr; use PHPStan\DependencyInjection\GenerateFactory; +use PHPStan\Node\Printer\ExprPrinter; use PHPStan\Type\Type; +use function array_keys; +use function is_array; +use function is_string; #[GenerateFactory(interface: ExpressionResultFactory::class)] final class ExpressionResult { + /** @var list|null */ + private ?array $readVariableNames = null; + + /** @var list|null */ + private ?array $readStateKeys = null; + + /** + * The subtree's emission segment in the convergence-pass recording that + * last walked it: [recording, start, end). A consumption splices it into + * the consuming pass's recording so that recording stays complete. + * + * @var array{RecordingNodeCallback, int, int}|null + */ + private ?array $recordingSegment = null; + /** @var (callable(): MutatingScope)|null */ private $truthyScopeCallback; @@ -160,4 +180,268 @@ public function getNativeType(): Type return $this->beforeScope->getNativeType($this->expr); } + /** + * Whether replaying this result at a foreign position with matching read + * state is exact: the walk derived no scope (the same instance came out as + * went in), and there are no throw/impure points whose recorded scopes + * would replay stale state (a try/catch merges throw-point scopes into its + * catch entries). + */ + public function isEffectFree(): bool + { + return $this->scope === $this->beforeScope + && $this->throwPoints === [] + && $this->impurePoints === []; + } + + /** + * Whether everything this expression reads - variables and tracked + * expression holders (property fetches, remembered call results) - has the + * same state at the given scope as at this result's own walk position. A + * convergence pass may then consume the stored result instead of + * re-walking the subtree. + */ + public function readStateMatches(MutatingScope $scope, bool $useNativeTypes): bool + { + // same unpromoted position implies same promoted position - skip the + // flavour derivation for the common same-position case + if ($scope === $this->beforeScope) { + return true; + } + // a closure's stored result IS its (by-ref converged) walk and the walk + // derives no state from the enclosing position (by-ref effects would + // fail the effect-free gate); its position-sensitive TYPE is priced by + // getType() on the consuming position, not by the walk + if ($this->expr instanceof Expr\Closure || $this->expr instanceof Expr\ArrowFunction) { + return true; + } + $names = $this->getReadVariableNames(); + $stateKeys = $this->getReadStateKeys($scope); + if ($names === [] && $stateKeys === []) { + return true; + } + + $readScope = $useNativeTypes ? $scope->doNotTreatPhpDocTypesAsCertain() : $scope; + $positionScope = $useNativeTypes ? $this->beforeScope->doNotTreatPhpDocTypesAsCertain() : $this->beforeScope; + if ($readScope === $positionScope) { + return true; + } + + foreach ($names as $name) { + $askKnows = $readScope->hasVariableType($name); + $positionKnows = $positionScope->hasVariableType($name); + if ($askKnows->no() && $positionKnows->no()) { + continue; + } + if (!$askKnows->equals($positionKnows)) { + return false; + } + $askType = $readScope->getVariableType($name); + $positionType = $positionScope->getVariableType($name); + // identity short-circuits the equals() for unchanged variables - + // the common case between converged passes + if ($askType !== $positionType && !$askType->equals($positionType)) { + return false; + } + } + + foreach ($stateKeys as $stateKey) { + $askHolder = $readScope->expressionTypes[$stateKey] ?? null; + $positionHolder = $positionScope->expressionTypes[$stateKey] ?? null; + // unchanged holders stay the same object across derived scopes + if ($askHolder === $positionHolder) { + continue; + } + if ($askHolder === null || $positionHolder === null) { + return false; + } + if (!$askHolder->getCertainty()->equals($positionHolder->getCertainty())) { + return false; + } + $askType = $askHolder->getType(); + $positionType = $positionHolder->getType(); + if ($askType !== $positionType && !$askType->equals($positionType)) { + return false; + } + } + + return true; + } + + public function setRecordingSegment(RecordingNodeCallback $recording, int $start, int $end): void + { + $this->recordingSegment = [$recording, $start, $end]; + } + + /** + * @return array{RecordingNodeCallback, int, int}|null + */ + public function getRecordingSegment(): ?array + { + return $this->recordingSegment; + } + + /** + * A copy of this result answering at a foreign consuming position: the + * scopes are re-anchored to the consuming scope, and the truthy/falsey + * callbacks are dropped - the originals capture the walk position's scopes + * and would answer stale narrowing; the defaults recompute on the new + * position. + */ + public function atAskPosition(MutatingScope $scope): self + { + $clone = clone $this; + $clone->scope = $scope; + $clone->beforeScope = $scope; + $clone->truthyScope = null; + $clone->falseyScope = null; + $clone->truthyScopeCallback = null; + $clone->falseyScopeCallback = null; + + return $clone; + } + + /** + * @return list + */ + private function getReadVariableNames(): array + { + return $this->readVariableNames ??= self::collectReadVariableNames($this->expr); + } + + /** + * The names are pure syntax, so they cache on the AST node itself as an + * attribute (sharing the node's own lifetime) - a deep fetch/call chain + * composes each link's set from its child's cached set in O(1) amortized + * instead of re-traversing the whole subtree per link (and per + * loop-convergence pass, which recreates the results). + */ + private const READ_VARIABLE_NAMES_ATTRIBUTE = 'readVariableNames'; + + /** + * @return list + */ + private static function collectReadVariableNames(Node $node): array + { + if ($node instanceof Expr) { + /** @var list|null $cached */ + $cached = $node->getAttribute(self::READ_VARIABLE_NAMES_ATTRIBUTE); + if ($cached !== null) { + return $cached; + } + } + + $names = []; + // $this included: its tracked holder does change ($this instanceof + // narrowing, ArrayAccess-style writes through $this[...]), and the + // identity shortcut in the comparison keeps the unchanged case cheap + if ($node instanceof Expr\Variable && is_string($node->name)) { + $names[$node->name] = true; + } + if ($node instanceof Expr\Closure) { + // a closure body's variables live in its own scope - only the + // use() clause reads the enclosing position. Arrow functions + // capture implicitly and are traversed. + foreach ($node->uses as $use) { + if (!is_string($use->var->name)) { + continue; + } + $names[$use->var->name] = true; + } + } else { + foreach ($node->getSubNodeNames() as $subNodeName) { + $subNode = $node->$subNodeName; + if ($subNode instanceof Node) { + foreach (self::collectReadVariableNames($subNode) as $name) { + $names[$name] = true; + } + } elseif (is_array($subNode)) { + foreach ($subNode as $item) { + if (!$item instanceof Node) { + continue; + } + foreach (self::collectReadVariableNames($item) as $name) { + $names[$name] = true; + } + } + } + } + } + + $result = array_keys($names); + if ($node instanceof Expr) { + $node->setAttribute(self::READ_VARIABLE_NAMES_ATTRIBUTE, $result); + } + + return $result; + } + + /** + * @return list + */ + private function getReadStateKeys(MutatingScope $scope): array + { + return $this->readStateKeys ??= self::collectReadStateKeys($this->expr, $scope->getExprPrinter()); + } + + private const READ_STATE_KEYS_ATTRIBUTE = 'readStateKeys'; + + /** + * ExprStrings of the subtree's expressions whose state a scope can track as + * a holder (property fetches, dim fetches, remembered call and constant + * results) - the non-variable half of the expression's read set. Variables + * are compared by name via collectReadVariableNames(); scalars are never + * tracked; a closure's body lives in its own scope (only its use() clause, + * covered by the variable set, reads the enclosing position). + * + * @return list + */ + private static function collectReadStateKeys(Node $node, ExprPrinter $exprPrinter): array + { + if ($node instanceof Expr) { + /** @var list|null $cached */ + $cached = $node->getAttribute(self::READ_STATE_KEYS_ATTRIBUTE); + if ($cached !== null) { + return $cached; + } + } + + $keys = []; + if ( + $node instanceof Expr + && !$node instanceof Expr\Variable + && !$node instanceof Node\Scalar + && !$node instanceof Expr\Closure + && !$node instanceof Expr\ArrowFunction + ) { + $keys[$exprPrinter->printExpr($node)] = true; + } + if (!$node instanceof Expr\Closure) { + foreach ($node->getSubNodeNames() as $subNodeName) { + $subNode = $node->$subNodeName; + if ($subNode instanceof Node) { + foreach (self::collectReadStateKeys($subNode, $exprPrinter) as $key) { + $keys[$key] = true; + } + } elseif (is_array($subNode)) { + foreach ($subNode as $item) { + if (!$item instanceof Node) { + continue; + } + foreach (self::collectReadStateKeys($item, $exprPrinter) as $key) { + $keys[$key] = true; + } + } + } + } + } + + $result = array_keys($keys); + if ($node instanceof Expr) { + $node->setAttribute(self::READ_STATE_KEYS_ATTRIBUTE, $result); + } + + return $result; + } + } diff --git a/src/Analyser/NodeScopeResolver.php b/src/Analyser/NodeScopeResolver.php index 95bdb58e739..85370b206f4 100644 --- a/src/Analyser/NodeScopeResolver.php +++ b/src/Analyser/NodeScopeResolver.php @@ -102,6 +102,7 @@ use function array_last; use function array_map; use function array_merge; +use function array_pop; use function array_slice; use function array_values; use function count; @@ -110,6 +111,7 @@ use function is_int; use function is_string; use function max; +use function spl_object_id; use function usort; #[AutowiredService] @@ -124,6 +126,54 @@ class NodeScopeResolver private ?ExpressionResultStorageStack $expressionResultStorageStack = null; + /** + * Per convergence site (a loop's fixpoint iteration, a by-ref closure + * convergence, a backward-goto replay), the full ExpressionResult of every + * expression its passes walked, keyed by spl_object_id of the Expr. A + * later pass consumes an entry instead of re-walking the subtree when the + * result is effect-free and everything it reads is unchanged. Frames pop + * when their site's convergence loop finishes, releasing the pinned + * results; lookups search innermost-out so a nested site (a closure + * converging inside a loop pass) still consumes the enclosing pass's + * entries. + * + * @var array> + */ + private array $convergenceSiteResults = []; + + /** + * Whether each convergence pass on the stack (innermost last) consumed a + * subtree whose recorded emissions could not be preserved - such a pass + * has an incomplete recording and cannot replace the final walk. + * + * @var array + */ + private array $convergencePassGaps = []; + + /** + * The active recording of each convergence pass on the stack (null for a + * pass that does not record, e.g. a non-replayable body). Tracked here and + * not derived from the walk's node callback: wrappers (a closure's + * gathering callback) hide the recorder underneath while emissions still + * reach it. + * + * @var array + */ + private array $convergencePassRecorders = []; + + /** + * The storage of each convergence pass on the stack. Consumption, segment + * tagging and frame stores apply only to walks carrying the pass's own + * storage: the old-world pricing walks that resolveType() starts for + * short-circuit operators re-enter processExprNode() with a fresh + * throwaway storage - they emit nowhere, so they may consume stored + * results but must not splice segments, store results, or tag brackets + * (a mispriced splice duplicated emissions in the pass recording). + * + * @var array + */ + private array $convergencePassStorages = []; + /** * @param ExtensionsCollection $functionParameterOutTypeExtensions * @param ExtensionsCollection $methodParameterOutTypeExtensions @@ -231,6 +281,32 @@ public function processNodes( $stmts[] = $node; } + $convergenceState = $this->suspendConvergenceState(); + try { + $this->processNodesInternal($nodes, $stmts, $stmtToNodeIndex, $scope, $expressionResultStorage, $nodeCallback, $alreadyTerminated, $exitPoints); + } finally { + $this->restoreConvergenceState($convergenceState); + } + } + + /** + * @param Node[] $nodes + * @param Node\Stmt[] $stmts + * @param array $stmtToNodeIndex + * @param callable(Node $node, Scope $scope): void $nodeCallback + * @param InternalStatementExitPoint[] $exitPoints + */ + private function processNodesInternal( + array $nodes, + array $stmts, + array $stmtToNodeIndex, + MutatingScope $scope, + ExpressionResultStorage $expressionResultStorage, + callable $nodeCallback, + bool $alreadyTerminated, + array $exitPoints, + ): void + { $dummyParent = new Node\Stmt\Nop(); foreach ($stmts as $si => $node) { if ($alreadyTerminated && !($node instanceof Node\Stmt\Function_ || $node instanceof Node\Stmt\ClassLike || $node instanceof Node\Stmt\Label)) { @@ -300,6 +376,16 @@ public function storeExpressionResult(ExpressionResultStorage $storage, Expr $ex // callbacks and the after-scope of every expression until the end of // the file. $storage->storeBeforeScope($expr, $expressionResult->getBeforeScope()); + if ($this->convergencePassGaps === [] || $this->convergencePassStorages[count($this->convergencePassStorages) - 1] !== $storage) { + return; + } + + // a convergence pass additionally keeps the full result in the + // innermost site frame, so a later pass can consume it instead of + // re-walking the subtree; the frame pops when the site finishes. + // Walks on a foreign storage (a resolveType() pricing walk) do not + // store - their results carry no usable emission segments. + $this->convergenceSiteResults[count($this->convergenceSiteResults) - 1][spl_object_id($expr)] = $expressionResult; } /** @@ -319,52 +405,67 @@ private function resolveBackwardGotoScope( $bodyScope = $scope; $count = 0; $prevEntryScope = null; - do { - $prevScope = $bodyScope; - if ($mergeBodyScopeEachIteration) { - $bodyScope = $bodyScope->mergeWith($scope); - } - if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { - // walking is deterministic in the entry scope - an unchanged entry - // reproduces the previous pass's exit, so the verification walk is - // skipped - $bodyScope = $prevScope; - break; - } - $prevEntryScope = $bodyScope; - $tempStorage = $storage->duplicate(); - $bodyScopeResult = $this->processStmtNodesInternal( - $parentNode, - $bodyStmts, - $bodyScope, - $tempStorage, - new NoopNodeCallback(), - $context, - ); - - $gotoScope = null; - foreach ($bodyScopeResult->getExitPoints() as $ep) { - $epStmt = $ep->getStatement(); - if (!($epStmt instanceof Goto_) || !$gotoNameMatcher($epStmt->name->toString())) { - continue; + // each pass chains the previous pass's storage as its baseline, so + // the convergence-pass mode consumes unchanged effect-free subtrees + // instead of re-walking the whole body every round + $previousPassStorage = null; + $this->enterConvergenceSite(); + try { + do { + $prevScope = $bodyScope; + if ($mergeBodyScopeEachIteration) { + $bodyScope = $bodyScope->mergeWith($scope); + } + if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { + // walking is deterministic in the entry scope - an unchanged entry + // reproduces the previous pass's exit, so the verification walk is + // skipped + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; + $tempStorage = ($previousPassStorage ?? $storage)->duplicate(); + $this->enterConvergencePass($tempStorage); + try { + $bodyScopeResult = $this->processStmtNodesInternal( + $parentNode, + $bodyStmts, + $bodyScope, + $tempStorage, + new NoopNodeCallback(), + $context, + ); + } finally { + $this->exitConvergencePass(); } + $previousPassStorage = $tempStorage; - $gotoScope = $gotoScope === null ? $ep->getScope() : $gotoScope->mergeWith($ep->getScope()); - } + $gotoScope = null; + foreach ($bodyScopeResult->getExitPoints() as $ep) { + $epStmt = $ep->getStatement(); + if (!($epStmt instanceof Goto_) || !$gotoNameMatcher($epStmt->name->toString())) { + continue; + } - if ($gotoScope !== null) { - $bodyScope = $scope->mergeWith($gotoScope); - } + $gotoScope = $gotoScope === null ? $ep->getScope() : $gotoScope->mergeWith($ep->getScope()); + } - if ($bodyScope->equals($prevScope)) { - break; - } + if ($gotoScope !== null) { + $bodyScope = $scope->mergeWith($gotoScope); + } - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); + if ($bodyScope->equals($prevScope)) { + break; + } + + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + } finally { + $this->exitConvergenceSite(); + } return $bodyScope; } @@ -446,14 +547,19 @@ public function processStmtNodes( // rule-facing ask paths $scope = $scope->toWalkScope(); $storage = new ExpressionResultStorage(); - return $this->processStmtNodesInternal( - $parentNode, - $stmts, - $scope, - $storage, - $nodeCallback, - $context, - )->toPublic(); + $convergenceState = $this->suspendConvergenceState(); + try { + return $this->processStmtNodesInternal( + $parentNode, + $stmts, + $scope, + $storage, + $nodeCallback, + $context, + )->toPublic(); + } finally { + $this->restoreConvergenceState($convergenceState); + } } /** @@ -814,6 +920,72 @@ public function processExprNode( callable $nodeCallback, ExpressionContext $context, ): ExpressionResult + { + if ($this->convergencePassGaps !== []) { + // a convergence pass: the previous pass's result answers exactly + // when the walk had no scope effects and nothing this expression + // reads changed between the passes - the loop-carried parts of the + // body fail one of the two gates and re-walk. A walk on a foreign + // storage (a resolveType() pricing walk re-entering here) may + // consume too - it emits nowhere - but never splices, gaps, tags + // or stores. + $passIndex = count($this->convergencePassGaps) - 1; + $inPassWalk = $this->convergencePassStorages[$passIndex] === $storage; + $storedResult = $this->findConvergenceStoredResult($expr); + if ( + $storedResult !== null + && $storedResult->isEffectFree() + && $storedResult->readStateMatches($scope, $scope->nativeTypesPromoted) + ) { + $recorder = $inPassWalk ? $this->convergencePassRecorders[$passIndex] : null; + $segment = $storedResult->getRecordingSegment(); + $consumed = $storedResult->getBeforeScope() === $scope ? $storedResult : $storedResult->atAskPosition($scope); + if ($recorder !== null && $segment !== null) { + // the consumption skips the subtree's emissions - splice its + // recorded segment from the pass that last walked it, so + // this pass's recording stays complete (the segment's scopes + // agree with the replay on everything the subtree reads) + $start = $recorder->count(); + $recorder->copyRange($segment[0], $segment[1], $segment[2]); + $consumed->setRecordingSegment($recorder, $start, $recorder->count()); + } elseif ($recorder !== null) { + // nothing to splice - the pass's recording is incomplete + // and cannot replace the final walk + $this->convergencePassGaps[$passIndex] = true; + } + + return $consumed; + } + + $recorder = $inPassWalk ? $this->convergencePassRecorders[$passIndex] : null; + if ($recorder !== null) { + // tag the subtree's emission segment in the pass recording, so + // a later pass consuming this result can splice the emissions + // it skips; the segment brackets the recorder's own growth, so + // emissions reaching it through wrappers are included and a + // nested walk emitting elsewhere tags an empty segment + $segmentStart = $recorder->count(); + $result = $this->processExprNodeDispatch($stmt, $expr, $scope, $storage, $nodeCallback, $context); + $result->setRecordingSegment($recorder, $segmentStart, $recorder->count()); + + return $result; + } + } + + return $this->processExprNodeDispatch($stmt, $expr, $scope, $storage, $nodeCallback, $context); + } + + /** + * @param callable(Node $node, Scope $scope): void $nodeCallback + */ + private function processExprNodeDispatch( + Node\Stmt $stmt, + Expr $expr, + MutatingScope $scope, + ExpressionResultStorage $storage, + callable $nodeCallback, + ExpressionContext $context, + ): ExpressionResult { if ($expr instanceof Expr\CallLike && $expr->isFirstClassCallable()) { if ($expr instanceof FuncCall) { @@ -914,6 +1086,94 @@ public function getAssignedVariables(Expr $expr): array return []; } + /** + * Opens a convergence site: a frame collecting the full ExpressionResults + * its passes walk. The caller wraps the whole fixpoint loop and closes the + * site in a finally block via exitConvergenceSite(). + */ + public function enterConvergenceSite(): void + { + $this->convergenceSiteResults[] = []; + } + + public function exitConvergenceSite(): void + { + array_pop($this->convergenceSiteResults); + } + + /** + * Enters one convergence pass of the innermost site: expressions whose + * stored result is effect-free (ExpressionResult::isEffectFree()) and + * whose read state did not change since the pass that walked them are + * consumed instead of re-walked - only the loop-carried parts of the body + * re-walk. The caller restores in a finally block via + * exitConvergencePass(). + */ + public function enterConvergencePass(ExpressionResultStorage $passStorage): void + { + $this->convergencePassGaps[] = false; + $this->convergencePassRecorders[] = null; + $this->convergencePassStorages[] = $passStorage; + } + + /** Sets the innermost pass's active recording (While switches between the cond and body recorders mid-pass). */ + public function setActiveConvergenceRecorder(?RecordingNodeCallback $recorder): void + { + $this->convergencePassRecorders[count($this->convergencePassRecorders) - 1] = $recorder; + } + + /** Returns whether the exited pass has a recording gap (a consumption whose emissions were lost). */ + public function exitConvergencePass(): bool + { + array_pop($this->convergencePassRecorders); + array_pop($this->convergencePassStorages); + $gapped = array_pop($this->convergencePassGaps); + if ($gapped === null) { + throw new ShouldNotHappenException(); + } + + return $gapped; + } + + /** + * A fresh walk an extension starts mid-analysis (processNodes() and + * processStmtNodes() are @api) must not inherit the enclosing walk's + * convergence state - site frames and pass modes describe the walk that + * was interrupted, not the nested one. + * + * @return array{array>, array, array, array} + */ + private function suspendConvergenceState(): array + { + $state = [$this->convergenceSiteResults, $this->convergencePassGaps, $this->convergencePassRecorders, $this->convergencePassStorages]; + $this->convergenceSiteResults = []; + $this->convergencePassGaps = []; + $this->convergencePassRecorders = []; + $this->convergencePassStorages = []; + + return $state; + } + + /** + * @param array{array>, array, array, array} $state + */ + private function restoreConvergenceState(array $state): void + { + [$this->convergenceSiteResults, $this->convergencePassGaps, $this->convergencePassRecorders, $this->convergencePassStorages] = $state; + } + + private function findConvergenceStoredResult(Expr $expr): ?ExpressionResult + { + $id = spl_object_id($expr); + for ($i = count($this->convergenceSiteResults) - 1; $i >= 0; $i--) { + if (isset($this->convergenceSiteResults[$i][$id])) { + return $this->convergenceSiteResults[$i][$id]; + } + } + + return null; + } + private const REPLAYABLE_BODY_ATTRIBUTE = 'convergenceReplayableBody'; /** @@ -1224,45 +1484,70 @@ public function processClosureNode( $replayPassResult = null; $replayEntryScope = null; $bodyIsReplayable = $this->isReplayableConvergenceBody($expr, $expr->stmts); - do { - $prevScope = $closureScope; - - $storage = $originalStorage->duplicate(); - $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); - // deep context, like the loop handlers' own convergence passes: inner - // loops walk single-pass here and only the final walk below (top-level) - // runs their full convergence - otherwise every closure-convergence - // pass would re-converge every inner loop from scratch - $intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $bodyRecording, StatementContext::createDeep()); - // the candidate to replace the final walk when this pass's entry - // turns out to be the fixpoint - if ($bodyRecording instanceof RecordingNodeCallback) { - $replayBodyRecording = $bodyRecording; - $replayPassStorage = $storage; - $replayPassResult = $intermediaryClosureScopeResult; - $replayEntryScope = $prevScope; - } - $intermediaryClosureScope = $intermediaryClosureScopeResult->getScope(); - foreach ($intermediaryClosureScopeResult->getExitPoints() as $exitPoint) { - $intermediaryClosureScope = $intermediaryClosureScope->mergeWith($exitPoint->getScope()); - } - - if ($expr->getAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME) === true) { - $closureResultScope = $intermediaryClosureScope; - break; - } + // each pass chains the previous pass's storage as its baseline, so + // the convergence-pass mode consumes unchanged effect-free subtrees + // instead of re-walking the whole body every round + $previousPassStorage = null; + $this->enterConvergenceSite(); + try { + do { + $prevScope = $closureScope; + + $storage = ($previousPassStorage ?? $originalStorage)->duplicate(); + $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); + $this->enterConvergencePass($storage); + $passGapped = false; + if ($bodyRecording instanceof RecordingNodeCallback) { + $this->setActiveConvergenceRecorder($bodyRecording); + } + try { + // deep context, like the loop handlers' own convergence passes: inner + // loops walk single-pass here and only the final walk below (top-level) + // runs their full convergence - otherwise every closure-convergence + // pass would re-converge every inner loop from scratch + $intermediaryClosureScopeResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $bodyRecording, StatementContext::createDeep()); + } finally { + $passGapped = $this->exitConvergencePass(); + } + $previousPassStorage = $storage; + // a pass whose recording is complete is the candidate to + // replace the final walk when its entry turns out to be the + // fixpoint + if ($bodyRecording instanceof RecordingNodeCallback && !$passGapped) { + $replayBodyRecording = $bodyRecording; + $replayPassStorage = $storage; + $replayPassResult = $intermediaryClosureScopeResult; + $replayEntryScope = $prevScope; + } else { + $replayBodyRecording = null; + $replayPassStorage = null; + $replayPassResult = null; + $replayEntryScope = null; + } + $intermediaryClosureScope = $intermediaryClosureScopeResult->getScope(); + foreach ($intermediaryClosureScopeResult->getExitPoints() as $exitPoint) { + $intermediaryClosureScope = $intermediaryClosureScope->mergeWith($exitPoint->getScope()); + } - $closureScope = $scope->enterAnonymousFunction($expr, $callableParameters, $nativeCallableParameters); - $closureScope = $closureScope->processClosureScope($intermediaryClosureScope, $prevScope, $byRefUses); + if ($expr->getAttribute(ImmediatelyInvokedClosureVisitor::ATTRIBUTE_NAME) === true) { + $closureResultScope = $intermediaryClosureScope; + break; + } - if ($closureScope->equals($prevScope)) { - break; - } - if ($count >= self::GENERALIZE_AFTER_ITERATION) { - $closureScope = $prevScope->generalizeWith($closureScope); - } - $count++; - } while ($count < self::LOOP_SCOPE_ITERATIONS); + $closureScope = $scope->enterAnonymousFunction($expr, $callableParameters, $nativeCallableParameters); + $closureScope = $closureScope->processClosureScope($intermediaryClosureScope, $prevScope, $byRefUses); + + if ($closureScope->equals($prevScope)) { + break; + } + if ($count >= self::GENERALIZE_AFTER_ITERATION) { + $closureScope = $prevScope->generalizeWith($closureScope); + } + $count++; + } while ($count < self::LOOP_SCOPE_ITERATIONS); + } finally { + $this->exitConvergenceSite(); + } if ($closureResultScope === null) { $closureResultScope = $closureScope; diff --git a/src/Analyser/RecordingNodeCallback.php b/src/Analyser/RecordingNodeCallback.php index be4641fe2d5..7f12db3d9ed 100644 --- a/src/Analyser/RecordingNodeCallback.php +++ b/src/Analyser/RecordingNodeCallback.php @@ -4,6 +4,7 @@ use PhpParser\Node; use PHPStan\ShouldNotHappenException; +use function count; /** * Records every (node, scope) emission of a convergence pass in order. When @@ -28,6 +29,25 @@ public function __invoke(Node $node, Scope $scope): void $this->pairs[] = [$node, $scope]; } + public function count(): int + { + return count($this->pairs); + } + + /** + * Splices another recording's [$start, $end) segment onto this one - a + * convergence pass consuming a subtree copies the subtree's emissions from + * the pass that last walked it, so the consuming pass's recording stays + * complete. Recordings are append-only, so a tagged segment stays valid + * for the lifetime of its recording. + */ + public function copyRange(self $source, int $start, int $end): void + { + for ($i = $start; $i < $end; $i++) { + $this->pairs[] = $source->pairs[$i]; + } + } + /** * @param callable(Node $node, Scope $scope): void $nodeCallback */ diff --git a/src/Analyser/StmtHandler/DoWhileHandler.php b/src/Analyser/StmtHandler/DoWhileHandler.php index 483ebceb03b..3ed7e6b5984 100644 --- a/src/Analyser/StmtHandler/DoWhileHandler.php +++ b/src/Analyser/StmtHandler/DoWhileHandler.php @@ -52,49 +52,80 @@ public function processStmt( $replayBodyRecording = null; $replayPassStorage = null; $replayPassResult = null; + $replayEntryScope = null; $prevEntryScope = null; if ($context->isTopLevel()) { $bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts); - do { - $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($scope); - if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { - // walking is deterministic in the entry scope - an unchanged entry - // reproduces the previous pass's exit (and repeats only idempotent - // merges into the final scope), so the verification walk is skipped - $bodyScope = $prevScope; - break; - } - $prevEntryScope = $bodyScope; - $storage = $originalStorage->duplicate(); - $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); - $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } - $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); - foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { - $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); - } - // the candidate to replace the final body walk when this pass's - // entry turns out to be the fixpoint - if ($bodyRecording instanceof RecordingNodeCallback) { - $replayBodyRecording = $bodyRecording; - $replayPassStorage = $storage; - $replayPassResult = $bodyScopeResult; - } - $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - if ($bodyScope->equals($prevScope)) { - break; - } + // each pass chains the previous pass's storage as its baseline, so + // the convergence-pass mode consumes unchanged effect-free subtrees + // instead of re-walking the whole body every round + $previousPassStorage = null; + $nodeScopeResolver->enterConvergenceSite(); + try { + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($scope); + if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { + // walking is deterministic in the entry scope - an unchanged entry + // reproduces the previous pass's exit (and repeats only idempotent + // merges into the final scope), so the verification walk is skipped + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; + $storage = ($previousPassStorage ?? $originalStorage)->duplicate(); + $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); + $nodeScopeResolver->enterConvergencePass($storage); + $passGapped = false; + try { + if ($bodyRecording instanceof RecordingNodeCallback) { + $nodeScopeResolver->setActiveConvergenceRecorder($bodyRecording); + } + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); + $alwaysTerminating = $bodyScopeResult->isAlwaysTerminating(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + $finalScope = $alwaysTerminating ? $finalScope : $bodyScope->mergeWith($finalScope); + foreach ($bodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $finalScope = $breakExitPoint->getScope()->mergeWith($finalScope); + } + // the pass's condition walk emits nowhere - deactivate + // the recorder so a consumption there skips nothing + // recordable + $nodeScopeResolver->setActiveConvergenceRecorder(null); + $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + } finally { + $passGapped = $nodeScopeResolver->exitConvergencePass(); + } + $previousPassStorage = $storage; + // a pass whose recording is complete is the candidate to + // replace the final body walk when its entry turns out to + // be the fixpoint + if ($bodyRecording instanceof RecordingNodeCallback && !$passGapped) { + $replayBodyRecording = $bodyRecording; + $replayPassStorage = $storage; + $replayPassResult = $bodyScopeResult; + $replayEntryScope = $prevEntryScope; + } else { + $replayBodyRecording = null; + $replayPassStorage = null; + $replayPassResult = null; + $replayEntryScope = null; + } + if ($bodyScope->equals($prevScope)) { + break; + } - if ($count >= NodeScopeResolver::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < NodeScopeResolver::LOOP_SCOPE_ITERATIONS); + if ($count >= NodeScopeResolver::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < NodeScopeResolver::LOOP_SCOPE_ITERATIONS); + } finally { + $nodeScopeResolver->exitConvergenceSite(); + } $bodyScope = $bodyScope->mergeWith($scope); } @@ -102,7 +133,7 @@ public function processStmt( $storage = $originalStorage; if ( $replayBodyRecording !== null && $replayPassStorage !== null && $replayPassResult !== null - && $prevEntryScope !== null && $bodyScope->equals($prevEntryScope) + && $replayEntryScope !== null && $bodyScope->equals($replayEntryScope) ) { // the final body walk would repeat the recorded fixpoint pass exactly // (same entry scope, deterministic walk) - adopt the pass's results diff --git a/src/Analyser/StmtHandler/ForHandler.php b/src/Analyser/StmtHandler/ForHandler.php index 46d2fb9e8e1..a293434bb65 100644 --- a/src/Analyser/StmtHandler/ForHandler.php +++ b/src/Analyser/StmtHandler/ForHandler.php @@ -161,43 +161,58 @@ public function processStmt( if ($context->isTopLevel()) { $count = 0; $prevEntryScope = null; - do { - $prevScope = $bodyScope; - $storage = $originalStorage->duplicate(); - $bodyScope = $bodyScope->mergeWith($initScope); - if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { - // walking is deterministic in the entry scope - an unchanged entry - // reproduces the previous pass's exit, so the verification walk is skipped - $bodyScope = $prevScope; - break; - } - $prevEntryScope = $bodyScope; - if ($lastCondExpr !== null) { - $bodyScope = $nodeScopeResolver->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); - } - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } + // each pass chains the previous pass's storage as its baseline, so + // the convergence-pass mode consumes unchanged effect-free subtrees + // instead of re-walking the whole body every round + $previousPassStorage = null; + $nodeScopeResolver->enterConvergenceSite(); + try { + do { + $prevScope = $bodyScope; + $storage = ($previousPassStorage ?? $originalStorage)->duplicate(); + $bodyScope = $bodyScope->mergeWith($initScope); + if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { + // walking is deterministic in the entry scope - an unchanged entry + // reproduces the previous pass's exit, so the verification walk is skipped + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; + $nodeScopeResolver->enterConvergencePass($storage); + try { + if ($lastCondExpr !== null) { + $bodyScope = $nodeScopeResolver->processExprNode($stmt, $lastCondExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createDeep())->getTruthyScope(); + } + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } - foreach ($stmt->loop as $loopExpr) { - $exprResult = $nodeScopeResolver->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel()); - $bodyScope = $exprResult->getScope(); - $hasYield = $hasYield || $exprResult->hasYield(); - $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); - $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); - } + foreach ($stmt->loop as $loopExpr) { + $exprResult = $nodeScopeResolver->processExprNode($stmt, $loopExpr, $bodyScope, $storage, new NoopNodeCallback(), ExpressionContext::createTopLevel()); + $bodyScope = $exprResult->getScope(); + $hasYield = $hasYield || $exprResult->hasYield(); + $throwPoints = array_merge($throwPoints, $exprResult->getThrowPoints()); + $impurePoints = array_merge($impurePoints, $exprResult->getImpurePoints()); + } + } finally { + $nodeScopeResolver->exitConvergencePass(); + } + $previousPassStorage = $storage; - if ($bodyScope->equals($prevScope)) { - break; - } + if ($bodyScope->equals($prevScope)) { + break; + } - if ($count >= NodeScopeResolver::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < NodeScopeResolver::LOOP_SCOPE_ITERATIONS); + if ($count >= NodeScopeResolver::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < NodeScopeResolver::LOOP_SCOPE_ITERATIONS); + } finally { + $nodeScopeResolver->exitConvergenceSite(); + } } $storage = $originalStorage; diff --git a/src/Analyser/StmtHandler/ForeachHandler.php b/src/Analyser/StmtHandler/ForeachHandler.php index 89e7d6d866f..0a839fd573b 100644 --- a/src/Analyser/StmtHandler/ForeachHandler.php +++ b/src/Analyser/StmtHandler/ForeachHandler.php @@ -158,41 +158,66 @@ public function processStmt( $count = 0; $prevEntryScope = null; $bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts); - do { - $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($iterateeScope); - if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { - // walking is deterministic in the entry scope - an unchanged entry - // reproduces the previous pass's exit, so the verification walk is skipped - $bodyScope = $prevScope; - break; - } - $prevEntryScope = $bodyScope; - $storage = $originalStorage->duplicate(); - $bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); - $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } - // the candidate to replace the final walk when this pass's - // entry turns out to be the fixpoint - if ($bodyRecording instanceof RecordingNodeCallback) { - $replayBodyRecording = $bodyRecording; - $replayPassStorage = $storage; - $replayPassResult = $bodyScopeResult; - $replayEntryScope = $prevEntryScope; - } - if ($bodyScope->equals($prevScope)) { - break; - } + // each pass chains the previous pass's storage as its baseline, so + // the convergence-pass mode consumes unchanged effect-free subtrees + // instead of re-walking the whole body every round + $previousPassStorage = null; + $nodeScopeResolver->enterConvergenceSite(); + try { + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($iterateeScope); + if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { + // walking is deterministic in the entry scope - an unchanged entry + // reproduces the previous pass's exit, so the verification walk is skipped + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; + $storage = ($previousPassStorage ?? $originalStorage)->duplicate(); + $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); + $nodeScopeResolver->enterConvergencePass($storage); + $passGapped = false; + try { + $bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback); + if ($bodyRecording instanceof RecordingNodeCallback) { + $nodeScopeResolver->setActiveConvergenceRecorder($bodyRecording); + } + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); + } finally { + $passGapped = $nodeScopeResolver->exitConvergencePass(); + } + $previousPassStorage = $storage; + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + // a pass whose recording is complete is the candidate + // to replace the final walk when its entry turns out + // to be the fixpoint + if ($bodyRecording instanceof RecordingNodeCallback && !$passGapped) { + $replayBodyRecording = $bodyRecording; + $replayPassStorage = $storage; + $replayPassResult = $bodyScopeResult; + $replayEntryScope = $prevEntryScope; + } else { + $replayBodyRecording = null; + $replayPassStorage = null; + $replayPassResult = null; + $replayEntryScope = null; + } + if ($bodyScope->equals($prevScope)) { + break; + } - if ($count >= NodeScopeResolver::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < NodeScopeResolver::LOOP_SCOPE_ITERATIONS); + if ($count >= NodeScopeResolver::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < NodeScopeResolver::LOOP_SCOPE_ITERATIONS); + } finally { + $nodeScopeResolver->exitConvergenceSite(); + } } } @@ -717,28 +742,43 @@ private function tryProcessUnrolledConstantArrayForeach( if ($hasUnsealed) { $loopScope = $endScope; $count = 0; - do { - $prevLoopScope = $loopScope; - $iterStorage = $originalStorage->duplicate(); - $iterBodyScope = $loopScope->mergeWith($endScope); - $iterBodyScope = $this->enterForeach($nodeScopeResolver, $iterBodyScope, $iterStorage, $originalScope, $stmt, $iterateeType, $nativeIterateeType, new NoopNodeCallback()); - $iterBodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $iterBodyScope, $iterStorage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); - $loopScope = $iterBodyScopeResult->getScope(); - foreach ($iterBodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $loopScope = $loopScope->mergeWith($continueExitPoint->getScope()); - } - foreach ($iterBodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { - $endScope = $endScope->mergeWith($breakExitPoint->getScope()); - } - $bodyScope = $bodyScope->mergeWith($loopScope); - if ($loopScope->equals($prevLoopScope)) { - break; - } - if ($count >= NodeScopeResolver::GENERALIZE_AFTER_ITERATION) { - $loopScope = $prevLoopScope->generalizeWith($loopScope); - } - $count++; - } while ($count < NodeScopeResolver::LOOP_SCOPE_ITERATIONS); + // each pass chains the previous pass's storage as its baseline, so + // the convergence-pass mode consumes unchanged effect-free subtrees + // instead of re-walking the whole body every round + $previousPassStorage = null; + $nodeScopeResolver->enterConvergenceSite(); + try { + do { + $prevLoopScope = $loopScope; + $iterStorage = ($previousPassStorage ?? $originalStorage)->duplicate(); + $iterBodyScope = $loopScope->mergeWith($endScope); + $nodeScopeResolver->enterConvergencePass($iterStorage); + try { + $iterBodyScope = $this->enterForeach($nodeScopeResolver, $iterBodyScope, $iterStorage, $originalScope, $stmt, $iterateeType, $nativeIterateeType, new NoopNodeCallback()); + $iterBodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $iterBodyScope, $iterStorage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints(); + } finally { + $nodeScopeResolver->exitConvergencePass(); + } + $previousPassStorage = $iterStorage; + $loopScope = $iterBodyScopeResult->getScope(); + foreach ($iterBodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $loopScope = $loopScope->mergeWith($continueExitPoint->getScope()); + } + foreach ($iterBodyScopeResult->getExitPointsByType(Break_::class) as $breakExitPoint) { + $endScope = $endScope->mergeWith($breakExitPoint->getScope()); + } + $bodyScope = $bodyScope->mergeWith($loopScope); + if ($loopScope->equals($prevLoopScope)) { + break; + } + if ($count >= NodeScopeResolver::GENERALIZE_AFTER_ITERATION) { + $loopScope = $prevLoopScope->generalizeWith($loopScope); + } + $count++; + } while ($count < NodeScopeResolver::LOOP_SCOPE_ITERATIONS); + } finally { + $nodeScopeResolver->exitConvergenceSite(); + } $endScope = $endScope->mergeWith($loopScope); } diff --git a/src/Analyser/StmtHandler/WhileHandler.php b/src/Analyser/StmtHandler/WhileHandler.php index 1edd270d700..03c810c8876 100644 --- a/src/Analyser/StmtHandler/WhileHandler.php +++ b/src/Analyser/StmtHandler/WhileHandler.php @@ -66,46 +66,77 @@ public function processStmt( $replayBodyRecording = null; $replayPassStorage = null; $replayPassResult = null; + $replayEntryScope = null; $prevEntryScope = null; if ($context->isTopLevel()) { $count = 0; $bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts); - do { - $prevScope = $bodyScope; - $bodyScope = $bodyScope->mergeWith($scope); - if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { - // walking is deterministic in the entry scope - an unchanged entry - // reproduces the previous pass's exit, so the verification walk is skipped - $bodyScope = $prevScope; - break; - } - $prevEntryScope = $bodyScope; - $storage = $originalStorage->duplicate(); - $condRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); - $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); - $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $condRecording, ExpressionContext::createDeep())->getTruthyScope(); - $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); - $bodyScope = $bodyScopeResult->getScope(); - foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { - $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); - } - // the candidate to replace the final walk when this pass's - // entry turns out to be the fixpoint - if ($condRecording instanceof RecordingNodeCallback && $bodyRecording instanceof RecordingNodeCallback) { - $replayCondRecording = $condRecording; - $replayBodyRecording = $bodyRecording; - $replayPassStorage = $storage; - $replayPassResult = $bodyScopeResult; - } - if ($bodyScope->equals($prevScope)) { - break; - } - - if ($count >= NodeScopeResolver::GENERALIZE_AFTER_ITERATION) { - $bodyScope = $prevScope->generalizeWith($bodyScope); - } - $count++; - } while ($count < NodeScopeResolver::LOOP_SCOPE_ITERATIONS); + // each pass chains the previous pass's storage as its baseline, so + // the convergence-pass mode consumes unchanged effect-free subtrees + // instead of re-walking the whole body every round + $previousPassStorage = null; + $nodeScopeResolver->enterConvergenceSite(); + try { + do { + $prevScope = $bodyScope; + $bodyScope = $bodyScope->mergeWith($scope); + if ($prevEntryScope !== null && $bodyScope->equals($prevEntryScope)) { + // walking is deterministic in the entry scope - an unchanged entry + // reproduces the previous pass's exit, so the verification walk is skipped + $bodyScope = $prevScope; + break; + } + $prevEntryScope = $bodyScope; + $storage = ($previousPassStorage ?? $originalStorage)->duplicate(); + $condRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); + $bodyRecording = $bodyIsReplayable ? new RecordingNodeCallback() : new NoopNodeCallback(); + $nodeScopeResolver->enterConvergencePass($storage); + $passGapped = false; + try { + if ($condRecording instanceof RecordingNodeCallback) { + $nodeScopeResolver->setActiveConvergenceRecorder($condRecording); + } + $bodyScope = $nodeScopeResolver->processExprNode($stmt, $stmt->cond, $bodyScope, $storage, $condRecording, ExpressionContext::createDeep())->getTruthyScope(); + if ($bodyRecording instanceof RecordingNodeCallback) { + $nodeScopeResolver->setActiveConvergenceRecorder($bodyRecording); + } + $bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $bodyRecording, $context->enterDeep())->filterOutLoopExitPoints(); + } finally { + $passGapped = $nodeScopeResolver->exitConvergencePass(); + } + $previousPassStorage = $storage; + $bodyScope = $bodyScopeResult->getScope(); + foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) { + $bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope()); + } + // a pass whose recording is complete is the candidate to + // replace the final walk when its entry turns out to be + // the fixpoint + if ($condRecording instanceof RecordingNodeCallback && $bodyRecording instanceof RecordingNodeCallback && !$passGapped) { + $replayCondRecording = $condRecording; + $replayBodyRecording = $bodyRecording; + $replayPassStorage = $storage; + $replayPassResult = $bodyScopeResult; + $replayEntryScope = $prevEntryScope; + } else { + $replayCondRecording = null; + $replayBodyRecording = null; + $replayPassStorage = null; + $replayPassResult = null; + $replayEntryScope = null; + } + if ($bodyScope->equals($prevScope)) { + break; + } + + if ($count >= NodeScopeResolver::GENERALIZE_AFTER_ITERATION) { + $bodyScope = $prevScope->generalizeWith($bodyScope); + } + $count++; + } while ($count < NodeScopeResolver::LOOP_SCOPE_ITERATIONS); + } finally { + $nodeScopeResolver->exitConvergenceSite(); + } } $bodyScope = $bodyScope->mergeWith($scope); @@ -114,7 +145,7 @@ public function processStmt( if ( $replayCondRecording !== null && $replayBodyRecording !== null && $replayPassStorage !== null && $replayPassResult !== null - && $prevEntryScope !== null && $bodyScope->equals($prevEntryScope) + && $replayEntryScope !== null && $bodyScope->equals($replayEntryScope) ) { // the final walk would repeat the recorded fixpoint pass exactly // (same entry scope, deterministic walk) - adopt the pass's results