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
8 changes: 8 additions & 0 deletions src/Analyser/ExprHandler/AssignOpHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@
use PHPStan\Type\Type;
use function array_merge;
use function get_class;
use function is_string;
use function sprintf;

/**
Expand Down Expand Up @@ -68,6 +69,13 @@ static function (MutatingScope $scope) use ($stmt, $expr, $nodeCallback, $contex
$scope = $scope->filterByFalseyValue(
new BinaryOp\NotIdentical($expr->var, new ConstFetch(new Name('null'))),
);

if ($expr->var instanceof Expr\Variable && is_string($expr->var->name)) {
$context = $context->enterRightSideAssign(
$expr->var->name,
$expr->expr,
);
}
}

$exprResult = $nodeScopeResolver->processExprNode($stmt, $expr->expr, $scope, $storage, $nodeCallback, $context->enterDeep());
Expand Down
58 changes: 58 additions & 0 deletions src/Analyser/NodeScopeResolver.php
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,7 @@
use PHPStan\Node\VarTagChangedExpressionTypeNode;
use PHPStan\Parser\ArrowFunctionArgVisitor;
use PHPStan\Parser\ClosureArgVisitor;
use PHPStan\Parser\ClosureUseByRefAssignmentsVisitor;
use PHPStan\Parser\GotoLabelVisitor;
use PHPStan\Parser\ImmediatelyInvokedClosureVisitor;
use PHPStan\Parser\LineAttributesVisitor;
Expand Down Expand Up @@ -2933,6 +2934,11 @@
$callableParameters = $this->createCallableParameters($scope, $expr, $closureCallArgs, $passedToType);
$nativeCallableParameters = $this->createNativeCallableParameters($scope, $expr, $closureCallArgs, $nativePassedToType);

$enclosingByRefAssignedExprs = $expr->getAttribute(ClosureUseByRefAssignmentsVisitor::ATTRIBUTE_NAME);
if (!is_array($enclosingByRefAssignedExprs)) {
$enclosingByRefAssignedExprs = [];
}

$useScope = $scope;
foreach ($expr->uses as $use) {
if ($use->byRef) {
Expand Down Expand Up @@ -2969,6 +2975,18 @@
}
$scope = $scope->assignVariable($inAssignRightSideVariableName, $variableType, $variableNativeType, TrinaryLogic::createYes());
}

if (
is_string($use->var->name)
&& isset($enclosingByRefAssignedExprs[$use->var->name])
) {
$scope = $this->widenByRefUseFromEnclosingAssignments(
$scope,
$expr,
$use->var->name,
$enclosingByRefAssignedExprs[$use->var->name],
);
}
}
$this->processExprNode($stmt, $use->var, $useScope, $storage, $nodeCallback, $context);
if (!$use->byRef) {
Expand Down Expand Up @@ -3095,6 +3113,46 @@
return new ProcessClosureResult($scope, $statementResult->getThrowPoints(), $statementResult->getImpurePoints(), $invalidateExpressions, $closureResultScope, $byRefUses);
}

/**
* A variable captured by reference shares storage with the enclosing variable and the
* closure may run at any time, so inside the closure body the variable can hold any value
* the enclosing variable is ever assigned. Widen the seeded type with those assignments.
*
* @param Expr[] $assignedExprs
*/
private function widenByRefUseFromEnclosingAssignments(
MutatingScope $scope,
Expr\Closure $closure,
string $variableName,
array $assignedExprs,
): MutatingScope
{
$alreadyDefined = $scope->hasVariableType($variableName)->yes();

Check warning on line 3130 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ array $assignedExprs, ): MutatingScope { - $alreadyDefined = $scope->hasVariableType($variableName)->yes(); + $alreadyDefined = !$scope->hasVariableType($variableName)->no(); $type = $alreadyDefined ? $scope->getVariableType($variableName) : new NullType(); $nativeType = $alreadyDefined ? $scope->getNativeType(new Expr\Variable($variableName)) : new NullType();

Check warning on line 3130 in src/Analyser/NodeScopeResolver.php

View workflow job for this annotation

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

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ array $assignedExprs, ): MutatingScope { - $alreadyDefined = $scope->hasVariableType($variableName)->yes(); + $alreadyDefined = !$scope->hasVariableType($variableName)->no(); $type = $alreadyDefined ? $scope->getVariableType($variableName) : new NullType(); $nativeType = $alreadyDefined ? $scope->getNativeType(new Expr\Variable($variableName)) : new NullType();

$type = $alreadyDefined ? $scope->getVariableType($variableName) : new NullType();
$nativeType = $alreadyDefined ? $scope->getNativeType(new Expr\Variable($variableName)) : new NullType();
$changed = false;

foreach ($assignedExprs as $assignedExpr) {
// The closure's own value when it is assigned to the captured variable is already
// handled precisely via the right-side-assign context; skip it to avoid recursing
// into the closure type we are currently resolving.
if ($assignedExpr === $closure) {
continue;
}

$type = TypeCombinator::union($type, $scope->getType($assignedExpr));
$nativeType = TypeCombinator::union($nativeType, $scope->getNativeType($assignedExpr));
$changed = true;
}

if (!$changed) {
return $scope;
}

return $scope->assignVariable($variableName, $type, $nativeType, TrinaryLogic::createYes());
}

/**
* @param InvalidateExprNode[] $invalidatedExpressions
* @param string[] $uses
Expand Down
126 changes: 126 additions & 0 deletions src/Parser/ClosureUseByRefAssignmentsVisitor.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?php declare(strict_types = 1);

namespace PHPStan\Parser;

use Override;
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
use PHPStan\DependencyInjection\AutowiredService;
use function array_pop;
use function count;
use function is_string;

/**
* For each closure that captures a variable by reference, this visitor collects the
* right-hand side expressions of the assignments to that same variable that happen in the
* enclosing function-like scope *after* the closure is defined.
*
* A variable captured by reference shares its storage with the enclosing variable, and the
* closure may be invoked at any later point in time. Its type inside the closure body must
* therefore account for the values the enclosing variable is assigned afterwards, which the
* forward flow analysis would otherwise not see at the point the closure is created.
* Assignments preceding the closure are already reflected in the variable's narrowed type.
*/
#[AutowiredService]
final class ClosureUseByRefAssignmentsVisitor extends NodeVisitorAbstract
{

/**
* Attribute value shape: array<string, list<Node\Expr>> keyed by by-reference use variable name.
*/
public const ATTRIBUTE_NAME = 'enclosingByRefUseAssignedExprs';

/**
* @var list<array{assignments: list<array{int, string, Node\Expr}>, closures: list<array{Node\Expr\Closure, list<string>}>}>
*/
private array $frames = [];

#[Override]
public function beforeTraverse(array $nodes): ?array
{
$this->frames = [['assignments' => [], 'closures' => []]];
return null;
}

#[Override]
public function enterNode(Node $node): ?Node
{
if (
($node instanceof Node\Expr\Assign || $node instanceof Node\Expr\AssignRef || $node instanceof Node\Expr\AssignOp)
&& $node->var instanceof Node\Expr\Variable
&& is_string($node->var->name)
) {
$this->frames[count($this->frames) - 1]['assignments'][] = [$node->getStartFilePos(), $node->var->name, $node->expr];
}

if ($node instanceof Node\Expr\Closure) {
$byRefNames = [];
foreach ($node->uses as $use) {
if (!$use->byRef || !is_string($use->var->name)) {
continue;
}
$byRefNames[] = $use->var->name;
}
if (count($byRefNames) > 0) {
$this->frames[count($this->frames) - 1]['closures'][] = [$node, $byRefNames];
}
}

if ($node instanceof Node\FunctionLike) {
$this->frames[] = ['assignments' => [], 'closures' => []];
}

return null;
}

#[Override]
public function leaveNode(Node $node): ?Node
{
if ($node instanceof Node\FunctionLike) {
$frame = array_pop($this->frames);
if ($frame !== null) {
$this->attachFrame($frame);
}
}

return null;
}

#[Override]
public function afterTraverse(array $nodes): ?array
{
$frame = array_pop($this->frames);
if ($frame !== null) {
$this->attachFrame($frame);
}

return null;
}

/**
* @param array{assignments: list<array{int, string, Node\Expr}>, closures: list<array{Node\Expr\Closure, list<string>}>} $frame
*/
private function attachFrame(array $frame): void
{
foreach ($frame['closures'] as [$closure, $byRefNames]) {
$closureStartPos = $closure->getStartFilePos();
$map = [];
foreach ($frame['assignments'] as [$assignStartPos, $assignName, $assignExpr]) {
if ($assignStartPos <= $closureStartPos) {
continue;
}
foreach ($byRefNames as $name) {
if ($name !== $assignName) {
continue;
}
$map[$name][] = $assignExpr;
}
}
if (count($map) === 0) {
continue;
}
$closure->setAttribute(self::ATTRIBUTE_NAME, $map);
}
}

}
19 changes: 19 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-13810.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
<?php // lint >= 8.0

declare(strict_types = 1);

namespace Bug13810;

use function PHPStan\Testing\assertType;

function test(): void
{
static $isSupported;
$isSupported ??= function (mixed $arg) use (&$isSupported): bool {
assertType('Closure(mixed): bool', $isSupported);
return $isSupported($arg);
};

assertType('mixed~null', $isSupported);
$isSupported('foo');
}
39 changes: 39 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-7751.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<?php declare(strict_types = 1);

namespace Bug7751;

use function PHPStan\Testing\assertType;

function test(): void
{
$foo = false;

$test = function () use (&$foo) {
assertType('array{}|false', $foo);
if (is_array($foo)) {
echo 'array';
} else {
echo 'not array';
}
};

$foo = [];

$test();
}

function shutdown(): void
{
$tmpdir = '';

$shutdownFunction = function () use (&$tmpdir) {
assertType('\'\'|\'/tmp/my/useful/tempdir\'', $tmpdir);
if ($tmpdir !== '' && file_exists($tmpdir)) {
echo $tmpdir;
}
};

register_shutdown_function($shutdownFunction);

$tmpdir = '/tmp/my/useful/tempdir';
}
Loading