Skip to content

Keep list type when writing to $list[count($list)] and compare the offset's array through its printed expression - #6226

Open
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-dmx933f
Open

Keep list type when writing to $list[count($list)] and compare the offset's array through its printed expression#6226
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-dmx933f

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

Assigning $list[count($list)] = $value appends right behind the last element and therefore keeps the array a list, but PHPStan degraded list<int> to non-empty-array<int<0, max>, int> and reported non-empty-array<int<0, max>, int> might not be a list.

AssignHandler::shouldKeepList() already knew a family of list-preserving offset idioms (count($list) - n, array_key_last(), array_key_first(), array_search(), $index + 1) but was missing the plain count($list) one. Probing the neighbours of that heuristic turned up four more defects in the same code, all fixed here.

Changes

All in src/Analyser/ExprHandler/AssignHandler.php:

  • shouldKeepList() recognizes $list[count($list)] and $list[sizeof($list)].
  • isSameVariable() is replaced by isSameArrayExpr() / isStableExpr(), which compare two array expressions by their printed form after checking they are side-effect free and stable (variables, property fetches, static property fetches, and dim fetches over those with scalar or stable dims). Every heuristic in shouldKeepList() now also fires for $this->list, self::$staticList, $this->nested['x'] and $data[$key].
  • New $list[array_key_last($list) + 1] append idiom, guarded by isIterableAtLeastOnce() because array_key_last() returns null for an empty list, making null + 1 === 1 leave a hole.
  • isFuncCallOnSameArray() rejects unpacked (count(...$list)) and named arguments, so they no longer masquerade as count($list).
  • The array_search() branch required >= 1 arguments but read getArgs()[1]; $list[array_search($list)] crashed with AssignHandler::isSameVariable(): Argument #2 ($b) must be of type PhpParser\Node\Expr, null given. It now requires >= 2.
  • produceArrayDimFetchAssignValueToWrite() hands shouldKeepList() the dim fetch carried by $offsetTypes instead of $dimFetchStack[$i]. The loop walks $offsetTypes reversed, so for a nested write the two indexes point at different links of the chain. The hasExpressionType() check above it deliberately keeps using $dimFetchStack[$i] — changing it there regresses nested constant-array precision (assign-nested-arrays.php, pr-4390.php) and is out of scope here.
  • The additional expression types registered after the write are skipped when the offset expression no longer resolves to the offset that was just written.

Root cause

Two independent root causes.

  1. A missing member of a heuristic family, plus a too-narrow expression comparison. shouldKeepList() enumerates offset expressions that provably keep an array a list. count($list) was simply absent from that list. The comparison that decides "is this the same array?" was isSameVariable(), which only ever returned true for two Variable nodes — so none of the heuristics worked when the array was a property, a static property, or a nested offset. The same narrowness meant nested writes never matched either, compounded by the wrong $dimFetchStack index.

  2. The offset expression is re-evaluated after the container changed. applyWrite() registers the written dim fetch in the scope via assignExpression(). MutatingScope::specifyExpressionTypeInPlace() then re-reads $expr->dim in the post-assignment scope and intersects the container with HasOffsetValueType. When the dim depends on the container — count($list) — it resolves to a different value than the one that was written, and the intersection is unsatisfiable:

    $list = [1, 2, 3];
    $list[count($list)] = 37;      // array{1, 2, 3, 37} intersected with hasOffsetValue(4, 37)
    \PHPStan\dumpType($list);      // *NEVER* before, array{1, 2, 3, 37} now

    The same collapse happened for $list[count($list) + 1] = 37. Skipping the registration when the recomputed offset type differs from the written one fixes both.

Test

tests/PHPStan/Analyser/nsrt/bug-15080.php holds the playground reproducer verbatim plus the analogous cases found while probing:

  • count() and sizeof() on a local variable, a property, a static property, a nested property, a nested local array, and a dim fetch with a variable key
  • $list[count($list)] ??= $v (assign-op path), count($list) - 0, and appending in a for loop
  • array_key_last($list) + 1 and 1 + array_key_last($list) on a non-empty list, and the possibly-empty-list counterpart that must not stay a list
  • the *NEVER* regressions: $list = [1, 2, 3]; $list[count($list)] = 37; and $list[count($list) + 1] = 37;
  • array_key_last(), count($list) - 1 and array_search() on $this->list, which the existing heuristics missed
  • negative cases that must keep degrading to non-empty-array: count($other), count($list, COUNT_RECURSIVE), count(...$list)
  • $list[array_search($list)], which used to abort the analysis with an internal error

tests/PHPStan/Rules/Functions/ReturnTypeRuleTest::testBug15080() locks the reported symptom itself — the reproducer must analyse without the return.type error.

Both fail without the fix (the rule test with the exact message from the issue, the NSRT file with 18 wrong types and an internal error) and pass with it. make tests, make phpstan and make cs are green.

Fixes phpstan/phpstan#15080

…offset's array through its printed expression

- `AssignHandler::shouldKeepList()` now recognizes `$list[count($list)]` / `$list[sizeof($list)]` as a write right behind the last element, so the list type survives the assignment
- Replaced `isSameVariable()` with `isSameArrayExpr()`, which compares side-effect-free expressions (variables, property fetches, static property fetches and dim fetches over them) through `ExprPrinter`. All existing heuristics - `count($list) - n`, `array_key_last()`/`array_key_first()`, `array_search()` - now work on `$this->list`, `self::$list` and `$data['x']` too, not just on plain variables
- Added `$list[array_key_last($list) + 1]` as another append idiom, guarded by non-emptiness because `array_key_last()` returns `null` on an empty list
- Function arguments are now read through a helper that rejects unpacked and named args, so `count(...$list)` no longer looks like `count($list)`
- Fixed a crash: `$list[array_search($list)]` (too few arguments) read `getArgs()[1]` unconditionally and blew up with a `TypeError`
- `shouldKeepList()` is now handed the dim fetch the written offset actually belongs to; the reversed loop indexed `$dimFetchStack` the other way round, which broke every heuristic for nested writes like `$data['x'][count($data['x'])]`
- Additional expression types are no longer registered when the offset expression resolves differently after the write. `$list = [1, 2, 3]; $list[count($list)] = 37;` re-evaluated `count($list)` against the already-updated array and intersected the result with `hasOffsetValue(4, 37)`, collapsing the whole array to `*NEVER*`
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Assigning $list[count($list)] makes arrays lose their list status

1 participant