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
47 changes: 46 additions & 1 deletion src/Analyser/StmtHandler/ForHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@
use function count;
use function in_array;
use function is_string;
use function spl_object_id;

/**
* @implements StmtHandler<For_>
Expand Down Expand Up @@ -138,6 +139,15 @@ public function processStmt(
$throwPoints = array_merge($throwPoints, $initResult->getThrowPoints());
$impurePoints = array_merge($impurePoints, $initResult->getImpurePoints());
}
$initTargets = [];
foreach ($stmt->init as $initExpr) {
if (!$initExpr instanceof Assign) {
continue;
}
foreach (self::targetVariables($initExpr->var) as $variable) {
$initTargets[spl_object_id($variable)] = true;
}
}

$originalStorage = $storage;

Expand Down Expand Up @@ -304,7 +314,15 @@ public function processStmt(
$loop = $isIterableAtLeastOnce->no()
? VariableFlow::sequence($condition, VariableFlow::dead(VariableFlow::sequence($finalScopeResult->getVariableFlow(), $update)))
: VariableFlow::loop($condition, $finalScopeResult->getVariableFlow(), $update, $isIterableAtLeastOnce->yes(), !$alwaysIterates->yes());
$variableFlow = VariableFlow::sequence(...[...$initFlow, $loop]);
$initWrites = VariableFlowBuilder::writes(VariableFlow::sequence(...$initFlow));
$bindings = [];
foreach ($initWrites as $write) {
if ($write->isOffsetWrite() || !isset($initTargets[$write->getId()])) {
continue;
}
$bindings[] = $write;
}
$variableFlow = VariableFlow::loopStatement($stmt, VariableFlow::sequence(...[...$initFlow, $loop]), $bindings, [...$initWrites, ...VariableFlowBuilder::writes($update)]);
return new InternalStatementResult(
$finalScope->addTemplateArgumentConstraints($loopScope->getTemplateArgumentConstraints()),
hasYield: $finalScopeResult->hasYield() || $hasYield,
Expand All @@ -316,4 +334,31 @@ public function processStmt(
);
}

/**
* The variables an assignment target binds - the variable itself, or the
* variables of a destructuring list's items.
*
* @return list<Variable>
*/
private static function targetVariables(Expr $target): array
{
if ($target instanceof Variable) {
return [$target];
}
if (!$target instanceof Expr\List_ && !$target instanceof Expr\Array_) {
return [];
}
$variables = [];
foreach ($target->items as $item) {
if ($item === null) {
continue;
}
foreach (self::targetVariables($item->value) as $variable) {
$variables[] = $variable;
}
}

return $variables;
}

}
9 changes: 9 additions & 0 deletions src/Analyser/StmtHandler/ForeachHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,15 @@ static function () use ($condResult, $emptyArrayType): Type {
$stmt->byRef && $stmt->valueVar instanceof Variable && is_string($stmt->valueVar->name) ? VariableFlow::escape($stmt->valueVar->name) : null,
);
$loopFlow = VariableFlow::loop($traversableThrowPoint !== null ? VariableFlow::throwing($traversableThrowPoint->getType(), true) : null, VariableFlow::sequence($bindingFlow, $finalScopeResult->getVariableFlow()), null, $isIterableAtLeastOnce->yes() && $nodeScopeResolver->shouldPolluteScopeWithAlwaysIterableForeach(), true);
$bindingWrites = VariableFlowBuilder::writes($bindingFlow);
$bindings = [];
foreach ($bindingWrites as $write) {
if ($write->isOffsetWrite()) {
continue;
}
$bindings[] = $write;
}
$loopFlow = VariableFlow::loopStatement($stmt, $loopFlow, $bindings, $bindingWrites);
return new InternalStatementResult(
$finalScope->addTemplateArgumentConstraints($finalScopeResult->getScope()->getTemplateArgumentConstraints()),
hasYield: $finalScopeResult->hasYield() || $condResult->hasYield(),
Expand Down
8 changes: 8 additions & 0 deletions src/Analyser/VariableControlFlow.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
namespace PHPStan\Analyser;

use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Stmt\For_;
use PhpParser\Node\Stmt\Foreach_;
use PHPStan\Node\Variable\VariableWrite;
use PHPStan\Type\Type;

final class VariableControlFlow extends VariableFlow
Expand All @@ -13,6 +16,8 @@ final class VariableControlFlow extends VariableFlow
* @param list<VariableFlow|null> $children
* @param list<array{Type, VariableFlow|null}> $catches
* @param list<array{VariableFlow|null, VariableFlow|null, bool}> $cases
* @param list<VariableWrite> $bindings
* @param list<VariableWrite> $ownWrites
*/
public function __construct(
string $kind,
Expand All @@ -27,6 +32,9 @@ public function __construct(
public readonly array $cases = [],
public readonly bool $canRepeat = true,
public readonly bool $canContainAnyThrowable = false,
public readonly Foreach_|For_|null $stmt = null,
public readonly array $bindings = [],
public readonly array $ownWrites = [],
)
{
parent::__construct($kind);
Expand Down
22 changes: 22 additions & 0 deletions src/Analyser/VariableFlow.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace PHPStan\Analyser;

use PhpParser\Node\Expr\ArrowFunction;
use PhpParser\Node\Stmt\For_;
use PhpParser\Node\Stmt\Foreach_;
use PHPStan\Node\Variable\VariableWrite;
use PHPStan\Type\Type;
use function count;
Expand Down Expand Up @@ -36,6 +38,7 @@ abstract class VariableFlow
public const THROW = 'throw';
public const STOP = 'stop';
public const ARROW = 'arrow';
public const LOOP_STATEMENT = 'loopStatement';

/** @param self::* $kind */
protected function __construct(public readonly string $kind)
Expand Down Expand Up @@ -160,6 +163,25 @@ public static function loop(?self $condition, ?self $body, ?self $update, bool $
return new VariableControlFlow(self::LOOP, [$condition, $body, $update], atLeastOnce: $atLeastOnce, canExit: $canExit, canRepeat: $canRepeat);
}

/**
* A loop statement whose head binds variables - a foreach key/value or
* a for-loop initial assignment. The bindings are checked for reusing a
* variable that is assigned before the statement and read after it;
* the statement's own writes (the bindings and a for-loop update) are
* the only assignments allowed in between.
*
* @param list<VariableWrite> $bindings
* @param list<VariableWrite> $ownWrites
*/
public static function loopStatement(Foreach_|For_ $stmt, ?self $flow, array $bindings, array $ownWrites): ?self
{
if ($bindings === []) {
return $flow;
}

return new VariableControlFlow(self::LOOP_STATEMENT, [$flow], stmt: $stmt, bindings: $bindings, ownWrites: $ownWrites);
}

/** @param list<array{Type, self|null}> $catches */
public static function tryCatch(?self $body, array $catches, ?self $finally): self
{
Expand Down
27 changes: 27 additions & 0 deletions src/Analyser/VariableFlowBuilder.php
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,33 @@ public static function targetRead(Expr $target, ExpressionResultStorage $storage
return self::child($target, $storage);
}

/**
* The writes of a flow composed only of accesses and sequences - an
* assignment target or a loop head.
*
* @return list<VariableWrite>
*/
public static function writes(?VariableFlow $flow): array
{
if ($flow === null) {
return [];
}
if ($flow instanceof VariableAccessFlow) {
return $flow->write !== null ? [$flow->write] : [];
}
if (!$flow instanceof VariableSequenceFlow) {
return [];
}
$writes = [];
foreach ($flow->children as $child) {
foreach (self::writes($child) as $write) {
$writes[] = $write;
}
}

return $writes;
}

/** @param VariableWrite::KIND_* $kind */
public static function targetWrite(Expr $target, int $kind, MutatingScope $scope, ExpressionResultStorage $storage, ?Type $redundant = null): ?VariableFlow
{
Expand Down
104 changes: 103 additions & 1 deletion src/Analyser/VariableLivenessResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
namespace PHPStan\Analyser;

use PhpParser\Node;
use PhpParser\Node\Stmt\For_;
use PhpParser\Node\Stmt\Foreach_;
use PHPStan\Node\Variable\VariableWrite;
use PHPStan\Node\VariableWritesNode;
use PHPStan\ShouldNotHappenException;
Expand All @@ -18,6 +20,11 @@
use function is_int;
use function is_string;
use function spl_object_id;
use function sprintf;
use function str_ends_with;
use function str_starts_with;
use function strlen;
use function substr;

/** Resolve liveness backwards over immutable body fragments. */
final class VariableLivenessResolver
Expand Down Expand Up @@ -77,6 +84,15 @@ final class VariableLivenessResolver
/** @var array<string, true> */
private array $allReadKeys = [];

/** @var array<int, Foreach_|For_> */
private array $loopStatements = [];

/** @var array<int, array<int, true>> */
private array $ownWriteIds = [];

/** @var array<int, Foreach_|For_> */
private array $variableOverwritingLoops = [];

private bool $opaque = false;

private bool $readsAllVariables = false;
Expand Down Expand Up @@ -125,7 +141,7 @@ public static function resolve(Node\FunctionLike $function, ?VariableFlow $flow)
$self->resolveCoverage();
}

return new VariableWritesNode($function, array_values($self->writes), $self->observedIds + $self->readIds, $self->readIds, $self->coveredIds, $self->readNames, $self->redundantTypes, $self->mentionedNames, $self->escapedNames, $self->opaque, $self->allNamesMentioned);
return new VariableWritesNode($function, array_values($self->writes), $self->observedIds + $self->readIds, $self->readIds, $self->coveredIds, $self->readNames, $self->redundantTypes, $self->mentionedNames, $self->escapedNames, $self->variableOverwritingLoops, $self->opaque, $self->allNamesMentioned);
}

private function collect(?VariableFlow $flow, bool $dead = false): void
Expand Down Expand Up @@ -177,6 +193,16 @@ private function collect(?VariableFlow $flow, bool $dead = false): void
if (!$flow instanceof VariableControlFlow) {
return;
}
if ($flow->kind === VariableFlow::LOOP_STATEMENT && $flow->stmt !== null) {
$ownIds = [];
foreach ($flow->ownWrites as $write) {
$ownIds[$write->getId()] = true;
}
foreach ($flow->bindings as $binding) {
$this->loopStatements[$binding->getId()] = $flow->stmt;
$this->ownWriteIds[$binding->getId()] = $ownIds;
}
}
if ($flow->kind === VariableFlow::RETURN && $flow->name !== null && $this->returnsByReference) {
$this->escapedNames[$flow->name] = true;
}
Expand Down Expand Up @@ -210,11 +236,17 @@ private function liveBefore(?VariableFlow $flow, array $next, VariableFlowContex
if (in_array($flow->kind, [VariableFlow::READ, VariableFlow::ESCAPE], true)) {
// a by-reference capture aliases the variable - the value it
// holds at that point is observable through the alias
if ($flow->kind === VariableFlow::ESCAPE && $this->loopStatements !== []) {
$next = $this->passBindingProbes($next, $flow->name, null);
}
return $next + ($this->readKeys[spl_object_id($flow)] ?? []);
}
if ($flow->write === null || $flow->kind === VariableFlow::DEFINE) {
return $next;
}
if ($this->loopStatements !== []) {
$next = $this->passBindingProbes($next, $flow->name, $flow->write, $flow->kind === VariableFlow::DISCARD);
}
$id = $flow->write->getId();
if ($flow->kind !== VariableFlow::DISCARD) {
$this->observeWrite($id, $next);
Expand Down Expand Up @@ -243,6 +275,34 @@ private function liveBefore(?VariableFlow $flow, array $next, VariableFlowContex
if (!$flow instanceof VariableControlFlow) {
throw new ShouldNotHappenException();
}
if ($flow->kind === VariableFlow::LOOP_STATEMENT) {
// a binding reusing a variable that is read after the loop: the
// probe follows the variable backwards through the statement;
// surviving to its entry, it is armed to catch the assignment
// before the loop whose value the binding replaces
foreach ($flow->bindings as $binding) {
if (!isset($this->nameKeys[$binding->getVariableName()])) {
continue;
}
foreach (array_keys($this->nameKeys[$binding->getVariableName()]) as $key) {
if (!isset($next[$key])) {
continue;
}
$next[self::bindingProbe($binding, false)] = true;
break;
}
}
$names = $this->liveBefore($flow->children[0], $next, $context);
foreach ($flow->bindings as $binding) {
$probe = self::bindingProbe($binding, false);
if (!isset($names[$probe])) {
continue;
}
unset($names[$probe]);
$names[self::bindingProbe($binding, true)] = true;
}
return $names;
}
if ($flow->kind === VariableFlow::ARROW && $flow->arrow !== null) {
$outputs = $this->liveBefore($flow->children[1], [], new VariableFlowContext([]));
$names = $this->liveBefore($flow->children[0], $outputs, new VariableFlowContext($outputs, uncaught: $outputs));
Expand Down Expand Up @@ -362,6 +422,48 @@ private function liveBefore(?VariableFlow $flow, array $next, VariableFlowContex
return $next;
}

/**
* A probe travels in the live set under a key no read can produce. Armed
* once it has survived its loop statement, it records the assignment (or
* by-reference alias) before the loop whose variable the binding takes
* over; the loop's own writes let it through in either state.
*/
private static function bindingProbe(VariableWrite $binding, bool $armed): string
{
return sprintf("\0%s\0%d%s", $binding->getVariableName(), $binding->getId(), $armed ? "\0" : '');
}

/**
* @param array<string, true> $next
* @return array<string, true>
*/
private function passBindingProbes(array $next, string $name, ?VariableWrite $write, bool $discard = false): array
{
$prefix = sprintf("\0%s\0", $name);
foreach (array_keys($next) as $key) {
if (!str_starts_with($key, $prefix)) {
continue;
}
$id = substr($key, strlen($prefix));
$armed = str_ends_with($id, "\0");
$bindingId = (int) ($armed ? substr($id, 0, -1) : $id);
if ($write !== null && isset($this->ownWriteIds[$bindingId][$write->getId()])) {
continue;
}
if ($armed && !$discard) {
$this->variableOverwritingLoops[$bindingId] = $this->loopStatements[$bindingId];
}
if ($write === null || $write->isOffsetWrite()) {
// an alias or an offset write keeps the variable - the probe
// carries on to the assignment that created it
continue;
}
unset($next[$key]);
}

return $next;
}

/** @param int|string $offset */
private static function offsetKey($offset): string
{
Expand Down
Loading
Loading