From 8a3d35cadd3a7aa747d2f1f60937e1b4095c6f78 Mon Sep 17 00:00:00 2001 From: klimick Date: Tue, 15 Sep 2026 13:28:48 +0300 Subject: [PATCH 1/2] Infer precise array shape unions in array unpacking --- .../InitializerExprTypeResolver.php | 95 +++- tests/PHPStan/Analyser/nsrt/bug-14708.php | 25 +- .../nsrt/precise-array-shape-unpacking.php | 408 ++++++++++++++++++ 3 files changed, 517 insertions(+), 11 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/precise-array-shape-unpacking.php diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index 10a913ac5d9..e58b73e710b 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -110,6 +110,7 @@ use function dirname; use function floor; use function in_array; +use function intdiv; use function intval; use function is_finite; use function is_float; @@ -128,6 +129,7 @@ final class InitializerExprTypeResolver { public const CALCULATE_SCALARS_LIMIT = 128; + private const CALCULATE_ARRAYS_LIMIT = 32; /** @var array */ private array $currentlyResolvingClassConstant = []; @@ -642,11 +644,98 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type return $this->oversizedArrayBuilder->build($expr, $getTypeCallback); } + $valueTypes = []; + $keyTypes = []; + $constantArrayVariantsByItemIndex = []; + $constantArraysCombinationsCount = 1; + $hasConstantArrayUnion = false; + $canResolveConstantArraysPrecisely = true; + + foreach ($expr->items as $itemIndex => $arrayItem) { + $valueType = $getTypeCallback($arrayItem->value); + $valueTypes[$itemIndex] = $valueType; + + if (!$arrayItem->unpack) { + $keyTypes[$itemIndex] = $arrayItem->key !== null + ? $getTypeCallback($arrayItem->key) + : null; + continue; + } + + $constantArrays = $valueType->getConstantArrays(); + $constantArraysCount = count($constantArrays); + $constantArrayVariantsByItemIndex[$itemIndex] = $constantArrays; + + if ($constantArraysCount === 0) { + $canResolveConstantArraysPrecisely = false; + continue; + } + + if ($constantArraysCount > 1) { + $hasConstantArrayUnion = true; + } + + if ($constantArraysCount > self::CALCULATE_ARRAYS_LIMIT + || $constantArraysCombinationsCount > intdiv(self::CALCULATE_ARRAYS_LIMIT, $constantArraysCount) + ) { + $canResolveConstantArraysPrecisely = false; + continue; + } + + $constantArraysCombinationsCount *= $constantArraysCount; + } + + if ($hasConstantArrayUnion && $canResolveConstantArraysPrecisely) { + $arrayBuilders = [ConstantArrayTypeBuilder::createEmpty()]; + $keepStringKeys = $this->phpVersion->supportsArrayUnpackingWithStringKeys(); + + foreach ($expr->items as $itemIndex => $arrayItem) { + if (!$arrayItem->unpack) { + foreach ($arrayBuilders as $arrayBuilder) { + $arrayBuilder->setOffsetValueType( + $keyTypes[$itemIndex], + $valueTypes[$itemIndex], + ); + } + + continue; + } + + $newArrayBuilders = []; + + foreach ($arrayBuilders as $arrayBuilder) { + foreach ($constantArrayVariantsByItemIndex[$itemIndex] as $constantArray) { + $newArrayBuilder = clone $arrayBuilder; + + foreach ($constantArray->getKeyTypes() as $j => $keyType) { + $newArrayBuilder->setOffsetValueType( + $keepStringKeys && $keyType->isString()->yes() ? $keyType : null, + $constantArray->getValueTypes()[$j], + $constantArray->isOptionalKey($j), + ); + } + + $newArrayBuilders[] = $newArrayBuilder; + } + } + + $arrayBuilders = $newArrayBuilders; + } + + $arrayTypes = []; + + foreach ($arrayBuilders as $arrayBuilder) { + $arrayTypes[] = $arrayBuilder->getArray(); + } + + return TypeCombinator::union(...$arrayTypes); + } + $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); $isList = null; $hasOffsetValueTypes = []; - foreach ($expr->items as $arrayItem) { - $valueType = $getTypeCallback($arrayItem->value); + foreach ($expr->items as $itemIndex => $arrayItem) { + $valueType = $valueTypes[$itemIndex]; if ($arrayItem->unpack) { $constantArrays = $valueType->getConstantArrays(); if (count($constantArrays) > 0) { @@ -739,7 +828,7 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type } } else { $arrayBuilder->setOffsetValueType( - $arrayItem->key !== null ? $getTypeCallback($arrayItem->key) : null, + $keyTypes[$itemIndex], $valueType, ); } diff --git a/tests/PHPStan/Analyser/nsrt/bug-14708.php b/tests/PHPStan/Analyser/nsrt/bug-14708.php index d6a659fe14c..25a3d201e91 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-14708.php +++ b/tests/PHPStan/Analyser/nsrt/bug-14708.php @@ -4,6 +4,7 @@ namespace Bug14708; +use function PHPStan\Testing\assertSuperType; use function PHPStan\Testing\assertType; /** @return array{test: bool, spread?: true} */ @@ -51,7 +52,8 @@ function testOverlappingKeys(bool $flag): void { 'a' => 1, ...($flag ? ['a' => 2, 'b' => 3] : ['b' => 4]), ]; - assertType('array{a: 1|2, b: 3|4}', $result); + assertSuperType('array{a: 1|2, b: 3|4}', $result); + assertType('array{a: 1, b: 4}|array{a: 2, b: 3}', $result); } function testIntegerKeysUnion(bool $flag): void { @@ -59,14 +61,16 @@ function testIntegerKeysUnion(bool $flag): void { 'start' => 0, ...($flag ? [1, 2] : [3]), ]; - assertType('array{start: 0, 0: 1|3, 1?: 2}', $result); + assertSuperType('array{start: 0, 0: 1|3, 1?: 2}', $result); + assertType('array{start: 0, 0: 1, 1: 2}|array{start: 0, 0: 3}', $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); + assertSuperType('array{a: 1|3, b: 2|4}', $result); + assertType('array{a: 1, b: 2}|array{a: 3, b: 4}', $result); } /** @param 'x'|'y'|'z' $variant */ @@ -79,21 +83,24 @@ function testThreeBranchUnion(string $variant): void { $extra = []; } $result = ['base' => true, ...$extra]; - assertType('array{base: true, y?: 2, x?: 1}', $result); + assertSuperType('array{base: true, y?: 2, x?: 1}', $result); + assertType('array{base: true, x: 1}|array{base: true, y?: 2}', $result); } function testIntegerOnlyUnion(bool $flag): void { $result = [ ...($flag ? [1, 2, 3] : [4, 5]), ]; - assertType('array{0: 1|4, 1: 2|5, 2?: 3}', $result); + assertSuperType('array{0: 1|4, 1: 2|5, 2?: 3}', $result); + assertType('array{1, 2, 3}|array{4, 5}', $result); } function testEmptyVsNonEmpty(bool $flag): void { $result = [ ...($flag ? ['key' => 'value'] : []), ]; - assertType("array{key?: 'value'}", $result); + assertSuperType("array{key?: 'value'}", $result); + assertType("array{}|array{key: 'value'}", $result); } function testStringKeyBranchAndIntegerKeyBranch(bool $flag): void { @@ -102,12 +109,14 @@ function testStringKeyBranchAndIntegerKeyBranch(bool $flag): void { 9, ...($flag ? ['a' => 1] : [5]), ]; - assertType('array{0: 9, a?: 1, 1?: 5}', $result); + assertSuperType('array{0: 9, a?: 1, 1?: 5}', $result); + assertType('array{0: 9, a: 1}|array{9, 5}', $result); } function testMixedKeysInBothBranches(bool $flag): void { $result = [ ...($flag ? ['a' => 1, 7] : [5, 'a' => 2]), ]; - assertType('array{a: 1|2, 0: 5|7}', $result); + assertSuperType('array{a: 1|2, 0: 5|7}', $result); + assertType('array{0: 5, a: 2}|array{a: 1, 0: 7}', $result); } diff --git a/tests/PHPStan/Analyser/nsrt/precise-array-shape-unpacking.php b/tests/PHPStan/Analyser/nsrt/precise-array-shape-unpacking.php new file mode 100644 index 00000000000..acbc7d05f73 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/precise-array-shape-unpacking.php @@ -0,0 +1,408 @@ += 8.1 + +declare(strict_types = 1); + +namespace ArraySpreadIntStringKeys; + +use function PHPStan\Testing\assertType; + +/** + * @param array{a: 1}|array{b: 2} $a + */ +function object2(array $a): void +{ + assertType( + 'array{a: 1}|array{b: 2}', + [...$a], + ); +} + +/** + * @param array{1}|array{a: 1} $a + */ +function objectAndTuple2(array $a): void +{ + $product = [ + 'array{1}', + 'array{a: 1}', + ]; + + assertType( + implode('|', $product), + [...$a], + ); +} + +/** + * @param array{a: 1}|array{b: 2} $a + * @param array{c: 3}|array{d: 4} $b + */ +function object4(array $a, array $b): void +{ + assertType( + 'array{a: 1, c: 3}|array{a: 1, d: 4}|array{b: 2, c: 3}|array{b: 2, d: 4}', + [...$a, ...$b], + ); +} + +/** + * @param array{1}|array{a: 1} $a + * @param array{2}|array{b: 2} $b + */ +function objectAndTuple4(array $a, array $b): void +{ + $product = [ + 'array{0: 1, b: 2}', + 'array{1, 2}', + 'array{a: 1, 0: 2}', + 'array{a: 1, b: 2}', + ]; + + assertType( + implode('|', $product), + [...$a, ...$b], + ); +} + +/** + * @param array{a: 1}|array{b: 2} $a + * @param array{c: 3}|array{d: 4} $b + * @param array{e: 5}|array{f: 6} $c + */ +function object8(array $a, array $b, array $c): void +{ + $product = [ + 'array{a: 1, c: 3, e: 5}', + 'array{a: 1, c: 3, f: 6}', + 'array{a: 1, d: 4, e: 5}', + 'array{a: 1, d: 4, f: 6}', + 'array{b: 2, c: 3, e: 5}', + 'array{b: 2, c: 3, f: 6}', + 'array{b: 2, d: 4, e: 5}', + 'array{b: 2, d: 4, f: 6}', + ]; + + assertType( + implode('|', $product), + [...$a, ...$b, ...$c], + ); +} + +/** + * @param array{1}|array{a: 1} $a + * @param array{2}|array{b: 2} $b + * @param array{3}|array{c: 3} $c + */ +function objectAndTuple8(array $a, array $b, array $c): void +{ + $product = [ + 'array{0: 1, 1: 2, c: 3}', + 'array{0: 1, b: 2, 1: 3}', + 'array{0: 1, b: 2, c: 3}', + 'array{1, 2, 3}', + 'array{a: 1, 0: 2, 1: 3}', + 'array{a: 1, 0: 2, c: 3}', + 'array{a: 1, b: 2, 0: 3}', + 'array{a: 1, b: 2, c: 3}', + ]; + + assertType( + implode('|', $product), + [...$a, ...$b, ...$c], + ); +} + +/** + * @param array{a: 1}|array{b: 2} $a + * @param array{c: 3}|array{d: 4} $b + * @param array{e: 5}|array{f: 6} $c + * @param array{g: 7}|array{h: 8} $d + */ +function object16(array $a, array $b, array $c, array $d): void +{ + $product = [ + 'array{a: 1, c: 3, e: 5, g: 7}', + 'array{a: 1, c: 3, e: 5, h: 8}', + 'array{a: 1, c: 3, f: 6, g: 7}', + 'array{a: 1, c: 3, f: 6, h: 8}', + 'array{a: 1, d: 4, e: 5, g: 7}', + 'array{a: 1, d: 4, e: 5, h: 8}', + 'array{a: 1, d: 4, f: 6, g: 7}', + 'array{a: 1, d: 4, f: 6, h: 8}', + 'array{b: 2, c: 3, e: 5, g: 7}', + 'array{b: 2, c: 3, e: 5, h: 8}', + 'array{b: 2, c: 3, f: 6, g: 7}', + 'array{b: 2, c: 3, f: 6, h: 8}', + 'array{b: 2, d: 4, e: 5, g: 7}', + 'array{b: 2, d: 4, e: 5, h: 8}', + 'array{b: 2, d: 4, f: 6, g: 7}', + 'array{b: 2, d: 4, f: 6, h: 8}', + ]; + + assertType( + implode('|', $product), + [...$a, ...$b, ...$c, ...$d], + ); +} + +/** + * @param array{1}|array{a: 1} $a + * @param array{2}|array{b: 2} $b + * @param array{3}|array{c: 3} $c + * @param array{4}|array{d: 4} $d + */ +function objectAndTuple16(array $a, array $b, array $c, array $d): void +{ + $product = [ + 'array{0: 1, 1: 2, 2: 3, d: 4}', + 'array{0: 1, 1: 2, c: 3, 2: 4}', + 'array{0: 1, 1: 2, c: 3, d: 4}', + 'array{0: 1, b: 2, 1: 3, 2: 4}', + 'array{0: 1, b: 2, 1: 3, d: 4}', + 'array{0: 1, b: 2, c: 3, 1: 4}', + 'array{0: 1, b: 2, c: 3, d: 4}', + 'array{1, 2, 3, 4}', + 'array{a: 1, 0: 2, 1: 3, 2: 4}', + 'array{a: 1, 0: 2, 1: 3, d: 4}', + 'array{a: 1, 0: 2, c: 3, 1: 4}', + 'array{a: 1, 0: 2, c: 3, d: 4}', + 'array{a: 1, b: 2, 0: 3, 1: 4}', + 'array{a: 1, b: 2, 0: 3, d: 4}', + 'array{a: 1, b: 2, c: 3, 0: 4}', + 'array{a: 1, b: 2, c: 3, d: 4}', + ]; + + assertType( + implode('|', $product), + [...$a, ...$b, ...$c, ...$d], + ); +} + +/** + * @param array{a: 1}|array{b: 2} $a + * @param array{c: 3}|array{d: 4} $b + * @param array{e: 5}|array{f: 6} $c + * @param array{g: 7}|array{h: 8} $d + * @param array{i: 9}|array{j: 0} $e + */ +function object32(array $a, array $b, array $c, array $d, array $e): void +{ + $product = [ + 'array{a: 1, c: 3, e: 5, g: 7, i: 9}', + 'array{a: 1, c: 3, e: 5, g: 7, j: 0}', + 'array{a: 1, c: 3, e: 5, h: 8, i: 9}', + 'array{a: 1, c: 3, e: 5, h: 8, j: 0}', + 'array{a: 1, c: 3, f: 6, g: 7, i: 9}', + 'array{a: 1, c: 3, f: 6, g: 7, j: 0}', + 'array{a: 1, c: 3, f: 6, h: 8, i: 9}', + 'array{a: 1, c: 3, f: 6, h: 8, j: 0}', + 'array{a: 1, d: 4, e: 5, g: 7, i: 9}', + 'array{a: 1, d: 4, e: 5, g: 7, j: 0}', + 'array{a: 1, d: 4, e: 5, h: 8, i: 9}', + 'array{a: 1, d: 4, e: 5, h: 8, j: 0}', + 'array{a: 1, d: 4, f: 6, g: 7, i: 9}', + 'array{a: 1, d: 4, f: 6, g: 7, j: 0}', + 'array{a: 1, d: 4, f: 6, h: 8, i: 9}', + 'array{a: 1, d: 4, f: 6, h: 8, j: 0}', + 'array{b: 2, c: 3, e: 5, g: 7, i: 9}', + 'array{b: 2, c: 3, e: 5, g: 7, j: 0}', + 'array{b: 2, c: 3, e: 5, h: 8, i: 9}', + 'array{b: 2, c: 3, e: 5, h: 8, j: 0}', + 'array{b: 2, c: 3, f: 6, g: 7, i: 9}', + 'array{b: 2, c: 3, f: 6, g: 7, j: 0}', + 'array{b: 2, c: 3, f: 6, h: 8, i: 9}', + 'array{b: 2, c: 3, f: 6, h: 8, j: 0}', + 'array{b: 2, d: 4, e: 5, g: 7, i: 9}', + 'array{b: 2, d: 4, e: 5, g: 7, j: 0}', + 'array{b: 2, d: 4, e: 5, h: 8, i: 9}', + 'array{b: 2, d: 4, e: 5, h: 8, j: 0}', + 'array{b: 2, d: 4, f: 6, g: 7, i: 9}', + 'array{b: 2, d: 4, f: 6, g: 7, j: 0}', + 'array{b: 2, d: 4, f: 6, h: 8, i: 9}', + 'array{b: 2, d: 4, f: 6, h: 8, j: 0}', + ]; + + assertType( + implode('|', $product), + [...$a, ...$b, ...$c, ...$d, ...$e], + ); +} + +/** + * @param array{1}|array{a: 1} $a + * @param array{2}|array{b: 2} $b + * @param array{3}|array{c: 3} $c + * @param array{4}|array{d: 4} $d + * @param array{5}|array{e: 5} $e + */ +function objectAndTuple32(array $a, array $b, array $c, array $d, array $e): void +{ + $product = [ + 'array{0: 1, 1: 2, 2: 3, 3: 4, e: 5}', + 'array{0: 1, 1: 2, 2: 3, d: 4, 3: 5}', + 'array{0: 1, 1: 2, 2: 3, d: 4, e: 5}', + 'array{0: 1, 1: 2, c: 3, 2: 4, 3: 5}', + 'array{0: 1, 1: 2, c: 3, 2: 4, e: 5}', + 'array{0: 1, 1: 2, c: 3, d: 4, 2: 5}', + 'array{0: 1, 1: 2, c: 3, d: 4, e: 5}', + 'array{0: 1, b: 2, 1: 3, 2: 4, 3: 5}', + 'array{0: 1, b: 2, 1: 3, 2: 4, e: 5}', + 'array{0: 1, b: 2, 1: 3, d: 4, 2: 5}', + 'array{0: 1, b: 2, 1: 3, d: 4, e: 5}', + 'array{0: 1, b: 2, c: 3, 1: 4, 2: 5}', + 'array{0: 1, b: 2, c: 3, 1: 4, e: 5}', + 'array{0: 1, b: 2, c: 3, d: 4, 1: 5}', + 'array{0: 1, b: 2, c: 3, d: 4, e: 5}', + 'array{1, 2, 3, 4, 5}', + 'array{a: 1, 0: 2, 1: 3, 2: 4, 3: 5}', + 'array{a: 1, 0: 2, 1: 3, 2: 4, e: 5}', + 'array{a: 1, 0: 2, 1: 3, d: 4, 2: 5}', + 'array{a: 1, 0: 2, 1: 3, d: 4, e: 5}', + 'array{a: 1, 0: 2, c: 3, 1: 4, 2: 5}', + 'array{a: 1, 0: 2, c: 3, 1: 4, e: 5}', + 'array{a: 1, 0: 2, c: 3, d: 4, 1: 5}', + 'array{a: 1, 0: 2, c: 3, d: 4, e: 5}', + 'array{a: 1, b: 2, 0: 3, 1: 4, 2: 5}', + 'array{a: 1, b: 2, 0: 3, 1: 4, e: 5}', + 'array{a: 1, b: 2, 0: 3, d: 4, 1: 5}', + 'array{a: 1, b: 2, 0: 3, d: 4, e: 5}', + 'array{a: 1, b: 2, c: 3, 0: 4, 1: 5}', + 'array{a: 1, b: 2, c: 3, 0: 4, e: 5}', + 'array{a: 1, b: 2, c: 3, d: 4, 0: 5}', + 'array{a: 1, b: 2, c: 3, d: 4, e: 5}', + ]; + + assertType( + implode('|', $product), + [...$a, ...$b, ...$c, ...$d, ...$e], + ); +} + +/** + * @param array{a: 1}|array{b: 2} $a + * @param array{c: 3}|array{d: 4} $b + * @param array{e: 5}|array{f: 6} $c + * @param array{g: 7}|array{h: 8} $d + * @param array{i: 9}|array{j: 0} $e + * @param array{k: 9}|array{l: 0} $f + */ +function objectInferenceLimits(array $a, array $b, array $c, array $d, array $e, array $f): void +{ + assertType( + 'array{a?: 1, b?: 2, c?: 3, d?: 4, e?: 5, f?: 6, g?: 7, h?: 8, i?: 9, j?: 0, k?: 9, l?: 0}', + [...$a, ...$b, ...$c, ...$d, ...$e, ...$f], + ); +} + +/** + * @param array{1}|array{a: 1} $a + * @param array{2}|array{b: 2} $b + * @param array{3}|array{c: 3} $c + * @param array{4}|array{d: 4} $d + * @param array{5}|array{e: 5} $e + * @param array{6}|array{f: 6} $f + */ +function objectAndTupleInferenceLimits(array $a, array $b, array $c, array $d, array $e, array $f): void +{ + $approximated = 'array{'.implode(', ', [ + '0?: 1|2|3|4|5|6', + 'a?: 1', + '1?: 2|3|4|5|6', + 'b?: 2', + '2?: 3|4|5|6', + 'c?: 3', + '3?: 4|5|6', + 'd?: 4', + '4?: 5|6', + 'e?: 5', + '5?: 6', + 'f?: 6', + ]).'}'; + + assertType( + $approximated, + [...$a, ...$b, ...$c, ...$d, ...$e, ...$f], + ); +} + +/** + * @param array{1}|array{2, 3} $a + */ +function differentTupleLengths(array $a): void +{ + assertType( + 'array{1, 4}|array{2, 3, 4}', + [...$a, 4], + ); +} + +/** + * @param array{a: 1}|array{b: 2, c: 3} $a + */ +function differentShapeLengths(array $a): void +{ + assertType( + 'array{a: 1, d: 4}|array{b: 2, c: 3, d: 4}', + [...$a, 'd' => 4], + ); +} + +/** + * @param array{1, 2} | array{c: 3, d: 4} $a + */ +function differentTupleAndShapeLengths(array $a): void +{ + assertType( + 'array{1, 2, 5}|array{c: 3, d: 4, 0: 5}', + [...$a, 5], + ); +} + +/** + * @param array{int}|array{b: int}|int $input + */ +function notOnlyArrays(int|array $input): void +{ + assertType( + 'array{b: int}|array{int}|int', + is_int($input) ? $input : [...$input], + ); +} + +/** + * @param array{x: 1}|array{a: 2} $a + * @param array{x: 3}|array{b: 4} $b + */ +function overlappingKeys(array $a, array $b): void +{ + assertType( + 'array{a: 2, b: 4}|array{a?: 2, x: 3}|array{x: 1, b: 4}', + [...$a, ...$b], + ); +} + +/** + * @param array{a: 1}|array{b: 2} $a + */ +function explicitKeysAroundSpread(array $a): void +{ + assertType( + 'array{a: 0, b: 2}|array{a: 1}', + ['a' => 0, ...$a], + ); + assertType( + 'array{b?: 2, a: 0}', + [...$a, 'a' => 0], + ); +} + +/** + * @param array{a: 1, x?: 3}|array{b: 2} $a + */ +function optionalKeyInUnionMember(array $a): void +{ + assertType( + 'array{a: 1, x?: 3}|array{b: 2}', + [...$a], + ); +} From c5c57eb3efb42bbaae48d873a511c63488eefae5 Mon Sep 17 00:00:00 2001 From: klimick Date: Tue, 15 Sep 2026 13:58:39 +0300 Subject: [PATCH 2/2] Bleeding edge parameter --- conf/bleedingEdge.neon | 1 + conf/config.neon | 1 + conf/parametersSchema.neon | 1 + .../ValidateIgnoredErrorsExtension.php | 2 +- .../InitializerExprTypeResolver.php | 133 +++++++++--------- src/Testing/PHPStanTestCase.php | 1 + .../PreciseArrayShapeUnpackingLegacyTest.php | 29 ++++ .../PreciseArrayShapeUnpackingTest.php | 36 +++++ .../precise-array-shape-unpacking-legacy.php | 15 ++ .../precise-array-shape-unpacking.php | 2 +- 10 files changed, 155 insertions(+), 66 deletions(-) create mode 100644 tests/PHPStan/Analyser/PreciseArrayShapeUnpackingLegacyTest.php create mode 100644 tests/PHPStan/Analyser/PreciseArrayShapeUnpackingTest.php create mode 100644 tests/PHPStan/Analyser/data/precise-array-shape-unpacking-legacy.php rename tests/PHPStan/Analyser/{nsrt => data}/precise-array-shape-unpacking.php (99%) diff --git a/conf/bleedingEdge.neon b/conf/bleedingEdge.neon index a2f57a4871f..6b1d40dffd0 100644 --- a/conf/bleedingEdge.neon +++ b/conf/bleedingEdge.neon @@ -1,6 +1,7 @@ parameters: featureToggles: bleedingEdge: true + preciseArrayShapeUnpacking: true checkNonStringableDynamicAccess: true checkParameterCastableToNumberFunctions: true skipCheckGenericClasses!: [] diff --git a/conf/config.neon b/conf/config.neon index 16e588434ac..32f8303cf5f 100644 --- a/conf/config.neon +++ b/conf/config.neon @@ -26,6 +26,7 @@ parameters: throwTypeCovariance: false featureToggles: bleedingEdge: false + preciseArrayShapeUnpacking: false checkNonStringableDynamicAccess: false checkParameterCastableToNumberFunctions: false skipCheckGenericClasses: diff --git a/conf/parametersSchema.neon b/conf/parametersSchema.neon index 8b6d821fe97..0fc605a6884 100644 --- a/conf/parametersSchema.neon +++ b/conf/parametersSchema.neon @@ -30,6 +30,7 @@ parametersSchema: ]) featureToggles: structure([ bleedingEdge: bool(), + preciseArrayShapeUnpacking: bool(), checkNonStringableDynamicAccess: bool(), checkParameterCastableToNumberFunctions: bool(), skipCheckGenericClasses: listOf(string()), diff --git a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php index 63c98a70d34..e859a0bbd70 100644 --- a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php +++ b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php @@ -124,7 +124,7 @@ public function resolveTypeAlias(string $aliasName, NameScope $nameScope): ?Type }), $constantResolver, - new InitializerExprTypeResolver($constantResolver, $reflectionProviderProvider, new PhpVersion(PHP_VERSION_ID), new OperatorTypeSpecifyingExtensionRegistry(new DirectExtensionsCollection([])), new UnaryOperatorTypeSpecifyingExtensionRegistry(new DirectExtensionsCollection([])), new OversizedArrayBuilder(), true), + new InitializerExprTypeResolver($constantResolver, $reflectionProviderProvider, new PhpVersion(PHP_VERSION_ID), new OperatorTypeSpecifyingExtensionRegistry(new DirectExtensionsCollection([])), new UnaryOperatorTypeSpecifyingExtensionRegistry(new DirectExtensionsCollection([])), new OversizedArrayBuilder(), true, $builder->parameters['featureToggles']['preciseArrayShapeUnpacking']), reportUnsafeArrayStringKeyCasting: null, ), ), diff --git a/src/Reflection/InitializerExprTypeResolver.php b/src/Reflection/InitializerExprTypeResolver.php index e58b73e710b..51b011ec28c 100644 --- a/src/Reflection/InitializerExprTypeResolver.php +++ b/src/Reflection/InitializerExprTypeResolver.php @@ -146,6 +146,8 @@ public function __construct( private OversizedArrayBuilder $oversizedArrayBuilder, #[AutowiredParameter] private bool $usePathConstantsAsConstantString, + #[AutowiredParameter(ref: '%featureToggles.preciseArrayShapeUnpacking%')] + private bool $preciseArrayShapeUnpacking, ) { } @@ -646,96 +648,97 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type $valueTypes = []; $keyTypes = []; - $constantArrayVariantsByItemIndex = []; - $constantArraysCombinationsCount = 1; - $hasConstantArrayUnion = false; - $canResolveConstantArraysPrecisely = true; + if ($this->preciseArrayShapeUnpacking) { + $constantArrayVariantsByItemIndex = []; + $constantArraysCombinationsCount = 1; + $hasConstantArrayUnion = false; + $canResolveConstantArraysPrecisely = true; + foreach ($expr->items as $itemIndex => $arrayItem) { + $valueType = $getTypeCallback($arrayItem->value); + $valueTypes[$itemIndex] = $valueType; - foreach ($expr->items as $itemIndex => $arrayItem) { - $valueType = $getTypeCallback($arrayItem->value); - $valueTypes[$itemIndex] = $valueType; + if (!$arrayItem->unpack) { + $keyTypes[$itemIndex] = $arrayItem->key !== null + ? $getTypeCallback($arrayItem->key) + : null; + continue; + } - if (!$arrayItem->unpack) { - $keyTypes[$itemIndex] = $arrayItem->key !== null - ? $getTypeCallback($arrayItem->key) - : null; - continue; - } + $constantArrays = $valueType->getConstantArrays(); + $constantArraysCount = count($constantArrays); + $constantArrayVariantsByItemIndex[$itemIndex] = $constantArrays; - $constantArrays = $valueType->getConstantArrays(); - $constantArraysCount = count($constantArrays); - $constantArrayVariantsByItemIndex[$itemIndex] = $constantArrays; + if ($constantArraysCount === 0) { + $canResolveConstantArraysPrecisely = false; + continue; + } - if ($constantArraysCount === 0) { - $canResolveConstantArraysPrecisely = false; - continue; - } + if ($constantArraysCount > 1) { + $hasConstantArrayUnion = true; + } - if ($constantArraysCount > 1) { - $hasConstantArrayUnion = true; - } + if ($constantArraysCount > self::CALCULATE_ARRAYS_LIMIT + || $constantArraysCombinationsCount > intdiv(self::CALCULATE_ARRAYS_LIMIT, $constantArraysCount) + ) { + $canResolveConstantArraysPrecisely = false; + continue; + } - if ($constantArraysCount > self::CALCULATE_ARRAYS_LIMIT - || $constantArraysCombinationsCount > intdiv(self::CALCULATE_ARRAYS_LIMIT, $constantArraysCount) - ) { - $canResolveConstantArraysPrecisely = false; - continue; + $constantArraysCombinationsCount *= $constantArraysCount; } - $constantArraysCombinationsCount *= $constantArraysCount; - } + if ($hasConstantArrayUnion && $canResolveConstantArraysPrecisely) { + $arrayBuilders = [ConstantArrayTypeBuilder::createEmpty()]; + $keepStringKeys = $this->phpVersion->supportsArrayUnpackingWithStringKeys(); - if ($hasConstantArrayUnion && $canResolveConstantArraysPrecisely) { - $arrayBuilders = [ConstantArrayTypeBuilder::createEmpty()]; - $keepStringKeys = $this->phpVersion->supportsArrayUnpackingWithStringKeys(); + foreach ($expr->items as $itemIndex => $arrayItem) { + if (!$arrayItem->unpack) { + foreach ($arrayBuilders as $arrayBuilder) { + $arrayBuilder->setOffsetValueType( + $keyTypes[$itemIndex], + $valueTypes[$itemIndex], + ); + } - foreach ($expr->items as $itemIndex => $arrayItem) { - if (!$arrayItem->unpack) { - foreach ($arrayBuilders as $arrayBuilder) { - $arrayBuilder->setOffsetValueType( - $keyTypes[$itemIndex], - $valueTypes[$itemIndex], - ); + continue; } - continue; - } + $newArrayBuilders = []; - $newArrayBuilders = []; + foreach ($arrayBuilders as $arrayBuilder) { + foreach ($constantArrayVariantsByItemIndex[$itemIndex] as $constantArray) { + $newArrayBuilder = clone $arrayBuilder; + + foreach ($constantArray->getKeyTypes() as $j => $keyType) { + $newArrayBuilder->setOffsetValueType( + $keepStringKeys && $keyType->isString()->yes() ? $keyType : null, + $constantArray->getValueTypes()[$j], + $constantArray->isOptionalKey($j), + ); + } - foreach ($arrayBuilders as $arrayBuilder) { - foreach ($constantArrayVariantsByItemIndex[$itemIndex] as $constantArray) { - $newArrayBuilder = clone $arrayBuilder; - - foreach ($constantArray->getKeyTypes() as $j => $keyType) { - $newArrayBuilder->setOffsetValueType( - $keepStringKeys && $keyType->isString()->yes() ? $keyType : null, - $constantArray->getValueTypes()[$j], - $constantArray->isOptionalKey($j), - ); + $newArrayBuilders[] = $newArrayBuilder; } - - $newArrayBuilders[] = $newArrayBuilder; } + + $arrayBuilders = $newArrayBuilders; } - $arrayBuilders = $newArrayBuilders; - } + $arrayTypes = []; - $arrayTypes = []; + foreach ($arrayBuilders as $arrayBuilder) { + $arrayTypes[] = $arrayBuilder->getArray(); + } - foreach ($arrayBuilders as $arrayBuilder) { - $arrayTypes[] = $arrayBuilder->getArray(); + return TypeCombinator::union(...$arrayTypes); } - - return TypeCombinator::union(...$arrayTypes); } $arrayBuilder = ConstantArrayTypeBuilder::createEmpty(); $isList = null; $hasOffsetValueTypes = []; foreach ($expr->items as $itemIndex => $arrayItem) { - $valueType = $valueTypes[$itemIndex]; + $valueType = $valueTypes[$itemIndex] ?? $getTypeCallback($arrayItem->value); if ($arrayItem->unpack) { $constantArrays = $valueType->getConstantArrays(); if (count($constantArrays) > 0) { @@ -828,7 +831,9 @@ public function getArrayType(Expr\Array_ $expr, callable $getTypeCallback): Type } } else { $arrayBuilder->setOffsetValueType( - $keyTypes[$itemIndex], + array_key_exists($itemIndex, $keyTypes) + ? $keyTypes[$itemIndex] + : ($arrayItem->key !== null ? $getTypeCallback($arrayItem->key) : null), $valueType, ); } diff --git a/src/Testing/PHPStanTestCase.php b/src/Testing/PHPStanTestCase.php index d06c9e3b41a..d7204adb7a2 100644 --- a/src/Testing/PHPStanTestCase.php +++ b/src/Testing/PHPStanTestCase.php @@ -106,6 +106,7 @@ public static function createScopeFactory(ReflectionProvider $reflectionProvider $container->getByType(UnaryOperatorTypeSpecifyingExtensionRegistry::class), new OversizedArrayBuilder(), $container->getParameter('usePathConstantsAsConstantString'), + $container->getParameter('featureToggles')['preciseArrayShapeUnpacking'], ); return new ScopeFactory( diff --git a/tests/PHPStan/Analyser/PreciseArrayShapeUnpackingLegacyTest.php b/tests/PHPStan/Analyser/PreciseArrayShapeUnpackingLegacyTest.php new file mode 100644 index 00000000000..f2f01731b8c --- /dev/null +++ b/tests/PHPStan/Analyser/PreciseArrayShapeUnpackingLegacyTest.php @@ -0,0 +1,29 @@ +assertFileAsserts($assertType, $file, ...$args); + } + +} diff --git a/tests/PHPStan/Analyser/PreciseArrayShapeUnpackingTest.php b/tests/PHPStan/Analyser/PreciseArrayShapeUnpackingTest.php new file mode 100644 index 00000000000..33b6d05a154 --- /dev/null +++ b/tests/PHPStan/Analyser/PreciseArrayShapeUnpackingTest.php @@ -0,0 +1,36 @@ +assertFileAsserts($assertType, $file, ...$args); + } + + public static function getAdditionalConfigFiles(): array + { + return [ + __DIR__ . '/../../../conf/bleedingEdge.neon', + ]; + } + +} diff --git a/tests/PHPStan/Analyser/data/precise-array-shape-unpacking-legacy.php b/tests/PHPStan/Analyser/data/precise-array-shape-unpacking-legacy.php new file mode 100644 index 00000000000..d4dbb4a8cf1 --- /dev/null +++ b/tests/PHPStan/Analyser/data/precise-array-shape-unpacking-legacy.php @@ -0,0 +1,15 @@ += 8.1 + +declare(strict_types = 1); + +namespace PreciseArrayShapeUnpackingLegacy; + +use function PHPStan\Testing\assertType; + +/** + * @param array{a: 1}|array{b: 2} $input + */ +function test(array $input): void +{ + assertType('array{a?: 1, b?: 2}', [...$input]); +} diff --git a/tests/PHPStan/Analyser/nsrt/precise-array-shape-unpacking.php b/tests/PHPStan/Analyser/data/precise-array-shape-unpacking.php similarity index 99% rename from tests/PHPStan/Analyser/nsrt/precise-array-shape-unpacking.php rename to tests/PHPStan/Analyser/data/precise-array-shape-unpacking.php index acbc7d05f73..f87d77cb6fa 100644 --- a/tests/PHPStan/Analyser/nsrt/precise-array-shape-unpacking.php +++ b/tests/PHPStan/Analyser/data/precise-array-shape-unpacking.php @@ -2,7 +2,7 @@ declare(strict_types = 1); -namespace ArraySpreadIntStringKeys; +namespace PreciseArrayShapeUnpacking; use function PHPStan\Testing\assertType;