Skip to content

Commit 1cb72b1

Browse files
committed
Fix phpstan/phpstan#14215: Nonexistent offset detection on lists with count() - 1
- Extended TypeSpecifier to detect `count($list) - K` patterns in comparisons, not just plain `count($list)` - Adjusted sizeType computation to account for the subtraction value - List offset inference now works with both `$index < count($list) - K` and `$index <= count($list) - K` (when K >= 1) - Non-empty array narrowing threshold adjusted for subtraction - New regression test in tests/PHPStan/Rules/Arrays/data/bug-14215.php
1 parent 84639d0 commit 1cb72b1

File tree

3 files changed

+168
-14
lines changed

3 files changed

+168
-14
lines changed

src/Analyser/TypeSpecifier.php

Lines changed: 34 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -266,36 +266,56 @@ public function specifyTypesInCondition(
266266
$leftType = $scope->getType($expr->left);
267267
$result = (new SpecifiedTypes([], []))->setRootExpr($expr);
268268

269+
$countFuncCall = null;
270+
$subtraction = 0;
271+
269272
if (
270-
!$context->null()
271-
&& $expr->right instanceof FuncCall
273+
$expr->right instanceof FuncCall
272274
&& $expr->right->name instanceof Name
273275
&& in_array(strtolower((string) $expr->right->name), ['count', 'sizeof'], true)
274276
&& count($expr->right->getArgs()) >= 1
277+
) {
278+
$countFuncCall = $expr->right;
279+
} elseif (
280+
$expr->right instanceof Node\Expr\BinaryOp\Minus
281+
&& $expr->right->left instanceof FuncCall
282+
&& $expr->right->left->name instanceof Name
283+
&& in_array(strtolower((string) $expr->right->left->name), ['count', 'sizeof'], true)
284+
&& count($expr->right->left->getArgs()) >= 1
285+
&& $expr->right->right instanceof Node\Scalar\Int_
286+
&& $expr->right->right->value >= 1
287+
) {
288+
$countFuncCall = $expr->right->left;
289+
$subtraction = $expr->right->right->value;
290+
}
291+
292+
if (
293+
!$context->null()
294+
&& $countFuncCall !== null
275295
&& $leftType->isInteger()->yes()
276296
) {
277-
$argType = $scope->getType($expr->right->getArgs()[0]->value);
297+
$argType = $scope->getType($countFuncCall->getArgs()[0]->value);
278298

279299
if ($leftType instanceof ConstantIntegerType) {
280300
if ($orEqual) {
281-
$sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue());
301+
$sizeType = IntegerRangeType::createAllGreaterThanOrEqualTo($leftType->getValue() + $subtraction);
282302
} else {
283-
$sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue());
303+
$sizeType = IntegerRangeType::createAllGreaterThan($leftType->getValue() + $subtraction);
284304
}
285305
} elseif ($leftType instanceof IntegerRangeType) {
286-
$sizeType = $leftType->shift($offset);
306+
$sizeType = $leftType->shift($offset + $subtraction);
287307
} else {
288308
$sizeType = $leftType;
289309
}
290310

291-
$specifiedTypes = $this->specifyTypesForCountFuncCall($expr->right, $argType, $sizeType, $context, $scope, $expr);
311+
$specifiedTypes = $this->specifyTypesForCountFuncCall($countFuncCall, $argType, $sizeType, $context, $scope, $expr);
292312
if ($specifiedTypes !== null) {
293313
$result = $result->unionWith($specifiedTypes);
294314
}
295315

296316
if (
297-
$context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset)->isSuperTypeOf($leftType)->yes())
298-
|| ($context->false() && (new ConstantIntegerType(1 - $offset))->isSuperTypeOf($leftType)->yes())
317+
$context->true() && (IntegerRangeType::createAllGreaterThanOrEqualTo(1 - $offset - $subtraction)->isSuperTypeOf($leftType)->yes())
318+
|| ($context->false() && (new ConstantIntegerType(1 - $offset - $subtraction))->isSuperTypeOf($leftType)->yes())
299319
) {
300320
if ($context->truthy() && $argType->isArray()->maybe()) {
301321
$countables = [];
@@ -318,7 +338,7 @@ public function specifyTypesInCondition(
318338
if (count($countables) > 0) {
319339
$countableType = TypeCombinator::union(...$countables);
320340

321-
return $this->create($expr->right->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr);
341+
return $this->create($countFuncCall->getArgs()[0]->value, $countableType, $context, $scope)->setRootExpr($expr);
322342
}
323343
}
324344

@@ -329,21 +349,21 @@ public function specifyTypesInCondition(
329349
}
330350

331351
$result = $result->unionWith(
332-
$this->create($expr->right->getArgs()[0]->value, $newType, $context, $scope)->setRootExpr($expr),
352+
$this->create($countFuncCall->getArgs()[0]->value, $newType, $context, $scope)->setRootExpr($expr),
333353
);
334354
}
335355
}
336356

337-
// infer $list[$index] after $index < count($list)
357+
// infer $list[$index] after $index < count($list) or $index < count($list) - K
338358
if (
339359
$context->true()
340-
&& !$orEqual
360+
&& (!$orEqual || $subtraction >= 1)
341361
// constant offsets are handled via HasOffsetType/HasOffsetValueType
342362
&& !$leftType instanceof ConstantIntegerType
343363
&& $argType->isList()->yes()
344364
&& IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($leftType)->yes()
345365
) {
346-
$arrayArg = $expr->right->getArgs()[0]->value;
366+
$arrayArg = $countFuncCall->getArgs()[0]->value;
347367
$dimFetch = new ArrayDimFetch($arrayArg, $expr->left);
348368
$result = $result->unionWith(
349369
$this->create($dimFetch, $argType->getIterableValueType(), TypeSpecifierContext::createTrue(), $scope)->setRootExpr($expr),

tests/PHPStan/Rules/Arrays/NonexistentOffsetInArrayDimFetchRuleTest.php

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1189,4 +1189,16 @@ public function testBug13770(): void
11891189
]);
11901190
}
11911191

1192+
public function testBug14215(): void
1193+
{
1194+
$this->reportPossiblyNonexistentGeneralArrayOffset = true;
1195+
1196+
$this->analyse([__DIR__ . '/data/bug-14215.php'], [
1197+
[
1198+
'Offset int might not exist on list<int>.',
1199+
39,
1200+
],
1201+
]);
1202+
}
1203+
11921204
}
Lines changed: 122 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,122 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14215;
4+
5+
class HelloWorld
6+
{
7+
/**
8+
* @param list<int> $array
9+
* @param positive-int $index
10+
*/
11+
public function positiveIntLessThanCountMinusOne(array $array, int $index): int
12+
{
13+
if ($index < count($array) - 1) {
14+
return $array[$index]; // should not report
15+
}
16+
17+
return 0;
18+
}
19+
20+
/**
21+
* @param list<int> $array
22+
* @param positive-int $index
23+
*/
24+
public function positiveIntLessThanOrEqualCountMinusOne(array $array, int $index): int
25+
{
26+
if ($index <= count($array) - 1) {
27+
return $array[$index]; // should not report
28+
}
29+
30+
return 0;
31+
}
32+
33+
/**
34+
* @param list<int> $array
35+
*/
36+
public function intLessThanOrEqualCountMinusOne(array $array, int $index): int
37+
{
38+
if ($index <= count($array) - 1) {
39+
return $array[$index]; // should error report, could be negative int
40+
}
41+
42+
return 0;
43+
}
44+
45+
/**
46+
* @param list<int> $array
47+
* @param int<0, max> $index
48+
*/
49+
public function nonNegativeIntLessThanCountMinusOne(array $array, int $index): int
50+
{
51+
if ($index < count($array) - 1) {
52+
return $array[$index]; // should not report
53+
}
54+
55+
return 0;
56+
}
57+
58+
/**
59+
* @param list<int> $array
60+
* @param int<0, max> $index
61+
*/
62+
public function nonNegativeIntLessThanOrEqualCountMinusOne(array $array, int $index): int
63+
{
64+
if ($index <= count($array) - 1) {
65+
return $array[$index]; // should not report
66+
}
67+
68+
return 0;
69+
}
70+
71+
/**
72+
* @param list<int> $array
73+
* @param positive-int $index
74+
*/
75+
public function positiveIntLessThanCountMinusTwo(array $array, int $index): int
76+
{
77+
if ($index < count($array) - 2) {
78+
return $array[$index]; // should not report
79+
}
80+
81+
return 0;
82+
}
83+
84+
/**
85+
* @param list<int> $array
86+
* @param positive-int $index
87+
*/
88+
public function positiveIntLessThanOrEqualCountMinusTwo(array $array, int $index): int
89+
{
90+
if ($index <= count($array) - 2) {
91+
return $array[$index]; // should not report
92+
}
93+
94+
return 0;
95+
}
96+
97+
/**
98+
* @param list<int> $array
99+
* @param positive-int $index
100+
*/
101+
public function positiveIntLessThanSizeofMinusOne(array $array, int $index): int
102+
{
103+
if ($index < sizeof($array) - 1) {
104+
return $array[$index]; // should not report
105+
}
106+
107+
return 0;
108+
}
109+
110+
/**
111+
* @param list<int> $array
112+
* @param positive-int $index
113+
*/
114+
public function positiveIntGreaterThanCountMinusOneInversed(array $array, int $index): int
115+
{
116+
if (count($array) - 1 > $index) {
117+
return $array[$index]; // should not report
118+
}
119+
120+
return 0;
121+
}
122+
}

0 commit comments

Comments
 (0)