diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 97ac6041ba..982527235b 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -647,27 +647,100 @@ 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]; - + if (count($constantArrays) > 0) { $hasStringKey = false; if ($this->phpVersion->supportsArrayUnpackingWithStringKeys()) { - foreach ($constantArrayType->getKeyTypes() as $keyType) { - if ($keyType->isString()->yes()) { - $hasStringKey = true; - break; + foreach ($constantArrays as $constantArrayType) { + foreach ($constantArrayType->getKeyTypes() as $keyType) { + if ($keyType->isString()->yes()) { + $hasStringKey = true; + break 2; + } } } } - foreach ($constantArrayType->getValueTypes() as $i => $innerValueType) { - if ($hasStringKey) { - $arrayBuilder->setOffsetValueType($constantArrayType->getKeyTypes()[$i], $innerValueType, $constantArrayType->isOptionalKey($i)); - if (!$constantArrayType->isOptionalKey($i)) { - $hasOffsetValueTypes[$constantArrayType->getKeyTypes()[$i]->getValue()] = new HasOffsetValueType($constantArrayType->getKeyTypes()[$i], $innerValueType); + if ($hasStringKey) { + $totalArrays = count($constantArrays); + /** @var array, keyType: ConstantIntegerType|ConstantStringType, presentCount: int, anyOptional: bool}> $mergedKeys */ + $mergedKeys = []; + $keyOrder = []; + + foreach ($constantArrays as $constantArrayType) { + foreach ($constantArrayType->getKeyTypes() as $i => $keyType) { + $keyValue = $keyType->getValue(); + if (!isset($mergedKeys[$keyValue])) { + $mergedKeys[$keyValue] = [ + 'valueTypes' => [], + 'keyType' => $keyType, + 'presentCount' => 0, + 'anyOptional' => false, + ]; + $keyOrder[] = $keyValue; + } + $mergedKeys[$keyValue]['valueTypes'][] = $constantArrayType->getValueTypes()[$i]; + $mergedKeys[$keyValue]['presentCount']++; + if (!$constantArrayType->isOptionalKey($i)) { + continue; + } + + $mergedKeys[$keyValue]['anyOptional'] = true; } - } else { - $arrayBuilder->setOffsetValueType(null, $innerValueType, $constantArrayType->isOptionalKey($i)); + } + + foreach ($keyOrder as $keyValue) { + $info = $mergedKeys[$keyValue]; + $mergedValueType = TypeCombinator::union(...$info['valueTypes']); + $isOptional = $info['anyOptional'] || $info['presentCount'] < $totalArrays; + $arrayBuilder->setOffsetValueType($info['keyType'], $mergedValueType, $isOptional); + + if (isset($hasOffsetValueTypes[$keyValue])) { + if ($isOptional) { + $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType( + $info['keyType'], + TypeCombinator::union($hasOffsetValueTypes[$keyValue]->getValueType(), $mergedValueType), + ); + } else { + $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType($info['keyType'], $mergedValueType); + } + } elseif (!$isOptional) { + $hasOffsetValueTypes[$keyValue] = new HasOffsetValueType($info['keyType'], $mergedValueType); + } + } + } else { + $maxLen = 0; + $totalArrays = count($constantArrays); + foreach ($constantArrays as $constantArrayType) { + $len = count($constantArrayType->getKeyTypes()); + if ($len <= $maxLen) { + continue; + } + + $maxLen = $len; + } + + for ($pos = 0; $pos < $maxLen; $pos++) { + $posValueTypes = []; + $presentCount = 0; + $anyOptional = false; + foreach ($constantArrays as $constantArrayType) { + $keyTypes = $constantArrayType->getKeyTypes(); + if ($pos >= count($keyTypes)) { + continue; + } + + $posValueTypes[] = $constantArrayType->getValueTypes()[$pos]; + $presentCount++; + if (!$constantArrayType->isOptionalKey($pos)) { + continue; + } + + $anyOptional = true; + } + + $mergedValueType = TypeCombinator::union(...$posValueTypes); + $isOptional = $anyOptional || $presentCount < $totalArrays; + $arrayBuilder->setOffsetValueType(null, $mergedValueType, $isOptional); } } } else { diff --git a/tests/PHPStan/Analyser/nsrt/bug-14708.php b/tests/PHPStan/Analyser/nsrt/bug-14708.php new file mode 100644 index 0000000000..410e85715e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-14708.php @@ -0,0 +1,97 @@ += 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); +}