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
104 changes: 101 additions & 3 deletions src/Analyser/ArgumentsHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -1007,14 +1007,20 @@ private function gatherClosureArgType(array $parametersAcceptors, int $i, Expr $
* never resolved below its bound, so the skeleton can only ever sharpen the
* resolution.
*
* The scope is the one before the array is evaluated, so its state only
* holds up to the first key/value that may change it (an assignment, ++/--,
* a call); the keys/values evaluated after it skip the scope state.
*
* Unlike gatherClosureArgType() this type never reaches $gatheredTypes: it
* exists solely to resolve the parameter type the nested closures are typed
* from. The argument's real type replaces it once the walk is done.
*/
private function gatherArrayArgTypeSkeleton(NodeScopeResolver $nodeScopeResolver, Expr\Array_ $expr, MutatingScope $scope): Type
{
$initializerContext = InitializerExprContext::fromScope($scope);
$getType = function (Expr $inner) use (&$getType, $nodeScopeResolver, $scope, $initializerContext): Type {
$staleLeaves = [];
self::collectStaleSkeletonLeaves($expr, false, $staleLeaves);
$getType = function (Expr $inner) use (&$getType, $nodeScopeResolver, $scope, $initializerContext, $staleLeaves): Type {
if ($inner instanceof Expr\Closure || $inner instanceof Expr\ArrowFunction) {
return $this->closureTypeResolver->getDeclaredClosureType($scope, $inner);
}
Expand All @@ -1023,13 +1029,105 @@ private function gatherArrayArgTypeSkeleton(NodeScopeResolver $nodeScopeResolver
return $this->initializerExprTypeResolver->getArrayType($inner, $getType);
}

return $nodeScopeResolver->findScopeStateType($inner, $scope)
?? $this->initializerExprTypeResolver->getType($inner, $initializerContext);
if (!isset($staleLeaves[spl_object_id($inner)])) {
$stateType = $nodeScopeResolver->findScopeStateType($inner, $scope);
if ($stateType !== null) {
return $stateType;
}
}

return $this->initializerExprTypeResolver->getType($inner, $initializerContext);
};

return $this->initializerExprTypeResolver->getArrayType($expr, $getType);
}

/**
* Walks the array literal in evaluation order and collects the keys/values
* evaluated after the first one that may change the scope. An item's key
* runs before its value, but a plain variable is only read when the item is
* added to the array - after both: `[$i++ => $i]` reads `$i` after the key.
*
* @param array<int, true> $staleLeaves
*/
private static function collectStaleSkeletonLeaves(Expr\Array_ $expr, bool $stale, array &$staleLeaves): bool
{
foreach ($expr->items as $item) {
$key = $item->key;
$value = $item->value;
$valueStale = $stale || ($key !== null && !self::isScopeNeutralSkeletonLeaf($key));
if ($value instanceof Expr\Array_) {
$staleAfterValue = self::collectStaleSkeletonLeaves($value, $valueStale, $staleLeaves);
} else {
if ($valueStale) {
$staleLeaves[spl_object_id($value)] = true;
}
$staleAfterValue = $valueStale || !self::isScopeNeutralSkeletonLeaf($value);
}

if ($key !== null && ($stale || ($staleAfterValue && $key instanceof Variable))) {
$staleLeaves[spl_object_id($key)] = true;
}

$stale = $staleAfterValue;
}

return $stale;
}

/**
* Whether evaluating the expression leaves the scope as it was: reads of
* variables and constants, literals, closures that bind nothing by
* reference, and first-class callables on such operands. Anything else is
* assumed to change it.
*/
private static function isScopeNeutralSkeletonLeaf(Expr $expr): bool
{
if ($expr instanceof Node\Scalar) {
return !$expr instanceof Node\Scalar\InterpolatedString;
}

if ($expr instanceof Variable) {
return is_string($expr->name);
}

if ($expr instanceof Expr\ConstFetch || $expr instanceof Expr\ArrowFunction) {
return true;
}

if ($expr instanceof Expr\ClassConstFetch) {
return $expr->class instanceof Name && $expr->name instanceof Identifier;
}

if ($expr instanceof Expr\Closure) {
foreach ($expr->uses as $use) {
if ($use->byRef) {
return false;
}
}

return true;
}

if (!$expr instanceof CallLike || !$expr->isFirstClassCallable()) {
return false;
}

if ($expr instanceof FuncCall) {
return $expr->name instanceof Name;
}

if ($expr instanceof MethodCall) {
return $expr->name instanceof Identifier && self::isScopeNeutralSkeletonLeaf($expr->var);
}

if ($expr instanceof StaticCall) {
return $expr->class instanceof Name && $expr->name instanceof Identifier;
}

return false;
}

/**
* @param array<int|string, Type> $types
* @param ParametersAcceptor[] $parametersAcceptors
Expand Down
2 changes: 1 addition & 1 deletion src/Turbo/TurboExtensionEnabler.php
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
final class TurboExtensionEnabler
{

public const EXPECTED_EXTENSION_VERSION = '28c29b8';
public const EXPECTED_EXTENSION_VERSION = '6f2977d';

private static bool $active = false;

Expand Down
145 changes: 145 additions & 0 deletions tests/PHPStan/Analyser/nsrt/array-arg-skeleton-stale-scope.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
<?php // lint >= 8.1

namespace ArrayArgSkeletonStaleScope;

use function PHPStan\Testing\assertType;

/**
* @template T
* @param array{first: mixed, value: T, callback: callable(T): void} $spec
*/
function run(array $spec): void
{
}

/**
* @template T
* @param array{0: array<mixed, T>, 1: callable(T): void} $spec
*/
function runValues(array $spec): void
{
}

/**
* @template K of int
* @param array{0: array<K, mixed>, 1: callable(K): void} $spec
*/
function runKeys(array $spec): void
{
}

class Foo
{

public ?int $prop = null;

public function reset(): void
{
$this->prop = null;
}

public function impureCall(): void
{
$this->prop = 5;
run([
'first' => $this->reset(),
'value' => $this->prop,
'callback' => function ($v): void {
assertType('mixed', $v);
},
]);
}

}

function assign(): void
{
$x = 1;
run([
'first' => $x = 'str',
'value' => $x,
'callback' => function ($v): void {
assertType('mixed', $v);
},
]);
}

function increment(): void
{
$i = 0;
run([
'first' => $i++,
'value' => $i,
'callback' => function ($v): void {
assertType('mixed', $v);
},
]);
}

function nested(): void
{
$i = 0;
run([
'first' => [$i++],
'value' => $i,
'callback' => function ($v): void {
assertType('mixed', $v);
},
]);
}

function valueReadAfterKey(): void
{
$i = 0;
runValues([
[$i++ => $i],
function ($v): void {
assertType('mixed', $v);
},
]);
}

function keyReadAfterValue(): void
{
$i = 0;
runKeys([
[$i => $i++],
function ($k): void {
assertType('int', $k);
},
]);
}

function neutralSiblings(int $x): void
{
$i = 0;
run([
'first' => $x,
'value' => $i,
'callback' => function ($v): void {
assertType('0', $v);
},
]);
run([
'first' => function () use ($i): void {
},
'value' => $i,
'callback' => function ($v): void {
assertType('0', $v);
},
]);
run([
'first' => strlen(...),
'value' => $i,
'callback' => function ($v): void {
assertType('0', $v);
},
]);
run([
'first' => \PHP_VERSION_ID,
'value' => $i,
'callback' => function ($v): void {
assertType('0', $v);
},
]);
}
Original file line number Diff line number Diff line change
Expand Up @@ -1288,4 +1288,9 @@ public function testBug14847(): void
]);
}

public function testArrayArgSkeletonStaleScope(): void
{
$this->analyse([__DIR__ . '/data/array-arg-skeleton-stale-scope.php'], []);
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php

namespace ArrayArgSkeletonStaleScopeRule;

/**
* @template T
* @param array{first: mixed, value: T, callback: callable(T): void} $spec
*/
function run(array $spec): void
{
}

function doFoo(): void
{
$i = 0;
run([
'first' => $i++,
'value' => $i,
'callback' => function ($v): void {
if ($v === 1) {
echo 'one';
}
},
]);
}
Loading
Loading