Skip to content

Commit 45d960e

Browse files
committed
Fix phpstan/phpstan#14336: Degrade list to array when assigning with arbitrary int offset
When assigning to a list-typed array variable with an arbitrary `int` offset (e.g., `$list[$int] = $value`), PHPStan incorrectly preserved the `list` type. Lists require sequential 0-based integer keys, so assigning with an arbitrary `int` (which may include negative values) should degrade the type to `array<int, T>`. Two changes fix the issue: 1. In AssignHandler::produceArrayDimFetchAssignValueToWrite, prevent setExistingOffsetValueType from being used for lists when the offset type is not guaranteed to be non-negative (int<0, max>). 2. In IntersectionType::setOffsetValueType, only re-add AccessoryArrayListType for list-of-arrays when the offset type is null (append) or non-negative.
1 parent cd904bb commit 45d960e

6 files changed

Lines changed: 214 additions & 4 deletions

File tree

src/Analyser/ExprHandler/AssignHandler.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -992,6 +992,7 @@ private function produceArrayDimFetchAssignValueToWrite(array $dimFetchStack, ar
992992
&& $arrayDimFetch !== null
993993
&& $scope->hasExpressionType($arrayDimFetch)->yes()
994994
&& !$offsetValueType->hasOffsetValueType($offsetType)->no()
995+
&& (!$offsetValueType->isList()->yes() || IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($offsetType)->yes())
995996
) {
996997
$hasOffsetType = null;
997998
if ($offsetType instanceof ConstantStringType || $offsetType instanceof ConstantIntegerType) {

src/Type/IntersectionType.php

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -981,7 +981,9 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $uni
981981
}
982982
}
983983

984-
if ($this->isList()->yes() && $this->getIterableValueType()->isArray()->yes()) {
984+
if ($this->isList()->yes() && $this->getIterableValueType()->isArray()->yes()
985+
&& ($offsetType === null || IntegerRangeType::fromInterval(0, null)->isSuperTypeOf($offsetType)->yes())
986+
) {
985987
$result = TypeCombinator::intersect($result, new AccessoryArrayListType());
986988
}
987989

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ protected function create_matrix(int $size): array
2222
$matrix[$size - 1][8] = 3;
2323

2424
// non-empty-array<int, non-empty-array<int, 0|3>&hasOffsetValue(8, 3)>
25-
assertType('non-empty-list<non-empty-array<int<0, max>, 0|3>>', $matrix);
25+
assertType('non-empty-array<int, non-empty-array<int<0, max>, 0|3>>', $matrix);
2626

2727
for ($i = 0; $i <= $size; $i++) {
2828
if ($matrix[$i][8] === 0) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ function test(array $xsdFiles, array $groupedByNamespace, array $extraNamespaces
3131
}
3232
}
3333
// After assigning with string keys ($viewHelper['name']), $xsdFiles[$xmlNamespace] should NOT be a list
34-
assertType('array<int<0, max>, array{xmlNamespace: string, namespace: string, name: string}>', $xsdFiles[$xmlNamespace]);
34+
assertType('array<int<0, max>|string, array{xmlNamespace: string, namespace: string, name: string}>', $xsdFiles[$xmlNamespace]);
3535
$xsdFiles[$xmlNamespace] = array_values($xsdFiles[$xmlNamespace]);
3636
}
3737
}

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -144,5 +144,5 @@ function ArrayKeyExistsKeepsList($needle): void {
144144
if (array_key_exists($needle, $list)) {
145145
$list[$needle] = 37;
146146
}
147-
assertType('list<int>', $list);
147+
assertType('array<int|(lowercase-string&numeric-string&uppercase-string), int>', $list);
148148
}
Lines changed: 207 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
<?php declare(strict_types = 1);
2+
3+
namespace Bug14336;
4+
5+
use function PHPStan\Testing\assertType;
6+
7+
/**
8+
* @param list<string> $list
9+
*/
10+
function test(array $list, int $int): void {
11+
$list[$int] = 'foo';
12+
assertType('non-empty-array<int, string>', $list);
13+
}
14+
15+
/**
16+
* @param array<string, list<array{xmlNamespace: string, namespace: string, name: string}>> $xsdFiles
17+
* @param array<string, list<array{xmlNamespace: string, namespace: string, name: string}>> $groupedByNamespace
18+
* @param array<string, list<string>> $extraNamespaces
19+
*/
20+
function test2(array $xsdFiles, array $groupedByNamespace, array $extraNamespaces, int $int): void {
21+
foreach ($extraNamespaces as $mergedNamespace) {
22+
if (count($mergedNamespace) < 2) {
23+
continue;
24+
}
25+
26+
$targetNamespace = end($mergedNamespace);
27+
if (!isset($groupedByNamespace[$targetNamespace])) {
28+
continue;
29+
}
30+
$xmlNamespace = $groupedByNamespace[$targetNamespace][0]['xmlNamespace'];
31+
32+
$xsdFiles[$xmlNamespace] = [];
33+
assertType('array{}', $xsdFiles[$xmlNamespace]);
34+
foreach ($mergedNamespace as $namespace) {
35+
foreach ($groupedByNamespace[$namespace] ?? [] as $viewHelper) {
36+
assertType('array{xmlNamespace: string, namespace: string, name: string}', $viewHelper);
37+
$xsdFiles[$xmlNamespace][$int] = $viewHelper;
38+
assertType('non-empty-array<int, array{xmlNamespace: string, namespace: string, name: string}>', $xsdFiles[$xmlNamespace]);
39+
}
40+
assertType('array<int, array{xmlNamespace: string, namespace: string, name: string}>', $xsdFiles[$xmlNamespace]);
41+
}
42+
assertType('array<int, array{xmlNamespace: string, namespace: string, name: string}>', $xsdFiles[$xmlNamespace]);
43+
}
44+
}
45+
46+
/**
47+
* @param list<string> $list
48+
*/
49+
function testInLoop(array $list, int $int): void {
50+
foreach ([1, 2, 3] as $item) {
51+
$list[$int] = 'foo';
52+
}
53+
assertType('non-empty-array<int, string>', $list);
54+
}
55+
56+
/**
57+
* @param array<string, list<string>> $map
58+
*/
59+
function testNestedDimFetchInLoop(array $map, string $key, int $int): void {
60+
$map[$key] = [];
61+
foreach ([1, 2, 3] as $item) {
62+
$map[$key][$int] = 'foo';
63+
}
64+
assertType('non-empty-array<int, string>', $map[$key]);
65+
}
66+
67+
/**
68+
* @param array<string, list<string>> $map
69+
* @param list<string> $items
70+
* @param list<string> $items2
71+
*/
72+
function testDoubleNestedForeachDimFetch(array $map, string $key, int $int, array $items, array $items2): void {
73+
$map[$key] = [];
74+
foreach ($items as $item) {
75+
foreach ($items2 as $item2) {
76+
$map[$key][$int] = $item2;
77+
}
78+
}
79+
assertType('array<int, string>', $map[$key]);
80+
}
81+
82+
/**
83+
* @param array<string, list<string>> $map
84+
* @param list<string> $items
85+
*/
86+
function testSingleVariableForeach(array $map, string $key, int $int, array $items): void {
87+
$map[$key] = [];
88+
foreach ($items as $item) {
89+
$map[$key][$int] = $item;
90+
}
91+
assertType('array<int, string>', $map[$key]);
92+
}
93+
94+
/**
95+
* @param array<string, list<string>> $map
96+
* @param list<string> $items
97+
* @param list<string> $outerItems
98+
*/
99+
function testOuterForeach(array $map, string $key, int $int, array $items, array $outerItems): void {
100+
foreach ($outerItems as $outerItem) {
101+
$map[$key] = [];
102+
foreach ($items as $item) {
103+
$map[$key][$int] = $item;
104+
}
105+
assertType('array<int, string>', $map[$key]);
106+
}
107+
}
108+
109+
/**
110+
* @param array<string, list<string>> $map
111+
* @param list<string> $items
112+
* @param list<string> $outerItems
113+
*/
114+
function testOuterForeachWithContinue(array $map, string $key, int $int, array $items, array $outerItems): void {
115+
foreach ($outerItems as $outerItem) {
116+
if (strlen($outerItem) < 2) {
117+
continue;
118+
}
119+
$map[$key] = [];
120+
foreach ($items as $item) {
121+
$map[$key][$int] = $item;
122+
}
123+
assertType('array<int, string>', $map[$key]);
124+
}
125+
}
126+
127+
/**
128+
* @param array<string, list<string>> $map
129+
* @param list<list<string>> $nestedItems
130+
* @param list<string> $outerItems
131+
*/
132+
function testNestedInnerForeach(array $map, string $key, int $int, array $nestedItems, array $outerItems): void {
133+
foreach ($outerItems as $outerItem) {
134+
if (strlen($outerItem) < 2) {
135+
continue;
136+
}
137+
$map[$key] = [];
138+
foreach ($nestedItems as $items) {
139+
foreach ($items as $item) {
140+
$map[$key][$int] = $item;
141+
}
142+
}
143+
assertType('array<int, string>', $map[$key]);
144+
}
145+
}
146+
147+
/**
148+
* @param array<string, list<string>> $map
149+
* @param array<string, list<string>> $nestedItems
150+
* @param list<string> $outerItems
151+
*/
152+
function testNestedInnerForeachNullCoalesce(array $map, string $key, int $int, array $nestedItems, array $outerItems): void {
153+
foreach ($outerItems as $outerItem) {
154+
if (strlen($outerItem) < 2) {
155+
continue;
156+
}
157+
$map[$key] = [];
158+
foreach ($outerItems as $ns) {
159+
foreach ($nestedItems[$ns] ?? [] as $item) {
160+
$map[$key][$int] = $item;
161+
}
162+
}
163+
assertType('array<int, string>', $map[$key]);
164+
}
165+
}
166+
167+
/**
168+
* @param array<string, list<array{ns: string, name: string}>> $map
169+
* @param array<string, list<array{ns: string, name: string}>> $grouped
170+
* @param array<string, list<string>> $extra
171+
*/
172+
function testCloseToOriginal(array $map, array $grouped, array $extra, int $int): void {
173+
foreach ($extra as $merged) {
174+
if (count($merged) < 2) {
175+
continue;
176+
}
177+
$target = end($merged);
178+
if (!isset($grouped[$target])) {
179+
continue;
180+
}
181+
$key = $grouped[$target][0]['ns'];
182+
183+
$map[$key] = [];
184+
foreach ($merged as $ns) {
185+
foreach ($grouped[$ns] ?? [] as $item) {
186+
$map[$key][$int] = $item;
187+
}
188+
}
189+
assertType('array<int, array{ns: string, name: string}>', $map[$key]);
190+
}
191+
}
192+
193+
/**
194+
* @param list<string> $list
195+
*/
196+
function testAppend(array $list): void {
197+
$list[] = 'foo';
198+
assertType('non-empty-list<string>', $list);
199+
}
200+
201+
/**
202+
* @param list<string> $list
203+
*/
204+
function testLiteralZero(array $list): void {
205+
$list[0] = 'foo';
206+
assertType("non-empty-list<string>&hasOffsetValue(0, 'foo')", $list);
207+
}

0 commit comments

Comments
 (0)