diff --git a/src/Analyser/ExpressionResult.php b/src/Analyser/ExpressionResult.php index 0ec7eb1597..48ab1406a4 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 95bdb58e73..85370b206f 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 be4641fe2d..7f12db3d9e 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 483ebceb03..3ed7e6b598 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 46d2fb9e8e..a293434bb6 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 89e7d6d866..0a839fd573 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 1edd270d70..03c810c887 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