Skip to content

Commit 369bfdf

Browse files
phpstan-botclaudestaabm
authored
Preserve constant array shape when spreading a union of constant arrays in array literals (#5774)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: Markus Staab <maggus.staab@googlemail.com>
1 parent c24bcf4 commit 369bfdf

3 files changed

Lines changed: 187 additions & 17 deletions

File tree

src/Reflection/InitializerExprTypeResolver.php

Lines changed: 61 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -649,27 +649,71 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type
649649
$valueType = $getTypeCallback($arrayItem->value);
650650
if ($arrayItem->unpack) {
651651
$constantArrays = $valueType->getConstantArrays();
652-
if (count($constantArrays) === 1) {
653-
$constantArrayType = $constantArrays[0];
654-
655-
$hasStringKey = false;
656-
if ($this->phpVersion->supportsArrayUnpackingWithStringKeys()) {
657-
foreach ($constantArrayType->getKeyTypes() as $keyType) {
658-
if ($keyType->isString()->yes()) {
659-
$hasStringKey = true;
660-
break;
652+
if (count($constantArrays) > 0) {
653+
$keepStringKeys = $this->phpVersion->supportsArrayUnpackingWithStringKeys();
654+
$totalArrays = count($constantArrays);
655+
656+
// Unpacking merges string keys by name, while integer keys are always
657+
// renumbered, so they're merged by their position among integer keys.
658+
// A slot missing from some of the unpacked arrays becomes optional.
659+
/** @var array<string, array{keyType: ConstantStringType|null, valueTypes: list<Type>, presentCount: int, anyOptional: bool}> $slots */
660+
$slots = [];
661+
/** @var list<string> $slotOrder */
662+
$slotOrder = [];
663+
664+
foreach ($constantArrays as $constantArrayType) {
665+
$nextIntegerSlot = 0;
666+
foreach ($constantArrayType->getKeyTypes() as $i => $keyType) {
667+
if ($keepStringKeys && $keyType->isString()->yes()) {
668+
$slotKey = 's' . $keyType->getValue();
669+
$slotKeyType = $keyType;
670+
} else {
671+
$slotKey = 'i' . $nextIntegerSlot;
672+
$slotKeyType = null;
673+
$nextIntegerSlot++;
674+
}
675+
676+
if (!isset($slots[$slotKey])) {
677+
$slots[$slotKey] = [
678+
'keyType' => $slotKeyType,
679+
'valueTypes' => [],
680+
'presentCount' => 0,
681+
'anyOptional' => false,
682+
];
683+
$slotOrder[] = $slotKey;
661684
}
662-
}
663-
}
664685

665-
foreach ($constantArrayType->getValueTypes() as $i => $innerValueType) {
666-
if ($hasStringKey) {
667-
$arrayBuilder->setOffsetValueType($constantArrayType->getKeyTypes()[$i], $innerValueType, $constantArrayType->isOptionalKey($i));
686+
$slots[$slotKey]['valueTypes'][] = $constantArrayType->getValueTypes()[$i];
687+
$slots[$slotKey]['presentCount']++;
668688
if (!$constantArrayType->isOptionalKey($i)) {
669-
$hasOffsetValueTypes[$constantArrayType->getKeyTypes()[$i]->getValue()] = new HasOffsetValueType($constantArrayType->getKeyTypes()[$i], $innerValueType);
689+
continue;
670690
}
671-
} else {
672-
$arrayBuilder->setOffsetValueType(null, $innerValueType, $constantArrayType->isOptionalKey($i));
691+
692+
$slots[$slotKey]['anyOptional'] = true;
693+
}
694+
}
695+
696+
foreach ($slotOrder as $slotKey) {
697+
$slot = $slots[$slotKey];
698+
$mergedValueType = TypeCombinator::union(...$slot['valueTypes']);
699+
$isOptional = $slot['anyOptional'] || $slot['presentCount'] < $totalArrays;
700+
$slotKeyType = $slot['keyType'];
701+
$arrayBuilder->setOffsetValueType($slotKeyType, $mergedValueType, $isOptional);
702+
703+
if ($slotKeyType === null) {
704+
continue;
705+
}
706+
707+
$keyValue = $slotKeyType->getValue();
708+
if (isset($hasOffsetValueTypes[$keyValue])) {
709+
$hasOffsetValueTypes[$keyValue] = new HasOffsetValueType(
710+
$slotKeyType,
711+
$isOptional
712+
? TypeCombinator::union($hasOffsetValueTypes[$keyValue]->getValueType(), $mergedValueType)
713+
: $mergedValueType,
714+
);
715+
} elseif (!$isOptional) {
716+
$hasOffsetValueTypes[$keyValue] = new HasOffsetValueType($slotKeyType, $mergedValueType);
673717
}
674718
}
675719
} else {

tests/PHPStan/Analyser/nsrt/array-unpacking-string-keys.php

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,19 @@
1212

1313
assertType('array{0: 1, a: 1, b: 2}', $bar);
1414

15+
// integer keys are renumbered even when the unpacked array also has string keys
16+
$mixed = [9, ...['a' => 1, 5], ...[6, 'b' => 2]];
17+
18+
assertType('array{0: 9, a: 1, 1: 5, 2: 6, b: 2}', $mixed);
19+
20+
$mixedOnlyIntegerKeys = [9, ...[5, 6]];
21+
22+
assertType('array{9, 5, 6}', $mixedOnlyIntegerKeys);
23+
24+
$mixedIntegerKeysOverwritten = [9, ...[3 => 'x']];
25+
26+
assertType("array{9, 'x'}", $mixedIntegerKeysOverwritten);
27+
1528
/**
1629
* @param array<string, int> $a
1730
* @param array<int, int> $b
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
<?php // lint >= 8.1
2+
3+
declare(strict_types = 1);
4+
5+
namespace Bug14708;
6+
7+
use function PHPStan\Testing\assertType;
8+
9+
/** @return array{test: bool, spread?: true} */
10+
function test1(bool $spread): array {
11+
$result = [
12+
'test' => $spread,
13+
...($spread ? ['spread' => true] : []),
14+
];
15+
assertType('array{test: bool, spread?: true}', $result);
16+
return $result;
17+
}
18+
19+
/** @return array{test: bool, spread?: true} */
20+
function test2(bool $spread): array {
21+
$return1 = ['test' => $spread];
22+
$return2 = $spread ? ['spread' => true] : [];
23+
24+
$result = [...$return1, ...$return2];
25+
assertType('array{test: bool, spread?: true}', $result);
26+
return $result;
27+
}
28+
29+
/** @return array{test: bool, spread?: true} */
30+
function test3(bool $spread): array {
31+
$return = ['test' => $spread];
32+
if ($spread) {
33+
$return['spread'] = true;
34+
}
35+
36+
assertType('array{test: bool, spread?: true}', $return);
37+
return $return;
38+
}
39+
40+
function testMultipleOptionalKeys(bool $a, bool $b): void {
41+
$result = [
42+
'base' => 1,
43+
...($a ? ['x' => 'hello'] : []),
44+
...($b ? ['y' => 42] : []),
45+
];
46+
assertType("array{base: 1, x?: 'hello', y?: 42}", $result);
47+
}
48+
49+
function testOverlappingKeys(bool $flag): void {
50+
$result = [
51+
'a' => 1,
52+
...($flag ? ['a' => 2, 'b' => 3] : ['b' => 4]),
53+
];
54+
assertType('array{a: 1|2, b: 3|4}', $result);
55+
}
56+
57+
function testIntegerKeysUnion(bool $flag): void {
58+
$result = [
59+
'start' => 0,
60+
...($flag ? [1, 2] : [3]),
61+
];
62+
assertType('array{start: 0, 0: 1|3, 1?: 2}', $result);
63+
}
64+
65+
function testAllBranchesSameKeys(bool $flag): void {
66+
$result = [
67+
...($flag ? ['a' => 1, 'b' => 2] : ['a' => 3, 'b' => 4]),
68+
];
69+
assertType('array{a: 1|3, b: 2|4}', $result);
70+
}
71+
72+
/** @param 'x'|'y'|'z' $variant */
73+
function testThreeBranchUnion(string $variant): void {
74+
if ($variant === 'x') {
75+
$extra = ['x' => 1];
76+
} elseif ($variant === 'y') {
77+
$extra = ['y' => 2];
78+
} else {
79+
$extra = [];
80+
}
81+
$result = ['base' => true, ...$extra];
82+
assertType('array{base: true, y?: 2, x?: 1}', $result);
83+
}
84+
85+
function testIntegerOnlyUnion(bool $flag): void {
86+
$result = [
87+
...($flag ? [1, 2, 3] : [4, 5]),
88+
];
89+
assertType('array{0: 1|4, 1: 2|5, 2?: 3}', $result);
90+
}
91+
92+
function testEmptyVsNonEmpty(bool $flag): void {
93+
$result = [
94+
...($flag ? ['key' => 'value'] : []),
95+
];
96+
assertType("array{key?: 'value'}", $result);
97+
}
98+
99+
function testStringKeyBranchAndIntegerKeyBranch(bool $flag): void {
100+
// integer keys are renumbered, string keys are kept
101+
$result = [
102+
9,
103+
...($flag ? ['a' => 1] : [5]),
104+
];
105+
assertType('array{0: 9, a?: 1, 1?: 5}', $result);
106+
}
107+
108+
function testMixedKeysInBothBranches(bool $flag): void {
109+
$result = [
110+
...($flag ? ['a' => 1, 7] : [5, 'a' => 2]),
111+
];
112+
assertType('array{a: 1|2, 0: 5|7}', $result);
113+
}

0 commit comments

Comments
 (0)