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?
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