diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 7332aab8f3..0b2c527049 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -649,27 +649,71 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type $valueType = $getTypeCallback($arrayItem->value); if ($arrayItem->unpack) { $constantArrays = $valueType->getConstantArrays(); - if (count($constantArrays) === 1) { - $constantArrayType = $constantArrays[0]; - - $hasStringKey = false; - if ($this->phpVersion->supportsArrayUnpackingWithStringKeys()) { - foreach ($constantArrayType->getKeyTypes() as $keyType) { - if ($keyType->isString()->yes()) { - $hasStringKey = true; - break; + if (count($constantArrays) > 0) { + $keepStringKeys = $this->phpVersion->supportsArrayUnpackingWithStringKeys(); + $totalArrays = count($constantArrays); + + // Unpacking merges string keys by name, while integer keys are always + // renumbered, so they're merged by their position among integer keys. + // A slot missing from some of the unpacked arrays becomes optional. + /** @var array, presentCount: int, anyOptional: bool}> $slots */ + $slots = []; + /** @var list $slotOrder */ + $slotOrder = []; + + foreach ($constantArrays as $constantArrayType) { + $nextIntegerSlot = 0; + foreach ($constantArrayType->getKeyTypes() as $i => $keyType) { + if ($keepStringKeys && $keyType->isString()->yes()) { + $slotKey = 's' . $keyType->getValue(); + $slotKeyType = $keyType; + } else { + $slotKey = 'i' . $nextIntegerSlot; + $slotKeyType = null; + $nextIntegerSlot++; + } + + if (!isset($slots[$slotKey])) { + $slots[$slotKey] = [ + 'keyType' => $slotKeyType, + 'valueTypes' => [], + 'presentCount' => 0, + 'anyOptional' => false, + ]; + $slotOrder[] = $slotKey; } - } - } - foreach ($constantArrayType->getValueTypes() as $i => $innerValueType) { - if ($hasStringKey) { - $arrayBuilder->setOffsetValueType($constantArrayType->getKeyTypes()[$i], $innerValueType, $constantArrayType->isOptionalKey($i)); + $slots[$slotKey]['valueTypes'][] = $constantArrayType->getValueTypes()[$i]; + $slots[$slotKey]['presentCount']++; if (!$constantArrayType->isOptionalKey($i)) { - $hasOffsetValueTypes[$constantArrayType->getKeyTypes()[$i]->getValue()] = new HasOffsetValueType($constantArrayType->getKeyTypes()[$i], $innerValueType); + continue; } - } else { - $arrayBuilder->setOffsetValueType(null, $innerValueType, $constantArrayType->isOptionalKey($i)); + + $slots[$slotKey]['anyOptional'] = true; + } + } + + foreach ($slotOrder as $slotKey) { + $slot = $slots[$slotKey]; + $mergedValueType = TypeCombinator::union(...$slot['valueTypes']); + $isOptional = $slot['anyOptional'] || $slot['presentCount'] < $totalArrays; + $slotKeyType = $slot['keyType']; + $arrayBuilder->setOffsetValueType($slotKeyType, $mergedValueType, $isOptional); + + if ($slotKeyType === null) { + continue; + } + + $keyValue = $slotKeyType->getValue(); + if (isset($hasOffsetValueTypes[$keyValue])) { + $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType( + $slotKeyType, + $isOptional + ? TypeCombinator::union($hasOffsetValueTypes[$keyValue]->getValueType(), $mergedValueType) + : $mergedValueType, + ); + } elseif (!$isOptional) { + $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType($slotKeyType, $mergedValueType); } } } else { diff --git a/tests/PHPStan/Analyser/nsrt/array-unpacking-string-keys.php b/tests/PHPStan/Analyser/nsrt/array-unpacking-string-keys.php index a03dbb75c9..1495bb711d 100644 --- a/tests/PHPStan/Analyser/nsrt/array-unpacking-string-keys.php +++ b/tests/PHPStan/Analyser/nsrt/array-unpacking-string-keys.php @@ -12,6 +12,19 @@ assertType('array{0: 1, a: 1, b: 2}', $bar); +// integer keys are renumbered even when the unpacked array also has string keys +$mixed = [9, ...['a' => 1, 5], ...[6, 'b' => 2]]; + +assertType('array{0: 9, a: 1, 1: 5, 2: 6, b: 2}', $mixed); + +$mixedOnlyIntegerKeys = [9, ...[5, 6]]; + +assertType('array{9, 5, 6}', $mixedOnlyIntegerKeys); + +$mixedIntegerKeysOverwritten = [9, ...[3 => 'x']]; + +assertType("array{9, 'x'}", $mixedIntegerKeysOverwritten); + /** * @param array $a * @param array $b diff --git a/tests/PHPStan/Analyser/nsrt/bug-14708.php b/tests/PHPStan/Analyser/nsrt/bug-14708.php new file mode 100644 index 0000000000..d6a659fe14 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14708.php @@ -0,0 +1,113 @@ += 8.1 + +declare(strict_types = 1); + +namespace Bug14708; + +use function PHPStan\Testing\assertType; + +/** @return array{test: bool, spread?: true} */ +function test1(bool $spread): array { + $result = [ + 'test' => $spread, + ...($spread ? ['spread' => true] : []), + ]; + assertType('array{test: bool, spread?: true}', $result); + return $result; +} + +/** @return array{test: bool, spread?: true} */ +function test2(bool $spread): array { + $return1 = ['test' => $spread]; + $return2 = $spread ? ['spread' => true] : []; + + $result = [...$return1, ...$return2]; + assertType('array{test: bool, spread?: true}', $result); + return $result; +} + +/** @return array{test: bool, spread?: true} */ +function test3(bool $spread): array { + $return = ['test' => $spread]; + if ($spread) { + $return['spread'] = true; + } + + assertType('array{test: bool, spread?: true}', $return); + return $return; +} + +function testMultipleOptionalKeys(bool $a, bool $b): void { + $result = [ + 'base' => 1, + ...($a ? ['x' => 'hello'] : []), + ...($b ? ['y' => 42] : []), + ]; + assertType("array{base: 1, x?: 'hello', y?: 42}", $result); +} + +function testOverlappingKeys(bool $flag): void { + $result = [ + 'a' => 1, + ...($flag ? ['a' => 2, 'b' => 3] : ['b' => 4]), + ]; + assertType('array{a: 1|2, b: 3|4}', $result); +} + +function testIntegerKeysUnion(bool $flag): void { + $result = [ + 'start' => 0, + ...($flag ? [1, 2] : [3]), + ]; + assertType('array{start: 0, 0: 1|3, 1?: 2}', $result); +} + +function testAllBranchesSameKeys(bool $flag): void { + $result = [ + ...($flag ? ['a' => 1, 'b' => 2] : ['a' => 3, 'b' => 4]), + ]; + assertType('array{a: 1|3, b: 2|4}', $result); +} + +/** @param 'x'|'y'|'z' $variant */ +function testThreeBranchUnion(string $variant): void { + if ($variant === 'x') { + $extra = ['x' => 1]; + } elseif ($variant === 'y') { + $extra = ['y' => 2]; + } else { + $extra = []; + } + $result = ['base' => true, ...$extra]; + assertType('array{base: true, y?: 2, x?: 1}', $result); +} + +function testIntegerOnlyUnion(bool $flag): void { + $result = [ + ...($flag ? [1, 2, 3] : [4, 5]), + ]; + assertType('array{0: 1|4, 1: 2|5, 2?: 3}', $result); +} + +function testEmptyVsNonEmpty(bool $flag): void { + $result = [ + ...($flag ? ['key' => 'value'] : []), + ]; + assertType("array{key?: 'value'}", $result); +} + +function testStringKeyBranchAndIntegerKeyBranch(bool $flag): void { + // integer keys are renumbered, string keys are kept + $result = [ + 9, + ...($flag ? ['a' => 1] : [5]), + ]; + assertType('array{0: 9, a?: 1, 1?: 5}', $result); +} + +function testMixedKeysInBothBranches(bool $flag): void { + $result = [ + ...($flag ? ['a' => 1, 7] : [5, 'a' => 2]), + ]; + assertType('array{a: 1|2, 0: 5|7}', $result); +}