Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
Pre-compute count-specific conditional expressions to narrow list typ…
…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
  • Loading branch information
phpstan-bot committed Apr 14, 2026
commit 83af88c851c7deb104a4b5df0b5c55a3f14f0459
41 changes: 41 additions & 0 deletions src/Analyser/ExprHandler/AssignHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
use function in_array;
use function is_int;
use function is_string;
use function strtolower;

/**
* @implements ExprHandler<Assign|AssignRef>
Expand All @@ -81,6 +82,8 @@
final class AssignHandler implements ExprHandler
{

private const COUNT_CONDITIONAL_LIMIT = 8;

public function __construct(
private TypeSpecifier $typeSpecifier,
private PhpVersion $phpVersion,
Expand Down Expand Up @@ -313,6 +316,44 @@
$conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType);
}

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

&& $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.

&& count($assignedExpr->getArgs()) === 1
&& !$type instanceof ConstantIntegerType
) {
$countArgType = $scope->getType($assignedExpr->getArgs()[0]->value);
if ($countArgType->isArray()->yes() && ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes())) {

Check warning on line 327 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ && !$type instanceof ConstantIntegerType ) { $countArgType = $scope->getType($assignedExpr->getArgs()[0]->value); - if ($countArgType->isArray()->yes() && ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes())) { + if ($countArgType->isArray()->yes() && ($countArgType->isList()->yes() || !$countArgType->isConstantArray()->no())) { for ($n = 1; $n <= self::COUNT_CONDITIONAL_LIMIT; $n++) { $nType = new ConstantIntegerType($n); $identicalExpr = new Expr\BinaryOp\Identical(

Check warning on line 327 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ && !$type instanceof ConstantIntegerType ) { $countArgType = $scope->getType($assignedExpr->getArgs()[0]->value); - if ($countArgType->isArray()->yes() && ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes())) { + if ($countArgType->isArray()->yes() && (!$countArgType->isList()->no() || $countArgType->isConstantArray()->yes())) { for ($n = 1; $n <= self::COUNT_CONDITIONAL_LIMIT; $n++) { $nType = new ConstantIntegerType($n); $identicalExpr = new Expr\BinaryOp\Identical(

Check warning on line 327 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.3, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ && !$type instanceof ConstantIntegerType ) { $countArgType = $scope->getType($assignedExpr->getArgs()[0]->value); - if ($countArgType->isArray()->yes() && ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes())) { + if (!$countArgType->isArray()->no() && ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes())) { for ($n = 1; $n <= self::COUNT_CONDITIONAL_LIMIT; $n++) { $nType = new ConstantIntegerType($n); $identicalExpr = new Expr\BinaryOp\Identical(

Check warning on line 327 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ && !$type instanceof ConstantIntegerType ) { $countArgType = $scope->getType($assignedExpr->getArgs()[0]->value); - if ($countArgType->isArray()->yes() && ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes())) { + if ($countArgType->isArray()->yes() && ($countArgType->isList()->yes() || !$countArgType->isConstantArray()->no())) { for ($n = 1; $n <= self::COUNT_CONDITIONAL_LIMIT; $n++) { $nType = new ConstantIntegerType($n); $identicalExpr = new Expr\BinaryOp\Identical(

Check warning on line 327 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ && !$type instanceof ConstantIntegerType ) { $countArgType = $scope->getType($assignedExpr->getArgs()[0]->value); - if ($countArgType->isArray()->yes() && ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes())) { + if ($countArgType->isArray()->yes() && (!$countArgType->isList()->no() || $countArgType->isConstantArray()->yes())) { for ($n = 1; $n <= self::COUNT_CONDITIONAL_LIMIT; $n++) { $nType = new ConstantIntegerType($n); $identicalExpr = new Expr\BinaryOp\Identical(

Check warning on line 327 in src/Analyser/ExprHandler/AssignHandler.php

View workflow job for this annotation

GitHub Actions / Mutation Testing (8.4, ubuntu-latest)

Escaped Mutant for Mutator "PHPStan\Infection\TrinaryLogicMutator": @@ @@ && !$type instanceof ConstantIntegerType ) { $countArgType = $scope->getType($assignedExpr->getArgs()[0]->value); - if ($countArgType->isArray()->yes() && ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes())) { + if (!$countArgType->isArray()->no() && ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes())) { for ($n = 1; $n <= self::COUNT_CONDITIONAL_LIMIT; $n++) { $nType = new ConstantIntegerType($n); $identicalExpr = new Expr\BinaryOp\Identical(
for ($n = 1; $n <= self::COUNT_CONDITIONAL_LIMIT; $n++) {
$nType = new ConstantIntegerType($n);
$identicalExpr = new Expr\BinaryOp\Identical(
$assignedExpr,
new Node\Scalar\Int_($n),
);
$identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
$scope,
$identicalExpr,
TypeSpecifierContext::createTrue(),
);
$conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign(
$scope,
$var->name,
$conditionalExpressions,
$identicalSpecifiedTypes,
$nType,
);
$conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign(
$scope,
$var->name,
$conditionalExpressions,
$identicalSpecifiedTypes,
$nType,
);
}
}
}

$nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage);
$scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes());
foreach ($conditionalExpressions as $exprString => $holders) {
Expand Down
102 changes: 102 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14464-analogous.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
<?php declare(strict_types = 1);

namespace Bug14464Analogous;

use function PHPStan\Testing\assertType;

/**
* sizeof() alias
* @param list<int> $items
*/
function testSizeof(array $items): void {
$count = sizeof($items);
if ($count === 3) {
assertType('array{int, int, int}', $items);
}
}

/**
* Inline count still works
* @param list<int> $items
*/
function testInlineCount(array $items): void {
if (count($items) === 3) {
assertType('array{int, int, int}', $items);
}
}

/**
* explode() result
*/
function testExplode(string $input): void {
$parts = explode(',', $input);
$count = count($parts);
if ($count === 3) {
assertType('array{string, string, string}', $parts);
} elseif ($count === 1) {
assertType('array{string}', $parts);
}
}

/**
* Variable count >= N (range comparison)
* @param list<int> $items
*/
function testGreaterOrEqual(array $items): void {
$count = count($items);
if ($count >= 3) {
assertType('non-empty-list<int>', $items);
}
}

/**
* Count value > 8 (beyond pre-computed limit)
* @param list<int> $items
*/
function testBeyondLimit(array $items): void {
$count = count($items);
if ($count === 10) {
assertType('non-empty-list<int>', $items);
}
}

/**
* Count with mode argument excluded from pre-computation
* @param list<int> $items
*/
function testCountWithMode(array $items, int $mode): void {
$count = count($items, $mode);
if ($count === 3) {
assertType('non-empty-list<int>', $items);
}
}

/**
* Variable count on non-empty-list
* @param non-empty-list<string> $items
*/
function testNonEmptyList(array $items): void {
$count = count($items);
if ($count === 2) {
assertType('array{string, string}', $items);
}
}

/**
* Variable count with switch statement
* @param list<int> $items
*/
function testSwitch(array $items): void {
$count = count($items);
switch ($count) {
case 1:
assertType('array{int}', $items);
break;
case 2:
assertType('array{int, int}', $items);
break;
case 3:
assertType('array{int, int, int}', $items);
break;
}
}
72 changes: 72 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14464.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<?php declare(strict_types = 1);

namespace Bug14464;

use function PHPStan\Testing\assertType;

class HelloWorld
{
/** Variable count with == (loose comparison from the issue) */
protected function columnOrAlias(string $columnName): void
{
$colParts = preg_split('/\s+/', $columnName, -1, \PREG_SPLIT_NO_EMPTY);
if ($colParts === false) {
throw new \RuntimeException('preg error');
}
assertType('list<non-empty-string>', $colParts);
$numParts = count($colParts);

if ($numParts == 3) {
assertType('array{non-empty-string, non-empty-string, non-empty-string}', $colParts);
$this->columnName($colParts[0]);
$this->columnName($colParts[1]);
$this->columnName($colParts[2]);
} elseif ($numParts == 2) {
assertType('array{non-empty-string, non-empty-string}', $colParts);
$this->columnName($colParts[0]);
$this->columnName($colParts[1]);
} elseif ($numParts == 1) {
assertType('array{non-empty-string}', $colParts);
$this->columnName($colParts[0]);
} else {
throw new \LogicException('invalid');
}
}

/** Variable count with === (strict comparison) */
protected function strictComparison(string $input): void
{
$parts = preg_split('/,/', $input, -1, \PREG_SPLIT_NO_EMPTY);
if ($parts === false) {
throw new \RuntimeException('preg error');
}
$count = count($parts);

if ($count === 3) {
assertType('array{non-empty-string, non-empty-string, non-empty-string}', $parts);
} elseif ($count === 1) {
assertType('array{non-empty-string}', $parts);
}
}

/**
* Variable count on a PHPDoc list type
* @param list<int> $items
*/
protected function phpdocList(array $items): void
{
$count = count($items);
if ($count === 3) {
assertType('array{int, int, int}', $items);
} elseif ($count === 5) {
assertType('array{int, int, int, int, int}', $items);
} else {
assertType('list<int>', $items);
}
}

public function columnName(string $columnName): string
{
return 'abc';
}
}
Loading