Skip to content

Commit 83af88c

Browse files
committed
Pre-compute count-specific conditional expressions to narrow list types 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
1 parent 03834ec commit 83af88c

3 files changed

Lines changed: 215 additions & 0 deletions

File tree

src/Analyser/ExprHandler/AssignHandler.php

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@
7373
use function in_array;
7474
use function is_int;
7575
use function is_string;
76+
use function strtolower;
7677

7778
/**
7879
* @implements ExprHandler<Assign|AssignRef>
@@ -81,6 +82,8 @@
8182
final class AssignHandler implements ExprHandler
8283
{
8384

85+
private const COUNT_CONDITIONAL_LIMIT = 8;
86+
8487
public function __construct(
8588
private TypeSpecifier $typeSpecifier,
8689
private PhpVersion $phpVersion,
@@ -313,6 +316,44 @@ 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($assignedExpr->name->toString()), ['count', 'sizeof'], true)
323+
&& count($assignedExpr->getArgs()) === 1
324+
&& !$type instanceof ConstantIntegerType
325+
) {
326+
$countArgType = $scope->getType($assignedExpr->getArgs()[0]->value);
327+
if ($countArgType->isArray()->yes() && ($countArgType->isList()->yes() || $countArgType->isConstantArray()->yes())) {
328+
for ($n = 1; $n <= self::COUNT_CONDITIONAL_LIMIT; $n++) {
329+
$nType = new ConstantIntegerType($n);
330+
$identicalExpr = new Expr\BinaryOp\Identical(
331+
$assignedExpr,
332+
new Node\Scalar\Int_($n),
333+
);
334+
$identicalSpecifiedTypes = $this->typeSpecifier->specifyTypesInCondition(
335+
$scope,
336+
$identicalExpr,
337+
TypeSpecifierContext::createTrue(),
338+
);
339+
$conditionalExpressions = $this->processSureTypesForConditionalExpressionsAfterAssign(
340+
$scope,
341+
$var->name,
342+
$conditionalExpressions,
343+
$identicalSpecifiedTypes,
344+
$nType,
345+
);
346+
$conditionalExpressions = $this->processSureNotTypesForConditionalExpressionsAfterAssign(
347+
$scope,
348+
$var->name,
349+
$conditionalExpressions,
350+
$identicalSpecifiedTypes,
351+
$nType,
352+
);
353+
}
354+
}
355+
}
356+
316357
$nodeScopeResolver->callNodeCallback($nodeCallback, new VariableAssignNode($var, $assignedExpr), $scopeBeforeAssignEval, $storage);
317358
$scope = $scope->assignVariable($var->name, $type, $scope->getNativeType($assignedExpr), TrinaryLogic::createYes());
318359
foreach ($conditionalExpressions as $exprString => $holders) {
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14464Analogous;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* sizeof() alias
9+
* @param list<int> $items
10+
*/
11+
function testSizeof(array $items): void {
12+
$count = sizeof($items);
13+
if ($count === 3) {
14+
assertType('array{int, int, int}', $items);
15+
}
16+
}
17+
18+
/**
19+
* Inline count still works
20+
* @param list<int> $items
21+
*/
22+
function testInlineCount(array $items): void {
23+
if (count($items) === 3) {
24+
assertType('array{int, int, int}', $items);
25+
}
26+
}
27+
28+
/**
29+
* explode() result
30+
*/
31+
function testExplode(string $input): void {
32+
$parts = explode(',', $input);
33+
$count = count($parts);
34+
if ($count === 3) {
35+
assertType('array{string, string, string}', $parts);
36+
} elseif ($count === 1) {
37+
assertType('array{string}', $parts);
38+
}
39+
}
40+
41+
/**
42+
* Variable count >= N (range comparison)
43+
* @param list<int> $items
44+
*/
45+
function testGreaterOrEqual(array $items): void {
46+
$count = count($items);
47+
if ($count >= 3) {
48+
assertType('non-empty-list<int>', $items);
49+
}
50+
}
51+
52+
/**
53+
* Count value > 8 (beyond pre-computed limit)
54+
* @param list<int> $items
55+
*/
56+
function testBeyondLimit(array $items): void {
57+
$count = count($items);
58+
if ($count === 10) {
59+
assertType('non-empty-list<int>', $items);
60+
}
61+
}
62+
63+
/**
64+
* Count with mode argument excluded from pre-computation
65+
* @param list<int> $items
66+
*/
67+
function testCountWithMode(array $items, int $mode): void {
68+
$count = count($items, $mode);
69+
if ($count === 3) {
70+
assertType('non-empty-list<int>', $items);
71+
}
72+
}
73+
74+
/**
75+
* Variable count on non-empty-list
76+
* @param non-empty-list<string> $items
77+
*/
78+
function testNonEmptyList(array $items): void {
79+
$count = count($items);
80+
if ($count === 2) {
81+
assertType('array{string, string}', $items);
82+
}
83+
}
84+
85+
/**
86+
* Variable count with switch statement
87+
* @param list<int> $items
88+
*/
89+
function testSwitch(array $items): void {
90+
$count = count($items);
91+
switch ($count) {
92+
case 1:
93+
assertType('array{int}', $items);
94+
break;
95+
case 2:
96+
assertType('array{int, int}', $items);
97+
break;
98+
case 3:
99+
assertType('array{int, int, int}', $items);
100+
break;
101+
}
102+
}
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14464;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
class HelloWorld
8+
{
9+
/** Variable count with == (loose comparison from the issue) */
10+
protected function columnOrAlias(string $columnName): void
11+
{
12+
$colParts = preg_split('/\s+/', $columnName, -1, \PREG_SPLIT_NO_EMPTY);
13+
if ($colParts === false) {
14+
throw new \RuntimeException('preg error');
15+
}
16+
assertType('list<non-empty-string>', $colParts);
17+
$numParts = count($colParts);
18+
19+
if ($numParts == 3) {
20+
assertType('array{non-empty-string, non-empty-string, non-empty-string}', $colParts);
21+
$this->columnName($colParts[0]);
22+
$this->columnName($colParts[1]);
23+
$this->columnName($colParts[2]);
24+
} elseif ($numParts == 2) {
25+
assertType('array{non-empty-string, non-empty-string}', $colParts);
26+
$this->columnName($colParts[0]);
27+
$this->columnName($colParts[1]);
28+
} elseif ($numParts == 1) {
29+
assertType('array{non-empty-string}', $colParts);
30+
$this->columnName($colParts[0]);
31+
} else {
32+
throw new \LogicException('invalid');
33+
}
34+
}
35+
36+
/** Variable count with === (strict comparison) */
37+
protected function strictComparison(string $input): void
38+
{
39+
$parts = preg_split('/,/', $input, -1, \PREG_SPLIT_NO_EMPTY);
40+
if ($parts === false) {
41+
throw new \RuntimeException('preg error');
42+
}
43+
$count = count($parts);
44+
45+
if ($count === 3) {
46+
assertType('array{non-empty-string, non-empty-string, non-empty-string}', $parts);
47+
} elseif ($count === 1) {
48+
assertType('array{non-empty-string}', $parts);
49+
}
50+
}
51+
52+
/**
53+
* Variable count on a PHPDoc list type
54+
* @param list<int> $items
55+
*/
56+
protected function phpdocList(array $items): void
57+
{
58+
$count = count($items);
59+
if ($count === 3) {
60+
assertType('array{int, int, int}', $items);
61+
} elseif ($count === 5) {
62+
assertType('array{int, int, int, int, int}', $items);
63+
} else {
64+
assertType('list<int>', $items);
65+
}
66+
}
67+
68+
public function columnName(string $columnName): string
69+
{
70+
return 'abc';
71+
}
72+
}

0 commit comments

Comments
 (0)