Pre-compute count-specific conditional expressions to narrow list types when count() is stored in a variable#5461
Conversation
…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) |
There was a problem hiding this comment.
why do we need a hardcoded special case for count()?
There was a problem hiding this comment.
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 thecount/sizeofname check with$type->isInteger()->yes(), removed the inner$countArgTypeguard (TypeSpecifier handles this internally), renamed constant toINTEGER_CONDITIONAL_LIMIT, removed unusedstrtolowerimporttests/PHPStan/Analyser/nsrt/bug-14464-analogous.php: UpdatedtestCountWithModeexpectation (now correctly narrows since TypeSpecifier'sisNormalCountCalldetermines it's safe forlist<int>), addedtestStrlento 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 |
There was a problem hiding this comment.
Could we remember the expression which a variable was assigned from, in case its a pure function call?
There was a problem hiding this comment.
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
-
ExpressionTypeHolder- Added an optionalassignedFromExprfield. Since ExpressionTypeHolder lives in theexpressionTypesarray which is already threaded through all scope operations, no changes to the MutatingScope constructor or the 37scopeFactory->create()calls were needed. -
AssignHandler- AfterassignVariable(), if the assigned expression is aFuncCall, setsassignedFromExpron the ExpressionTypeHolder. Removed the pre-computation loop andINTEGER_CONDITIONAL_LIMITconstant. -
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. -
MutatingScope- AddedgetVariableAssignedFromExpr()method. ModifiedspecifyExpressionType(),filterBySpecifiedTypes(),processScopeForBody(), andgeneralizeVariableTypeHolders()to preserveassignedFromExprthrough type narrowing, branch merges, and loop body processing. The remembered expression is automatically cleared on variable reassignment (sinceinvalidateExpressionremoves the old holder).
Benefits over pre-computation
- No arbitrary limit:
count($arr) === 10now correctly producesarray{int, int, int, int, int, int, int, int, int, int}(previously fell back tonon-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>
Summary
When
count()is used inline in a condition (e.g.if (count($list) === 3)), PHPStan correctly narrows alist<T>toarray{T, T, T}. However, when the count result is stored in a variable first ($n = count($list); if ($n === 3)), the narrowing only producednon-empty-list<T>— losing the exact array shape and causing false "Offset might not exist" warnings.Changes
src/Analyser/ExprHandler/AssignHandler.php$var = count($arrayExpr)(orsizeof()) 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$varvalue (e.g.int(3)) to the precise narrowed array type (e.g.array{T, T, T})specifyTypesInCondition+processSureTypesForConditionalExpressionsAfterAssignmechanismCOUNT_CONDITIONAL_LIMIT = 8constant to bound the pre-computation!$type instanceof ConstantIntegerTypeto skip when count is already knownRoot cause
The conditional expression mechanism in
AssignHandlerpre-computes type narrowings at variable assignment time. For$count = count($list), it previously only computed:$countis non-zero →$listisnon-empty-list(viaCountFunctionTypeSpecifyingExtension)When
$count === 3was later checked, the truthy conditional fired via supertype match (int<1, max>⊇int(3)), giving onlynon-empty-list. The more precisearray{T, T, T}narrowing fromspecifyTypesForCountFuncCallwas never invoked because the variable comparison didn't reach the count-specific code path inresolveNormalizedIdentical(which requires the expression to be aFuncCall, not aVariable).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 (produceslist<string>which is narrowed) ✓non-empty-listtypes: works ✓switchstatement: works (each case narrows independently) ✓COUNT_NORMALmode (2 args): excluded from pre-computation, falls back tonon-empty-list— acceptable since the mode could affect semanticsnon-empty-listvia truthy conditional — acceptable tradeoffstrlen()variable narrowing: separate pre-existing issue —strlen()has noFunctionTypeSpecifyingExtension, so neither truthy nor exact-value conditional expressions are created for string narrowingcount()doesn't narrowarray{a: string, b?: int}by count valueTest
tests/PHPStan/Analyser/nsrt/bug-14464.php— regression test for the reported issue: variable count with==and===onpreg_splitresult and PHPDoc list typestests/PHPStan/Analyser/nsrt/bug-14464-analogous.php— tests for analogous cases:sizeof(),explode(),non-empty-list, range comparisons, values beyond limit, count with mode, andswitchstatementsFixes phpstan/phpstan#14464