Skip to content

Commit cab38fc

Browse files
committed
Preserve array shape and make it unsealed when non-constant key is assigned
1 parent c10aad0 commit cab38fc

7 files changed

Lines changed: 142 additions & 50 deletions

File tree

src/Type/Constant/ConstantArrayTypeBuilder.php

Lines changed: 78 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -287,9 +287,8 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt
287287
}
288288
}
289289
if (count($scalarTypes) > 0 && count($scalarTypes) < self::ARRAY_COUNT_LIMIT) {
290-
$match = true;
291-
$hasMatch = false;
292290
$valueTypes = $this->valueTypes;
291+
$unmatchedScalars = [];
293292
foreach ($scalarTypes as $scalarType) {
294293
$offsetMatch = false;
295294

@@ -308,61 +307,97 @@ public function setOffsetValueType(?Type $offsetType, Type $valueType, bool $opt
308307
}
309308

310309
if ($offsetMatch) {
311-
$hasMatch = true;
312310
continue;
313311
}
314312

315-
$match = false;
313+
$unmatchedScalars[] = $scalarType;
316314
}
317315

318-
if ($match) {
319-
$this->valueTypes = $valueTypes;
316+
$this->valueTypes = $valueTypes;
317+
318+
if (count($unmatchedScalars) === 0) {
320319
return;
321320
}
322321

323-
if (!$hasMatch) {
324-
foreach ($scalarTypes as $scalarType) {
325-
$this->keyTypes[] = $scalarType;
326-
$this->valueTypes[] = $valueType;
327-
$this->optionalKeys[] = count($this->keyTypes) - 1;
322+
foreach ($unmatchedScalars as $scalarType) {
323+
$this->keyTypes[] = $scalarType;
324+
$this->valueTypes[] = $valueType;
325+
$this->optionalKeys[] = count($this->keyTypes) - 1;
328326

329-
if (!($scalarType instanceof ConstantIntegerType)) {
330-
continue;
331-
}
327+
if (!($scalarType instanceof ConstantIntegerType)) {
328+
continue;
329+
}
332330

333-
if (count($this->nextAutoIndexes) === 0) {
334-
continue;
335-
}
331+
if (count($this->nextAutoIndexes) === 0) {
332+
continue;
333+
}
336334

337-
$max = max($this->nextAutoIndexes);
338-
$offsetValue = $scalarType->getValue();
339-
if ($offsetValue < $max) {
340-
continue;
341-
}
335+
$max = max($this->nextAutoIndexes);
336+
$offsetValue = $scalarType->getValue();
337+
if ($offsetValue < $max) {
338+
continue;
339+
}
342340

343-
/** @var int|float $newAutoIndex */
344-
$newAutoIndex = $offsetValue + 1;
345-
if (is_float($newAutoIndex)) {
346-
continue;
347-
}
348-
$this->nextAutoIndexes[] = $newAutoIndex;
341+
/** @var int|float $newAutoIndex */
342+
$newAutoIndex = $offsetValue + 1;
343+
if (is_float($newAutoIndex)) {
344+
continue;
349345
}
346+
$this->nextAutoIndexes[] = $newAutoIndex;
347+
}
350348

351-
$this->isList = TrinaryLogic::createNo();
349+
$this->isList = TrinaryLogic::createNo();
350+
351+
if (
352+
!$this->disableArrayDegradation
353+
&& count($this->keyTypes) > self::ARRAY_COUNT_LIMIT
354+
) {
355+
$this->degradeToGeneralArray = true;
356+
$this->oversized = true;
357+
}
352358

353-
if (
354-
!$this->disableArrayDegradation
355-
&& count($this->keyTypes) > self::ARRAY_COUNT_LIMIT
356-
) {
357-
$this->degradeToGeneralArray = true;
358-
$this->oversized = true;
359+
return;
360+
}
361+
362+
$this->isList = TrinaryLogic::createNo();
363+
364+
// If the builder is already unsealed (e.g. fresh bleeding-edge
365+
// builder, or a PHPDoc shape like `array{a: int, ...<int, T>}`),
366+
// fold the unknown offset/value into the existing unsealed
367+
// extras instead of dropping per-key precision by degrading to a
368+
// general array. The actual decision between unsealed
369+
// ConstantArrayType and general ArrayType is then made in
370+
// getArray() based on whether any constant keys ended up
371+
// alongside these extras.
372+
if ($this->unsealed !== null) {
373+
// Existing keys whose value the new offset could overwrite
374+
// must widen to a union of (existing, new) — the assignment
375+
// might or might not have hit them.
376+
$residualOffset = $offsetType;
377+
foreach ($this->keyTypes as $i => $keyType) {
378+
if ($offsetType->isSuperTypeOf($keyType)->no()) {
379+
continue;
359380
}
381+
$this->valueTypes[$i] = TypeCombinator::union($this->valueTypes[$i], $valueType);
382+
$residualOffset = TypeCombinator::remove($residualOffset, $keyType);
383+
}
360384

385+
if ($residualOffset instanceof NeverType) {
361386
return;
362387
}
363-
}
364388

365-
$this->isList = TrinaryLogic::createNo();
389+
[$existingKey, $existingValue] = $this->unsealed;
390+
$isExplicitNever = $existingKey instanceof NeverType && $existingKey->isExplicit();
391+
if ($isExplicitNever) {
392+
$this->unsealed = [$residualOffset, $valueType];
393+
} else {
394+
$this->unsealed = [
395+
TypeCombinator::union($existingKey, $residualOffset),
396+
TypeCombinator::union($existingValue, $valueType),
397+
];
398+
}
399+
return;
400+
}
366401
}
367402

368403
if ($offsetType === null) {
@@ -409,7 +444,11 @@ public function getArray(): Type
409444
[$unsealedKey, $unsealedValue] = $this->unsealed;
410445
$isExplicitNever = $unsealedKey instanceof NeverType && $unsealedKey->isExplicit();
411446
if (!$isExplicitNever) {
412-
return new ArrayType($unsealedKey, $unsealedValue);
447+
$arrayType = new ArrayType($unsealedKey, $unsealedValue);
448+
if ($this->isNonEmpty->yes()) {
449+
return TypeCombinator::intersect($arrayType, new NonEmptyArrayType());
450+
}
451+
return $arrayType;
413452
}
414453
}
415454
return new ConstantArrayType([], [], unsealed: $this->unsealed);
@@ -418,7 +457,7 @@ public function getArray(): Type
418457
if (!$this->degradeToGeneralArray) {
419458
/** @var list<ConstantIntegerType|ConstantStringType> $keyTypes */
420459
$keyTypes = $this->keyTypes;
421-
$array = new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, , $this->unsealed);
460+
$array = new ConstantArrayType($keyTypes, $this->valueTypes, $this->nextAutoIndexes, $this->optionalKeys, $this->isList, $this->unsealed);
422461
if ($this->isNonEmpty->yes() && !$array->isIterableAtLeastOnce()->yes()) {
423462
return TypeCombinator::intersect($array, new NonEmptyArrayType());
424463
}

tests/PHPStan/Analyser/AnalyserIntegrationTest.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -839,7 +839,7 @@ public function testBug7094(): void
839839
$this->assertSame('Return type of call to method Bug7094\Foo::getAttribute() contains unresolvable type.', $errors[4]->getMessage());
840840
$this->assertSame(79, $errors[4]->getLine());
841841

842-
$this->assertSame('Parameter #1 $attr of method Bug7094\Foo::setAttributes() expects array{foo?: string, bar?: 5|6|7, baz?: bool}, non-empty-array<\'bar\'|\'baz\'|\'foo\'|K of string, 5|6|7|bool|string> given.', $errors[5]->getMessage());
842+
$this->assertSame('Parameter #1 $attr of method Bug7094\Foo::setAttributes() expects array{foo?: string, bar?: 5|6|7, baz?: bool}, array{foo?: string, bar?: 5|6|7, baz?: bool, ...<K of string, 5|6|7|bool|string>} given.', $errors[5]->getMessage());
843843
$this->assertSame(29, $errors[5]->getLine());
844844
}
845845

tests/PHPStan/Analyser/nsrt/array-keys-branches.php

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,7 @@ function (array $generalArray) {
5858
assertType('mixed~null', $generalArray['key']);
5959
assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf);
6060
assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach);
61-
assertType('non-empty-array<int<0, max>, literal-string&lowercase-string&non-falsy-string>', $anotherArrayAppendedInForeach);
61+
assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ...<int<2, max>, 'baz'>}", $anotherArrayAppendedInForeach);
6262
assertType('\'str\'', $array['n']);
6363
assertType('int<0, max>', $incremented);
6464
assertType('0|1', $setFromZeroToOne);
@@ -124,7 +124,7 @@ function (array $generalArray, array $xs) {
124124
assertType('mixed~null', $generalArray['key']);
125125
assertType('array{0: \'foo\', 1: \'bar\', 2?: \'baz\'}', $arrayAppendedInIf);
126126
assertType('non-empty-list<\'bar\'|\'baz\'|\'foo\'>', $arrayAppendedInForeach);
127-
assertType('non-empty-array<int<0, max>, literal-string&lowercase-string&non-falsy-string>', $anotherArrayAppendedInForeach);
127+
assertType("array{literal-string&lowercase-string&non-falsy-string, literal-string&lowercase-string&non-falsy-string, ...<int<0, max>, 'baz'>}", $anotherArrayAppendedInForeach);
128128
assertType('\'str\'', $array['n']);
129129
assertType('int<0, max>', $incremented);
130130
assertType('0|1', $setFromZeroToOne);

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

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,8 @@ function testNonConstantKeyBreaksImplicitIndex(int $key): void
4545
// Since $key is non-constant, we don't know the implicit indices of &$a and &$c
4646
// so we can't correctly track the reference propagation
4747
$b[2] = 2;
48-
assertType("1|2|'test'|'x'", $a); // Could be 1|2
49-
assertType("1|2|'test'|'x'", $c); // Could be 'test'|2
48+
assertType("1|2|'test'", $a); // Could be 1|2
49+
assertType("1|2|'test'", $c); // Could be 'test'|2
5050
}
5151

5252
function testNested(): void

tests/PHPStan/Analyser/nsrt/constant-array-type-set.php

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ public function doFoo(int $i)
1111
{
1212
$a = [1, 2, 3];
1313
$a[$i] = 4;
14-
assertType('non-empty-array<int, 1|2|3|4>', $a);
14+
assertType('array{1|4, 2|4, 3|4, ...<int<min, -1>|int<3, max>, 4>}', $a);
1515

1616
$b = [1, 2, 3];
1717
$b[3] = 4;
@@ -33,7 +33,7 @@ public function doFoo(int $i)
3333
/** @var 0|1|2|3 $offset3 */
3434
$offset3 = doFoo();
3535
$e[$offset3] = true;
36-
assertType('non-empty-array<0|1|2|3, bool>', $e);
36+
assertType('array{0: bool, 1: bool, 2: bool, 3?: true}', $e);
3737

3838
$f = [false, false, false];
3939
/** @var 0|1 $offset4 */
@@ -72,7 +72,7 @@ public function doBar3(int $offset): void
7272
{
7373
$a = [false, false, false, false, false];
7474
$a[$offset] = true;
75-
assertType('non-empty-array<int<0, max>, bool>', $a);
75+
assertType('array{bool, bool, bool, bool, bool, ...<int<5, max>, true>}', $a);
7676
}
7777

7878
/**
@@ -83,7 +83,7 @@ public function doBar4(int $offset): void
8383
{
8484
$a = [false, false, false, false, false];
8585
$a[$offset] = true;
86-
assertType('non-empty-array<int<min, 4>, bool>', $a);
86+
assertType('array{bool, false, false, false, false, ...<int<min, -1>, true>}', $a);
8787
}
8888

8989
/**
@@ -94,7 +94,7 @@ public function doBar5(int $offset): void
9494
{
9595
$a = [false, false, false];
9696
$a[$offset] = true;
97-
assertType('non-empty-array<int<0, 4>, bool>', $a);
97+
assertType('array{0: bool, 1: bool, 2: bool, 3?: true, 4?: true}', $a);
9898
}
9999

100100
public function doBar6(bool $offset): void

tests/PHPStan/Analyser/nsrt/pr-4390.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,6 @@ function (string $s): void {
1313
}
1414
}
1515

16-
assertType('non-empty-array<int<0, 9>, non-empty-array<int<0, 9>, string>>', $locations);
16+
assertType('non-empty-list<non-empty-array<int<0, 9>, string>>', $locations);
1717
assertType('non-empty-array<int<0, 9>, string>', $locations[0]);
1818
};

tests/PHPStan/Analyser/nsrt/unsealed-array-shapes.php

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -83,6 +83,59 @@ public function edgeCases(array $a, array $b, array $c): void
8383
assertType('array{a: int, b: float|string, c?: string}', $c);
8484
}
8585

86+
/**
87+
* @param array<int, string> $a
88+
* @param array<string, string> $b
89+
* @param array<string, string> $c
90+
* @return void
91+
*/
92+
public function generalArray(array $a, array $b, array $c): void
93+
{
94+
$a[1] = 'foo';
95+
assertType("non-empty-array<int, string>&hasOffsetValue(1, 'foo')", $a);
96+
97+
$b[1] = 'foo';
98+
assertType("non-empty-array<1|string, string>&hasOffsetValue(1, 'foo')", $b);
99+
100+
$c['foo'] = 1;
101+
assertType("non-empty-array<string, 1|string>&hasOffsetValue('foo', 1)", $c);
102+
}
103+
104+
public function sealedBecomesUnsealed(string $s, int $i): void
105+
{
106+
$a = [];
107+
$a[] = 5;
108+
assertType('array{5}', $a);
109+
$a[$s] = 6;
110+
assertType('array{5, ...<string, 6>}', $a);
111+
$a[$i] = 7;
112+
assertType('array{5|7, ...<int<min,-1>|int<1,max>|string, 6|7>}', $a);
113+
114+
$b = [];
115+
$b[$s] = 1;
116+
assertType('non-empty-array<string, 1>', $b);
117+
118+
$b[$i] = 2;
119+
assertType('non-empty-array<int|string, 1|2>', $b);
120+
121+
$c = [
122+
1 => 'foo',
123+
$s => 'bar',
124+
];
125+
assertType("array{1: 'foo', ...<string, 'bar'>}", $c);
126+
127+
$d = [
128+
$s => 'foo',
129+
1 => 'bar',
130+
];
131+
assertType("array{1: 'bar', ...<string, 'foo'>}", $d);
132+
133+
$e = [
134+
$s => 'foo',
135+
];
136+
assertType('non-empty-array<string, \'foo\'>', $e);
137+
}
138+
86139
}
87140

88141
class Generics

0 commit comments

Comments
 (0)