Skip to content

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

Closed
phpstan-bot wants to merge 3 commits intophpstan:2.1.xfrom
phpstan-bot:create-pull-request/patch-7ltpyvc
Closed

Pre-compute count-specific conditional expressions to narrow list types when count() is stored in a variable#5461
phpstan-bot wants to merge 3 commits 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
if (
$assignedExpr instanceof FuncCall
&& $assignedExpr->name instanceof Name
&& in_array(strtolower($assignedExpr->name->toString()), ['count', 'sizeof'], true)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why do we need a hardcoded special case for count()?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Here's what I changed to address the reviewer's feedback:

The concern: staabm asked why we need a hardcoded special case for count() in AssignHandler.

The fix: Generalized the pre-computation to work for any FuncCall returning an integer, not just count/sizeof. The key insight is that TypeSpecifier::specifyTypesInCondition() already knows which functions benefit from value-specific narrowing (count, sizeof, strlen, mb_strlen, preg_match, etc.) — AssignHandler doesn't need to duplicate that knowledge.

Changes:

  • src/Analyser/ExprHandler/AssignHandler.php: Replaced the count/sizeof name check with $type->isInteger()->yes(), removed the inner $countArgType guard (TypeSpecifier handles this internally), renamed constant to INTEGER_CONDITIONAL_LIMIT, removed unused strtolower import
  • tests/PHPStan/Analyser/nsrt/bug-14464-analogous.php: Updated testCountWithMode expectation (now correctly narrows since TypeSpecifier's isNormalCountCall determines it's safe for list<int>), added testStrlen to demonstrate the generalization works beyond count

Verification: All 11,856 tests pass, PHPStan self-analysis reports no errors.

…sions

Instead of hardcoding count()/sizeof() in AssignHandler, delegate to
TypeSpecifier which already knows which functions benefit from
value-specific narrowing. This removes the special case and makes the
mechanism work for any integer-returning FuncCall (e.g. strlen).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
}

if (
$assignedExpr instanceof FuncCall
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we remember the expression which a variable was assigned from, in case its a pure function call?

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. Here's a summary of the changes made to address staabm's review:

What changed

Instead of pre-computing conditional expressions for integer values 1-8 at assignment time (the INTEGER_CONDITIONAL_LIMIT loop), the code now remembers the original FuncCall expression that was assigned to a variable and resolves it dynamically when needed.

Architecture

  1. ExpressionTypeHolder - Added an optional assignedFromExpr field. Since ExpressionTypeHolder lives in the expressionTypes array which is already threaded through all scope operations, no changes to the MutatingScope constructor or the 37 scopeFactory->create() calls were needed.

  2. AssignHandler - After assignVariable(), if the assigned expression is a FuncCall, sets assignedFromExpr on the ExpressionTypeHolder. Removed the pre-computation loop and INTEGER_CONDITIONAL_LIMIT constant.

  3. TypeSpecifier::resolveIdentical() - When one side is a Variable with a remembered FuncCall, also resolves the comparison with the original FuncCall substituted in and unions the results.

  4. MutatingScope - Added getVariableAssignedFromExpr() method. Modified specifyExpressionType(), filterBySpecifiedTypes(), processScopeForBody(), and generalizeVariableTypeHolders() to preserve assignedFromExpr through type narrowing, branch merges, and loop body processing. The remembered expression is automatically cleared on variable reassignment (since invalidateExpression removes the old holder).

Benefits over pre-computation

  • No arbitrary limit: count($arr) === 10 now correctly produces array{int, int, int, int, int, int, int, int, int, int} (previously fell back to non-empty-list<int>)
  • More memory-efficient: One expression reference per variable vs N conditional expressions
  • No hardcoded function names: Works for any FuncCall, resolved dynamically by TypeSpecifier

Verification

  • All 11,856 tests pass
  • PHPStan self-analysis reports no errors

…mputing conditional expressions

Instead of pre-computing conditional expressions for integer values 1-8 at
assignment time, remember the original FuncCall expression that was assigned
to a variable. When TypeSpecifier later resolves a comparison like `$var === N`,
it looks up the remembered expression and resolves `funcCall(...) === N`
dynamically, producing the same precise type narrowing without an arbitrary limit.

This addresses the review feedback asking to remember the expression a variable
was assigned from. The approach:

- ExpressionTypeHolder gains an optional `assignedFromExpr` field that survives
  type narrowing, branch merges, and scope transitions naturally (since it lives
  in the expressionTypes array which is already threaded through all scope operations)
- AssignHandler sets `assignedFromExpr` when a variable is assigned from a FuncCall
- TypeSpecifier::resolveIdentical() checks for remembered expressions and resolves
  the comparison with the original FuncCall substituted in
- The remembered expression is automatically cleared on variable reassignment
  (invalidateExpression removes the old ExpressionTypeHolder)

Benefits over the pre-computation approach:
- No arbitrary limit (count === 10 now produces array{T, T, T, T, T, T, T, T, T, T})
- More memory-efficient (one expression reference vs N conditional expressions)
- Works for any FuncCall, resolved dynamically by TypeSpecifier

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@staabm staabm closed this Apr 16, 2026
@staabm staabm deleted the create-pull-request/patch-7ltpyvc branch April 16, 2026 08:00
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.

2 participants