Skip to content
Open
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
284 changes: 284 additions & 0 deletions src/Analyser/ExpressionResult.php
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>|null */
private ?array $readVariableNames = null;

/** @var list<string>|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;

Expand Down Expand Up @@ -160,4 +180,268 @@
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()) {

Check warning on line 233 in src/Analyser/ExpressionResult.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ foreach ($names as $name) { $askKnows = $readScope->hasVariableType($name); $positionKnows = $positionScope->hasVariableType($name); - if ($askKnows->no() && $positionKnows->no()) { + if ($askKnows->no() && !$positionKnows->yes()) { continue; } if (!$askKnows->equals($positionKnows)) {

Check warning on line 233 in src/Analyser/ExpressionResult.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ foreach ($names as $name) { $askKnows = $readScope->hasVariableType($name); $positionKnows = $positionScope->hasVariableType($name); - if ($askKnows->no() && $positionKnows->no()) { + if ($askKnows->no() && !$positionKnows->yes()) { continue; } if (!$askKnows->equals($positionKnows)) {
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<string>
*/
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<string>
*/
private static function collectReadVariableNames(Node $node): array
{
if ($node instanceof Expr) {
/** @var list<string>|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<string>
*/
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<string>
*/
private static function collectReadStateKeys(Node $node, ExprPrinter $exprPrinter): array
{
if ($node instanceof Expr) {
/** @var list<string>|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;
}

}
Loading
Loading