Skip to content

Commit 67e37ac

Browse files
phpstan-botVincentLangletclaude
authored
Fix phpstan/phpstan#13705: Adding elements to empty array in while loop does not result in array being recognized as potentially non-empty for in_array (#5419)
Co-authored-by: VincentLanglet <9052536+VincentLanglet@users.noreply.github.com> Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 7ebe2f2 commit 67e37ac

File tree

6 files changed

+191
-12
lines changed

6 files changed

+191
-12
lines changed

src/Analyser/TypeSpecifier.php

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -280,21 +280,36 @@ public function specifyTypesInCondition(
280280
) {
281281
$argType = $scope->getType($expr->right->getArgs()[0]->value);
282282

283+
$sizeType = null;
283284
if ($leftType instanceof ConstantIntegerType) {
284285
if ($orEqual) {
285286
$sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue());
286287
} else {
287288
$sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue());
288289
}
289290
} elseif ($leftType instanceof IntegerRangeType) {
290-
$sizeType = $leftType->shift($offset);
291+
if ($context->falsey() && $leftType->getMax() !== null) {
292+
if ($orEqual) {
293+
$sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMax());
294+
} else {
295+
$sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMax());
296+
}
297+
} elseif ($context->truthy() && $leftType->getMin() !== null) {
298+
if ($orEqual) {
299+
$sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getMin());
300+
} else {
301+
$sizeType = IntegerRangeType::createAllGreaterThan($leftType->getMin());
302+
}
303+
}
291304
} else {
292305
$sizeType = $leftType;
293306
}
294307

295-
$specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr);
296-
if ($specifiedTypes !== null) {
297-
$result = $result->unionWith($specifiedTypes);
308+
if ($sizeType !== null) {
309+
$specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr);
310+
if ($specifiedTypes !== null) {
311+
$result = $result->unionWith($specifiedTypes);
312+
}
298313
}
299314

300315
if (
Lines changed: 158 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,158 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug13705;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
function whileLoop(): void
8+
{
9+
$quantity = random_int(1, 42);
10+
$codes = [];
11+
while (count($codes) < $quantity) {
12+
assertType('list<non-empty-string>', $codes);
13+
$code = random_bytes(16);
14+
if (!in_array($code, $codes, true)) {
15+
$codes[] = $code;
16+
}
17+
}
18+
}
19+
20+
/**
21+
* @param list<string> $arr
22+
* @param int<2, 5> $boundedRange
23+
* @param int<2, max> $unboundedMaxRange
24+
* @param int<min, 5> $unboundedMinRange
25+
*/
26+
function countLessThanRange(array $arr, int $boundedRange, int $unboundedMaxRange, int $unboundedMinRange): void
27+
{
28+
// count($arr) < $range → inverted to NOT($range <= count($arr))
29+
// Inner: orEqual=true, false context → falsey + max !== null + orEqual (branch 1)
30+
// Else: orEqual=true, true context → truthy + min !== null + orEqual (branch 3)
31+
if (count($arr) < $boundedRange) {
32+
assertType('list<string>', $arr);
33+
} else {
34+
assertType('non-empty-list<string>&hasOffsetValue(1, string)', $arr);
35+
}
36+
37+
// count($arr) < unbounded max range → falsey + max is null → fallback via min (branch 3/4)
38+
if (count($arr) < $unboundedMaxRange) {
39+
assertType('list<string>', $arr);
40+
} else {
41+
assertType('non-empty-list<string>&hasOffsetValue(1, string)', $arr);
42+
}
43+
44+
// count($arr) < unbounded min range → fallback branch (min is null)
45+
if (count($arr) < $unboundedMinRange) {
46+
assertType('list<string>', $arr);
47+
} else {
48+
assertType('list<string>', $arr);
49+
}
50+
}
51+
52+
/**
53+
* @param list<string> $arr
54+
* @param int<2, 5> $boundedRange
55+
*/
56+
function countLessThanOrEqualRange(array $arr, int $boundedRange): void
57+
{
58+
// count($arr) <= $range → inverted to NOT($range < count($arr))
59+
// Inner: orEqual=false, false context → falsey + max !== null + !orEqual (branch 2)
60+
// Else: orEqual=false, true context → truthy + min !== null + !orEqual (branch 4)
61+
if (count($arr) <= $boundedRange) {
62+
assertType('list<string>', $arr);
63+
} else {
64+
assertType('non-empty-list<string>&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr);
65+
}
66+
}
67+
68+
/**
69+
* @param list<string> $arr
70+
* @param int<2, 5> $boundedRange
71+
*/
72+
function rangeGreaterThanOrEqualCount(array $arr, int $boundedRange): void
73+
{
74+
// $range >= count($arr) → same as count($arr) <= $range
75+
if ($boundedRange >= count($arr)) {
76+
assertType('list<string>', $arr);
77+
} else {
78+
assertType('non-empty-list<string>&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr);
79+
}
80+
}
81+
82+
/**
83+
* @param list<string> $arr
84+
* @param int<2, 5> $boundedRange
85+
*/
86+
function rangeLessThanOrEqualCount(array $arr, int $boundedRange): void
87+
{
88+
// $range <= count($arr) → direct, orEqual=true
89+
// True context: truthy + orEqual + min !== null (branch 3)
90+
// False context: falsey + orEqual + max !== null (branch 1)
91+
if ($boundedRange <= count($arr)) {
92+
assertType('non-empty-list<string>&hasOffsetValue(1, string)', $arr);
93+
} else {
94+
assertType('list<string>', $arr);
95+
}
96+
}
97+
98+
/**
99+
* @param list<string> $arr
100+
* @param int<2, 5> $boundedRange
101+
*/
102+
function rangeLessThanCount(array $arr, int $boundedRange): void
103+
{
104+
// $range < count($arr) → direct, orEqual=false
105+
// True context: truthy + !orEqual + min !== null (branch 4)
106+
// False context: falsey + !orEqual + max !== null (branch 2)
107+
if ($boundedRange < count($arr)) {
108+
assertType('non-empty-list<string>&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $arr);
109+
} else {
110+
assertType('list<string>', $arr);
111+
}
112+
}
113+
114+
function whileLoopOriginal(int $length, int $quantity): void
115+
{
116+
if ($length < 8) {
117+
throw new \InvalidArgumentException();
118+
}
119+
$codes = [];
120+
while ($quantity >= 1 && count($codes) < $quantity) {
121+
$code = '';
122+
for ($i = 0; $i < $length; $i++) {
123+
$code .= 'x';
124+
}
125+
if (!in_array($code, $codes, true)) {
126+
$codes[] = $code;
127+
}
128+
}
129+
}
130+
131+
class HelloWorld
132+
{
133+
private const MIN_LENGTH = 8;
134+
135+
/**
136+
* @return list<non-empty-string>
137+
*/
138+
public function generatePlainRecoveryCodes(int $length = 8, int $quantity = 8): array
139+
{
140+
if ($length < self::MIN_LENGTH) {
141+
throw new \InvalidArgumentException(
142+
$length . ' is not allowed as length for recovery codes. Must be at least ' . self::MIN_LENGTH,
143+
1613666803
144+
);
145+
}
146+
$codes = [];
147+
while ($quantity >= 1 && count($codes) < $quantity) {
148+
$code = '';
149+
for ($i = 0; $i < $length; $i++) {
150+
$code .= 'x';
151+
}
152+
if (!in_array($code, $codes, true)) {
153+
$codes[] = $code;
154+
}
155+
}
156+
return $codes;
157+
}
158+
}

tests/PHPStan/Analyser/nsrt/bug-4700.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,8 @@ function(array $array, int $count): void {
2121
assertType('int<1, 5>', count($a));
2222
assertType('list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a);
2323
} else {
24-
assertType('0', count($a));
25-
assertType('array{}', $a);
24+
assertType('int<0, 5>', count($a));
25+
assertType('array{}|list{0: mixed~null, 1?: mixed~null, 2?: mixed~null, 3?: mixed~null, 4?: mixed~null}', $a);
2626
}
2727
};
2828

tests/PHPStan/Analyser/nsrt/bug11480.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -106,7 +106,7 @@ public function intRangeCount($count): void
106106
if (count($x) >= $count) {
107107
assertType("array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x);
108108
} else {
109-
assertType("array{}", $x);
109+
assertType("array{}|array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x);
110110
}
111111
assertType("array{}|array{'xy'}|array{0: 'ab', 1?: 'xy'}", $x);
112112
}

tests/PHPStan/Analyser/nsrt/list-count.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ protected function testOptionalKeysInUnionArray($row): void
352352
protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoOrThree, $twoOrMore, int $maxThree, $tenOrEleven, $threeOrMoreInRangeLimit, $threeOrMoreOverRangeLimit): void
353353
{
354354
if (count($row) >= $twoOrThree) {
355-
assertType('array{0: int, 1: string|null, 2?: int|null}', $row);
355+
assertType('list{0: int, 1: string|null, 2?: int|null, 3?: float|null}', $row);
356356
} else {
357357
assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row);
358358
}
@@ -376,25 +376,25 @@ protected function testOptionalKeysInUnionListWithIntRange($row, $listRow, $twoO
376376
}
377377

378378
if (count($row) >= $threeOrMoreInRangeLimit) {
379-
assertType('list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row);
379+
assertType('array{0: int, 1: string|null, 2: int|null, 3?: float|null}', $row);
380380
} else {
381381
assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row);
382382
}
383383

384384
if (count($listRow) >= $threeOrMoreInRangeLimit) {
385-
assertType('list{0: string, 1: string, 2: string, 3?: string, 4?: string, 5?: string, 6?: string, 7?: string, 8?: string, 9?: string, 10?: string, 11?: string, 12?: string, 13?: string, 14?: string, 15?: string, 16?: string, 17?: string, 18?: string, 19?: string, 20?: string, 21?: string, 22?: string, 23?: string, 24?: string, 25?: string, 26?: string, 27?: string, 28?: string, 29?: string, 30?: string, 31?: string}', $listRow);
385+
assertType('non-empty-list<string>&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $listRow);
386386
} else {
387387
assertType('list<string>', $listRow);
388388
}
389389

390390
if (count($row) >= $threeOrMoreOverRangeLimit) {
391-
assertType('list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row);
391+
assertType('array{0: int, 1: string|null, 2: int|null, 3?: float|null}', $row);
392392
} else {
393393
assertType('array{string}|list{0: int, 1?: string|null, 2?: int|null, 3?: float|null}', $row);
394394
}
395395

396396
if (count($listRow) >= $threeOrMoreOverRangeLimit) {
397-
assertType('non-empty-list<string>', $listRow);
397+
assertType('non-empty-list<string>&hasOffsetValue(1, string)&hasOffsetValue(2, string)', $listRow);
398398
} else {
399399
assertType('list<string>', $listRow);
400400
}

tests/PHPStan/Rules/Comparison/ImpossibleCheckTypeFunctionCallRuleTest.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1202,6 +1202,12 @@ public function testBug14429(): void
12021202
$this->analyse([__DIR__ . '/data/bug-14429.php'], []);
12031203
}
12041204

1205+
public function testBug13705(): void
1206+
{
1207+
$this->treatPhpDocTypesAsCertain = true;
1208+
$this->analyse([__DIR__ . '/../../Analyser/nsrt/bug-13705.php'], []);
1209+
}
1210+
12051211
public function testBug13799(): void
12061212
{
12071213
$this->treatPhpDocTypesAsCertain = true;

0 commit comments

Comments
 (0)