Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
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
Fix phpstan/phpstan#14464: Narrow list types when count() result is s…
…tored in variable

When the result of count() or sizeof() on a list or constant array is
assigned to a variable, create ConditionalExpressionHolders so that
comparing that variable to specific integers narrows the array type
to the corresponding fixed-size shape.

Previously, `$n = count($list); if ($n === 3)` did not narrow $list,
while the direct `if (count($list) === 3)` did. This was because
AssignHandler only created holders for falsey scalar values, not for
count-specific integer comparisons.

The fix iterates over possible array sizes and creates holders that
map each size to the narrowed array type produced by TypeSpecifier's
existing specifyTypesForCountFuncCall logic.
  • Loading branch information
phpstan-bot committed Apr 14, 2026
commit a9a6ebcb949b63ae39352e23cc8b2f407d9d91ee
33 changes: 33 additions & 0 deletions src/Analyser/ExprHandler/AssignHandler.php
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
use PHPStan\Type\Accessory\HasOffsetValueType;
use PHPStan\Type\Accessory\NonEmptyArrayType;
use PHPStan\Type\Constant\ConstantArrayType;
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
use PHPStan\Type\Constant\ConstantIntegerType;
use PHPStan\Type\Constant\ConstantStringType;
use PHPStan\Type\ConstantTypeHelper;
Expand All @@ -73,6 +74,8 @@
use function in_array;
use function is_int;
use function is_string;
use function min;
use function strtolower;

/**
* @implements ExprHandler<Assign|AssignRef>
Expand Down Expand Up @@ -313,6 +316,36 @@
$conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType);
}

if (
$assignedExpr instanceof FuncCall
&& $assignedExpr->name instanceof Name
&& in_array(strtolower((string) $assignedExpr->name), ['count', 'sizeof'], true)
&& count($assignedExpr->getArgs()) >= 1
) {
$countArgType = $scope->getType($assignedExpr->getArgs()[0]->value);
if ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes()) {

Check warning on line 326 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": @@ @@ && count($assignedExpr->getArgs()) >= 1 ) { $countArgType = $scope->getType($assignedExpr->getArgs()[0]->value); - if ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes()) { + if ($countArgType->isList()->yes() || !$countArgType->isConstantArray()->no()) { $arraySize = $countArgType->getArraySize(); $maxSize = ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT; if ($arraySize instanceof ConstantIntegerType) {

Check warning on line 326 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": @@ @@ && count($assignedExpr->getArgs()) >= 1 ) { $countArgType = $scope->getType($assignedExpr->getArgs()[0]->value); - if ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes()) { + if (!$countArgType->isList()->no() || $countArgType->isConstantArray()->yes()) { $arraySize = $countArgType->getArraySize(); $maxSize = ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT; if ($arraySize instanceof ConstantIntegerType) {

Check warning on line 326 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": @@ @@ && count($assignedExpr->getArgs()) >= 1 ) { $countArgType = $scope->getType($assignedExpr->getArgs()[0]->value); - if ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes()) { + if ($countArgType->isList()->yes() || !$countArgType->isConstantArray()->no()) { $arraySize = $countArgType->getArraySize(); $maxSize = ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT; if ($arraySize instanceof ConstantIntegerType) {

Check warning on line 326 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": @@ @@ && count($assignedExpr->getArgs()) >= 1 ) { $countArgType = $scope->getType($assignedExpr->getArgs()[0]->value); - if ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes()) { + if (!$countArgType->isList()->no() || $countArgType->isConstantArray()->yes()) { $arraySize = $countArgType->getArraySize(); $maxSize = ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT; if ($arraySize instanceof ConstantIntegerType) {
$arraySize = $countArgType->getArraySize();
$maxSize = ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT;
if ($arraySize instanceof ConstantIntegerType) {
$maxSize = $arraySize->getValue();
} elseif ($arraySize instanceof IntegerRangeType && $arraySize->getMax() !== null) {
$maxSize = min($maxSize, $arraySize->getMax());
}

for ($i = 1; $i <= $maxSize; $i++) {
$sizeType = new ConstantIntegerType($i);
if (!$type->isSuperTypeOf($sizeType)->yes()) {

Check warning on line 337 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": @@ @@ for ($i = 1; $i <= $maxSize; $i++) { $sizeType = new ConstantIntegerType($i); - if (!$type->isSuperTypeOf($sizeType)->yes()) { + if ($type->isSuperTypeOf($sizeType)->no()) { continue; }

Check warning on line 337 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": @@ @@ for ($i = 1; $i <= $maxSize; $i++) { $sizeType = new ConstantIntegerType($i); - if (!$type->isSuperTypeOf($sizeType)->yes()) { + if ($type->isSuperTypeOf($sizeType)->no()) { continue; }
continue;
}

$identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, new Node\Scalar\Int_($i));
$identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue());
$conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $sizeType);
$conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $sizeType);
}
}
}

$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
109 changes: 109 additions & 0 deletions tests/PHPStan/Analyser/nsrt/bug-14464.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
<?php declare(strict_types = 1);

namespace Bug14464;

use function PHPStan\Testing\assertType;

class HelloWorld
{
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);
} elseif ($numParts == 2) {
assertType('array{non-empty-string, non-empty-string}', $colParts);
} elseif ($numParts == 1) {
assertType('array{non-empty-string}', $colParts);
}
}

/** @param list<string> $list */
public function indirectCountCheck(array $list): void
{
$n = count($list);
if ($n === 3) {
assertType('array{string, string, string}', $list);
}
if ($n === 2) {
assertType('array{string, string}', $list);
}
if ($n === 1) {
assertType('array{string}', $list);
}
}

/** @param list<string> $list */
public function directCountCheck(array $list): void
{
if (count($list) === 3) {
assertType('array{string, string, string}', $list);
}
if (count($list) === 2) {
assertType('array{string, string}', $list);
}
if (count($list) === 1) {
assertType('array{string}', $list);
}
}

/** @param list<string> $list */
public function sizeofIndirect(array $list): void
{
$n = sizeof($list);
if ($n === 2) {
assertType('array{string, string}', $list);
}
}

/** @param list<int> $list */
public function looseEqualityCheck(array $list): void
{
$n = count($list);
if ($n == 3) {
assertType('array{int, int, int}', $list);
}
}

/**
* Non-list arrays should not get specific shapes since keys are unknown
* @param array<string, int> $map
*/
public function nonListArray(array $map): void
{
$n = count($map);
if ($n === 2) {
assertType('non-empty-array<string, int>', $map);
}
}

/** @param array{string}|array{string, string}|array{string, string, string} $list */
public function constantArrayUnionIndirect(array $list): void
{
$n = count($list);
if ($n === 2) {
assertType('array{string, string}', $list);
}
if ($n === 3) {
assertType('array{string, string, string}', $list);
}
}

/** @param array{a: string, b: int}|array{x: float, y: float, z: float} $map */
public function constantNonListDifferentShapes(array $map): void
{
$n = count($map);
if ($n === 2) {
assertType('array{a: string, b: int}', $map);
}
if ($n === 3) {
assertType('array{x: float, y: float, z: float}', $map);
}
}
}
Loading