Skip to content

Commit a9a6ebc

Browse files
committed
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.
1 parent 03834ec commit a9a6ebc

2 files changed

Lines changed: 142 additions & 0 deletions

File tree

src/Analyser/ExprHandler/AssignHandler.php

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
use PHPStan\Type\Accessory\HasOffsetValueType;
5252
use PHPStan\Type\Accessory\NonEmptyArrayType;
5353
use PHPStan\Type\Constant\ConstantArrayType;
54+
use PHPStan\Type\Constant\ConstantArrayTypeBuilder;
5455
use PHPStan\Type\Constant\ConstantIntegerType;
5556
use PHPStan\Type\Constant\ConstantStringType;
5657
use PHPStan\Type\ConstantTypeHelper;
@@ -73,6 +74,8 @@
7374
use function in_array;
7475
use function is_int;
7576
use function is_string;
77+
use function min;
78+
use function strtolower;
7679

7780
/**
7881
* @implements ExprHandler<Assign|AssignRef>
@@ -313,6 +316,36 @@ public function processAssignVar(
313316
$conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $falseyType);
314317
}
315318

319+
if (
320+
$assignedExpr instanceof FuncCall
321+
&& $assignedExpr->name instanceof Name
322+
&& in_array(strtolower((string) $assignedExpr->name), ['count', 'sizeof'], true)
323+
&& count($assignedExpr->getArgs()) >= 1
324+
) {
325+
$countArgType = $scope->getType($assignedExpr->getArgs()[0]->value);
326+
if ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes()) {
327+
$arraySize = $countArgType->getArraySize();
328+
$maxSize = ConstantArrayTypeBuilder::ARRAY_COUNT_LIMIT;
329+
if ($arraySize instanceof ConstantIntegerType) {
330+
$maxSize = $arraySize->getValue();
331+
} elseif ($arraySize instanceof IntegerRangeType && $arraySize->getMax() !== null) {
332+
$maxSize = min($maxSize, $arraySize->getMax());
333+
}
334+
335+
for ($i = 1; $i <= $maxSize; $i++) {
336+
$sizeType = new ConstantIntegerType($i);
337+
if (!$type->isSuperTypeOf($sizeType)->yes()) {
338+
continue;
339+
}
340+
341+
$identicalConditionExpr = new Expr\BinaryOp\Identical($assignedExpr, new Node\Scalar\Int_($i));
342+
$identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition($scope, $identicalConditionExpr, TypeSpecifierContext::createTrue());
343+
$conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $sizeType);
344+
$conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign($scope, $var->name, $conditionalExpressions, $identicalSpecifiedTypes, $sizeType);
345+
}
346+
}
347+
}
348+
316349
$nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage);
317350
$scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes());
318351
foreach ($conditionalExpressions as $exprString => $holders) {
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14464;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class HelloWorld
8+
{
9+
protected function columnOrAlias(string $columnName): void
10+
{
11+
$colParts = preg_split('/\s+/', $columnName, -1, \PREG_SPLIT_NO_EMPTY);
12+
if ($colParts === false) {
13+
throw new \RuntimeException('preg error');
14+
}
15+
assertType('list<non-empty-string>', $colParts);
16+
$numParts = count($colParts);
17+
18+
if ($numParts == 3) {
19+
assertType('array{non-empty-string, non-empty-string, non-empty-string}', $colParts);
20+
} elseif ($numParts == 2) {
21+
assertType('array{non-empty-string, non-empty-string}', $colParts);
22+
} elseif ($numParts == 1) {
23+
assertType('array{non-empty-string}', $colParts);
24+
}
25+
}
26+
27+
/** @param list<string> $list */
28+
public function indirectCountCheck(array $list): void
29+
{
30+
$n = count($list);
31+
if ($n === 3) {
32+
assertType('array{string, string, string}', $list);
33+
}
34+
if ($n === 2) {
35+
assertType('array{string, string}', $list);
36+
}
37+
if ($n === 1) {
38+
assertType('array{string}', $list);
39+
}
40+
}
41+
42+
/** @param list<string> $list */
43+
public function directCountCheck(array $list): void
44+
{
45+
if (count($list) === 3) {
46+
assertType('array{string, string, string}', $list);
47+
}
48+
if (count($list) === 2) {
49+
assertType('array{string, string}', $list);
50+
}
51+
if (count($list) === 1) {
52+
assertType('array{string}', $list);
53+
}
54+
}
55+
56+
/** @param list<string> $list */
57+
public function sizeofIndirect(array $list): void
58+
{
59+
$n = sizeof($list);
60+
if ($n === 2) {
61+
assertType('array{string, string}', $list);
62+
}
63+
}
64+
65+
/** @param list<int> $list */
66+
public function looseEqualityCheck(array $list): void
67+
{
68+
$n = count($list);
69+
if ($n == 3) {
70+
assertType('array{int, int, int}', $list);
71+
}
72+
}
73+
74+
/**
75+
* Non-list arrays should not get specific shapes since keys are unknown
76+
* @param array<string, int> $map
77+
*/
78+
public function nonListArray(array $map): void
79+
{
80+
$n = count($map);
81+
if ($n === 2) {
82+
assertType('non-empty-array<string, int>', $map);
83+
}
84+
}
85+
86+
/** @param array{string}|array{string, string}|array{string, string, string} $list */
87+
public function constantArrayUnionIndirect(array $list): void
88+
{
89+
$n = count($list);
90+
if ($n === 2) {
91+
assertType('array{string, string}', $list);
92+
}
93+
if ($n === 3) {
94+
assertType('array{string, string, string}', $list);
95+
}
96+
}
97+
98+
/** @param array{a: string, b: int}|array{x: float, y: float, z: float} $map */
99+
public function constantNonListDifferentShapes(array $map): void
100+
{
101+
$n = count($map);
102+
if ($n === 2) {
103+
assertType('array{a: string, b: int}', $map);
104+
}
105+
if ($n === 3) {
106+
assertType('array{x: float, y: float, z: float}', $map);
107+
}
108+
}
109+
}

0 commit comments

Comments
 (0)