Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions src/Analyser/ExpressionResultStorage.php
Original file line number Diff line number Diff line change
Expand Up @@ -41,4 +41,17 @@ public function findBeforeScope(Expr $expr): ?Scope
return $this->scopesById[spl_object_id($expr)] ?? null;
}

/**
* Adopts every before-scope the other storage stored - a finished
* convergence pass's stores answer for the final walk its replay
* replaces.
*/
public function mergeResults(self $other): void
{
foreach ($other->exprsById as $id => $expr) {
$this->exprsById[$id] = $expr;
$this->scopesById[$id] = $other->scopesById[$id];
}
}

}
128 changes: 126 additions & 2 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@
use function array_values;
use function count;
use function in_array;
use function is_array;
use function is_int;
use function is_string;
use function max;
Expand Down Expand Up @@ -913,6 +914,91 @@ public function getAssignedVariables(Expr $expr): array
return [];
}

private const REPLAYABLE_BODY_ATTRIBUTE = 'convergenceReplayableBody';

/**
* Whether a recorded convergence pass over the loop body can replace the
* final walk. A pass runs at deep statement context, the final walk at top
* level - constructs that analyse differently between the two (nested
* loop/label fixpoints run only at top level, statement-level classes are
* skipped at deep context) disqualify the body. Closure bodies process
* context-independently and are not traversed.
*
* @param Node\Stmt[] $bodyStmts
*/
public function isReplayableConvergenceBody(Node $loopNode, array $bodyStmts): bool
{
$cached = $loopNode->getAttribute(self::REPLAYABLE_BODY_ATTRIBUTE);
if ($cached !== null) {
return $cached;
}

$replayable = true;
foreach ($bodyStmts as $bodyStmt) {
if ($this->hasContextSensitiveConstruct($bodyStmt)) {
$replayable = false;
break;
}
}
$loopNode->setAttribute(self::REPLAYABLE_BODY_ATTRIBUTE, $replayable);

return $replayable;
}

private function hasContextSensitiveConstruct(Node $node): bool
{
if ($node instanceof Expr\Closure) {
return false;
}
if (
$node instanceof Node\Stmt\While_
|| $node instanceof Node\Stmt\Do_
|| $node instanceof Node\Stmt\For_
|| $node instanceof Foreach_
|| $node instanceof Node\Stmt\Label
|| $node instanceof Node\Stmt\ClassLike
) {
return true;
}

foreach ($node->getSubNodeNames() as $subNodeName) {
$subNode = $node->$subNodeName;
if ($subNode instanceof Node) {
if ($this->hasContextSensitiveConstruct($subNode)) {
return true;
}
} elseif (is_array($subNode)) {
foreach ($subNode as $item) {
if ($item instanceof Node && $this->hasContextSensitiveConstruct($item)) {
return true;
}
}
}
}

return false;
}

/**
* Replays a recorded convergence pass's emissions through the real node
* callback in place of the final loop walk. The pass's storage was merged
* into $storage by the caller; binding it for the whole replay lets the
* recorded scopes answer rule asks from the stored before-scopes, the
* same way the repeated walk's per-emission binding would.
*
* @param callable(Node $node, Scope $scope): void $nodeCallback
*/
public function replayRecording(RecordingNodeCallback $recording, callable $nodeCallback, ExpressionResultStorage $storage): void
{
$stack = $this->getExpressionResultStorageStack();
$stack->push($storage);
try {
$recording->replayThrough($nodeCallback);
} finally {
$stack->pop();
}
}

/**
* @param callable(Node $node, Scope $scope): void $nodeCallback
*/
Expand Down Expand Up @@ -955,6 +1041,13 @@ public function callNodeCallback(
return;
}

if ($nodeCallback instanceof RecordingNodeCallback) {
// recording never asks about types - the pairs are wrapped and
// bound to the storage at replay time instead
$nodeCallback($node, $scope);
return;
}

// post-order emission means the node's own result and every subnode
// result are already stored when the callback fires - NodeCallbackScope
// answers every ask synchronously from the storage; the emitting
Expand Down Expand Up @@ -1126,15 +1219,29 @@ public function processClosureNode(

$count = 0;
$closureResultScope = null;
$replayBodyRecording = null;
$replayPassStorage = null;
$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, new NoopNodeCallback(), StatementContext::createDeep());
$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());
Expand Down Expand Up @@ -1162,7 +1269,24 @@ public function processClosureNode(
}

$storage = $originalStorage;
$statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel());
if (
$replayBodyRecording !== null && $replayPassStorage !== null
&& $replayPassResult !== null && $replayEntryScope !== null
&& $closureScope->equals($replayEntryScope)
) {
// the final walk would repeat the recorded fixpoint pass exactly
// (same entry scope, deterministic walk) - adopt the pass's result
// and replay its emissions through the gathering callback instead.
// The pass's own entry scope takes over: the recorded pairs carry
// its anonymous-function reflection, which the gathering filter
// compares by identity (the state is equals-identical anyway).
$closureScope = $replayEntryScope;
$originalStorage->mergeResults($replayPassStorage);
$this->replayRecording($replayBodyRecording, $closureStmtsCallback, $originalStorage);
$statementResult = $replayPassResult;
} else {
$statementResult = $this->processStmtNodesInternal($expr, $expr->stmts, $closureScope, $storage, $closureStmtsCallback, StatementContext::createTopLevel());
}
$publicStatementResult = $statementResult->toPublic();
$closureReturnStatementsNodeScope = $this->refineClosureNodeScope($closureScope, $scope, $expr, $gatheredReturnStatementsWithScope, $gatheredYieldStatementsWithScope, $executionEnds, $statementResult->getThrowPoints(), array_merge($closureImpurePoints, $statementResult->getImpurePoints()), $invalidateExpressions);
$this->callNodeCallback($nodeCallback, new ClosureReturnStatementsNode(
Expand Down
44 changes: 44 additions & 0 deletions src/Analyser/RecordingNodeCallback.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
<?php declare(strict_types = 1);

namespace PHPStan\Analyser;

use PhpParser\Node;
use PHPStan\ShouldNotHappenException;

/**
* Records every (node, scope) emission of a convergence pass in order. When
* the pass turns out to be the fixpoint (the final walk's entry scope equals
* the pass's entry), the final walk is replaced by replaying the recording
* through the real node callback - the scopes are state-equal to what the
* repeated walk would emit.
*
* Recording appends the raw walk scope - nothing asks about types until a
* replay happens, so the pass pays no callback-scope construction and no
* storage binding. replayThrough() wraps each pair the way
* NodeScopeResolver::callNodeCallback() would have.
*/
final class RecordingNodeCallback
{

/** @var list<array{Node, Scope}> */
private array $pairs = [];

public function __invoke(Node $node, Scope $scope): void
{
$this->pairs[] = [$node, $scope];
}

/**
* @param callable(Node $node, Scope $scope): void $nodeCallback
*/
public function replayThrough(callable $nodeCallback): void
{
foreach ($this->pairs as [$node, $scope]) {
if (!$scope instanceof MutatingScope) {
throw new ShouldNotHappenException();
}
$nodeCallback($node, $scope->toNodeCallbackScope());
}
}

}
32 changes: 29 additions & 3 deletions src/Analyser/StmtHandler/DoWhileHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\Analyser\NoopNodeCallback;
use PHPStan\Analyser\RecordingNodeCallback;
use PHPStan\Analyser\StatementContext;
use PHPStan\Analyser\StmtHandler;
use PHPStan\DependencyInjection\AutowiredService;
Expand Down Expand Up @@ -48,8 +49,12 @@ public function processStmt(
$impurePoints = [];
$originalStorage = $storage;

$replayBodyRecording = null;
$replayPassStorage = null;
$replayPassResult = null;
$prevEntryScope = null;
if ($context->isTopLevel()) {
$prevEntryScope = null;
$bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts);
do {
$prevScope = $bodyScope;
$bodyScope = $bodyScope->mergeWith($scope);
Expand All @@ -62,7 +67,8 @@ public function processStmt(
}
$prevEntryScope = $bodyScope;
$storage = $originalStorage->duplicate();
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints();
$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) {
Expand All @@ -72,6 +78,13 @@ public function processStmt(
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;
Expand All @@ -87,7 +100,20 @@ public function processStmt(
}

$storage = $originalStorage;
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints();
if (
$replayBodyRecording !== null && $replayPassStorage !== null && $replayPassResult !== null
&& $prevEntryScope !== null && $bodyScope->equals($prevEntryScope)
) {
// the final body walk would repeat the recorded fixpoint pass exactly
// (same entry scope, deterministic walk) - adopt the pass's results
// and replay its emissions through the real callback instead; the
// condition walks below stay real
$originalStorage->mergeResults($replayPassStorage);
$nodeScopeResolver->replayRecording($replayBodyRecording, $nodeCallback, $originalStorage);
$bodyScopeResult = $replayPassResult;
} else {
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $context)->filterOutLoopExitPoints();
}
$bodyScope = $bodyScopeResult->getScope();
foreach ($bodyScopeResult->getExitPointsByType(Continue_::class) as $continueExitPoint) {
$bodyScope = $bodyScope->mergeWith($continueExitPoint->getScope());
Expand Down
35 changes: 32 additions & 3 deletions src/Analyser/StmtHandler/ForeachHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
use PHPStan\Analyser\MutatingScope;
use PHPStan\Analyser\NodeScopeResolver;
use PHPStan\Analyser\NoopNodeCallback;
use PHPStan\Analyser\RecordingNodeCallback;
use PHPStan\Analyser\Scope;
use PHPStan\Analyser\StatementContext;
use PHPStan\Analyser\StmtHandler;
Expand Down Expand Up @@ -135,6 +136,10 @@ public function processStmt(
$iterateeScope = $nodeScopeResolver->shouldPolluteScopeWithAlwaysIterableForeach() ? $scope->filterByTruthyValue($arrayComparisonExpr) : $scope;

$originalStorage = $storage;
$replayBodyRecording = null;
$replayPassStorage = null;
$replayPassResult = null;
$replayEntryScope = null;
$unrolledEndScope = null;
$unrolledTotalKeys = null;
if ($context->isTopLevel()) {
Expand All @@ -152,6 +157,7 @@ public function processStmt(
$bodyScope = $this->enterForeach($nodeScopeResolver, $originalScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback);
$count = 0;
$prevEntryScope = null;
$bodyIsReplayable = $nodeScopeResolver->isReplayableConvergenceBody($stmt, $stmt->stmts);
do {
$prevScope = $bodyScope;
$bodyScope = $bodyScope->mergeWith($iterateeScope);
Expand All @@ -164,11 +170,20 @@ public function processStmt(
$prevEntryScope = $bodyScope;
$storage = $originalStorage->duplicate();
$bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback);
$bodyScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, new NoopNodeCallback(), $context->enterDeep())->filterOutLoopExitPoints();
$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;
}
Expand All @@ -182,10 +197,24 @@ public function processStmt(
}

$bodyScope = $bodyScope->mergeWith($iterateeScope);
$finalEntryScope = $bodyScope;
$storage = $originalStorage;
$bodyScope = $this->enterForeach($nodeScopeResolver, $bodyScope, $storage, $originalScope, $stmt, $foreachIterateeType, $foreachNativeIterateeType, $nodeCallback);
$finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context;
$finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints();
if (
$replayBodyRecording !== null && $replayPassStorage !== null
&& $replayPassResult !== null && $replayEntryScope !== null
&& $unrolledTotalKeys === null && $finalEntryScope->equals($replayEntryScope)
) {
// the final walk would repeat the recorded fixpoint pass exactly
// (same entry scope, deterministic walk) - adopt the pass's results
// and replay its emissions through the real callback instead
$originalStorage->mergeResults($replayPassStorage);
$nodeScopeResolver->replayRecording($replayBodyRecording, $nodeCallback, $originalStorage);
$finalScopeResult = $replayPassResult;
} else {
$finalPassContext = $unrolledTotalKeys !== null ? $context->enterUnrolledForeach($unrolledTotalKeys) : $context;
$finalScopeResult = $nodeScopeResolver->processStmtNodesInternal($stmt, $stmt->stmts, $bodyScope, $storage, $nodeCallback, $finalPassContext)->filterOutLoopExitPoints();
}
$finalScope = $finalScopeResult->getScope();
$scopesWithIterableValueType = [];

Expand Down
Loading
Loading