From a9a6ebcb949b63ae39352e23cc8b2f407d9d91ee Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Tue, 14 Apr 2026 08:05:55 +0000 Subject: [PATCH] Fix phpstan/phpstan#14464: Narrow list types when count() result is stored 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. --- src/Analyser/ExprHandler/AssignHandler.php | 33 +++++++ tests/PHPStan/Analyser/nsrt/bug-14464.php | 109 +++++++++++++++++++++ 2 files changed, 142 insertions(+) create mode 100644 tests/PHPStan/Analyser/nsrt/bug-14464.php diff --git a/src/Analyser/ExprHandler/AssignHandler.php b/src/Analyser/ExprHandler/AssignHandler.php index 51995ac6657..2b13bb7428a 100644 --- a/src/Analyser/ExprHandler/AssignHandler.php +++ b/src/Analyser/ExprHandler/AssignHandler.php @@ -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; @@ -73,6 +74,8 @@ use function in_array; use function is_int; use function is_string; +use function min; +use function strtolower; /** * @implements ExprHandler @@ -313,6 +316,36 @@ public function processAssignVar( $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()) { + $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()) { + 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) { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14464.php b/tests/PHPStan/Analyser/nsrt/bug-14464.php new file mode 100644 index 00000000000..197754bc51d --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14464.php @@ -0,0 +1,109 @@ +', $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 $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 $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 $list */ + public function sizeofIndirect(array $list): void + { + $n = sizeof($list); + if ($n === 2) { + assertType('array{string, string}', $list); + } + } + + /** @param list $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 $map + */ + public function nonListArray(array $map): void + { + $n = count($map); + if ($n === 2) { + assertType('non-empty-array', $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); + } + } +}