Skip to content

Pre-compute count-specific conditional expressions to narrow list types when count() is stored in a variable#5461

Open
phpstan-bot wants to merge 1 commit intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-7ltpyvc
Open

Pre-compute count-specific conditional expressions to narrow list types when count() is stored in a variable#5461
phpstan-bot wants to merge 1 commit intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-7ltpyvc

Conversation

@phpstan-bot
Copy link
Copy Markdown
Collaborator

Summary

When count() is used inline in a condition (e.g. if (count($list) === 3)), PHPStan correctly narrows a list<T> to array{T, T, T}. However, when the count result is stored in a variable first ($n = count($list); if ($n === 3)), the narrowing only produced non-empty-list<T> — losing the exact array shape and causing false "Offset might not exist" warnings.

Changes

  • Added count-specific conditional expression pre-computation in src/Analyser/ExprHandler/AssignHandler.php
    • When $var = count($arrayExpr) (or sizeof()) is assigned with a single argument, and the array is a list or constant array type, pre-compute conditional expressions for count values 1 through 8
    • Each conditional expression maps a specific $var value (e.g. int(3)) to the precise narrowed array type (e.g. array{T, T, T})
    • Uses the existing specifyTypesInCondition + processSureTypesForConditionalExpressionsAfterAssign mechanism
    • Added COUNT_CONDITIONAL_LIMIT = 8 constant to bound the pre-computation
    • Guarded by !$type instanceof ConstantIntegerType to skip when count is already known

Root cause

The conditional expression mechanism in AssignHandler pre-computes type narrowings at variable assignment time. For $count = count($list), it previously only computed:

  • Truthy: when $count is non-zero → $list is non-empty-list (via CountFunctionTypeSpecifyingExtension)
  • Falsey scalar 0: skipped because it's redundant with the truthy case

When $count === 3 was later checked, the truthy conditional fired via supertype match (int<1, max>int(3)), giving only non-empty-list. The more precise array{T, T, T} narrowing from specifyTypesForCountFuncCall was never invoked because the variable comparison didn't reach the count-specific code path in resolveNormalizedIdentical (which requires the expression to be a FuncCall, not a Variable).

The fix pre-computes the count-specific narrowing for small integer values (1-8) at assignment time, storing them as conditional expressions that fire on exact match when the count variable is compared to a specific integer.

Analogous cases probed

  • sizeof() alias: works (checked for in the same condition) ✓
  • explode() results: works (produces list<string> which is narrowed) ✓
  • non-empty-list types: works ✓
  • switch statement: works (each case narrows independently) ✓
  • Count with COUNT_NORMAL mode (2 args): excluded from pre-computation, falls back to non-empty-list — acceptable since the mode could affect semantics
  • Count value > 8: falls back to non-empty-list via truthy conditional — acceptable tradeoff
  • strlen() variable narrowing: separate pre-existing issue — strlen() has no FunctionTypeSpecifyingExtension, so neither truthy nor exact-value conditional expressions are created for string narrowing
  • Constant array with optional keys: pre-existing limitation — even inline count() doesn't narrow array{a: string, b?: int} by count value

Test

  • tests/PHPStan/Analyser/nsrt/bug-14464.php — regression test for the reported issue: variable count with == and === on preg_split result and PHPDoc list types
  • tests/PHPStan/Analyser/nsrt/bug-14464-analogous.php — tests for analogous cases: sizeof(), explode(), non-empty-list, range comparisons, values beyond limit, count with mode, and switch statements

Fixes phpstan/phpstan#14464

…es when `count()` is stored in a variable

- When `$count = count($list)` is assigned, pre-compute conditional
  expressions for count values 1-8 so that `$count === N` narrows
  `$list` to the exact array shape (e.g. `array{T, T, T}` for N=3)
- Previously, only inline `count($list) === 3` narrowed correctly;
  storing the count in a variable only gave `non-empty-list<T>`
- The fix extends AssignHandler to call specifyTypesInCondition with
  synthetic `count($expr) === N` comparisons for small N values,
  storing the results as ConditionalExpressionHolders
- Works for count() and sizeof() with a single argument on list and
  constant array types
- Analogous cases verified: sizeof() alias, explode() results,
  non-empty-list types, switch statements, PHPDoc list types
- strlen() variable narrowing is a separate pre-existing issue with
  a different mechanism (no TypeSpecifyingExtension) — not addressed
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.

1 participant