From 71d80ae9f3056f9b29ac9b63b6582bea6010a08b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 13 Nov 2025 21:55:31 +0100 Subject: [PATCH 01/12] Don't loose known offset-values in array_merge() --- ...ergeFunctionDynamicReturnTypeExtension.php | 49 ++++++++++++++----- .../Analyser/LegacyNodeScopeResolverTest.php | 4 +- .../nsrt/array-merge-const-non-const.php | 28 +++++++++++ tests/PHPStan/Analyser/nsrt/bug-2911.php | 12 ++--- 4 files changed, 72 insertions(+), 21 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 1d4f7a1122..a60058e09a 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -8,6 +8,7 @@ use PHPStan\Reflection\FunctionReflection; use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; +use PHPStan\Type\Accessory\HasOffsetType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantArrayType; @@ -73,26 +74,40 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, static fn (Type $argType) => $argType->isConstantArray(), ); + $nonOptionalConstKeys = []; + $newArrayBuilder = null; if ($allConstant->yes()) { $newArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); - foreach ($argTypes as $argType) { - /** @var array $keyTypes */ - $keyTypes = []; - foreach ($argType->getConstantArrays() as $constantArray) { - foreach ($constantArray->getKeyTypes() as $keyType) { - $keyTypes[$keyType->getValue()] = $keyType; + } + foreach ($argTypes as $argType) { + /** @var array $keyTypes */ + $keyTypes = []; + foreach ($argType->getConstantArrays() as $constantArray) { + foreach ($constantArray->getKeyTypes() as $i => $keyType) { + $keyTypes[$keyType->getValue()] = $keyType; + + if ($constantArray->isOptionalKey($i)) { + continue; } - } - foreach ($keyTypes as $keyType) { - $newArrayBuilder->setOffsetValueType( - $keyType instanceof ConstantIntegerType ? null : $keyType, - $argType->getOffsetValueType($keyType), - !$argType->hasOffsetValueType($keyType)->yes(), - ); + $nonOptionalConstKeys[] = $keyType; } } + if ($newArrayBuilder === null) { + continue; + } + + foreach ($keyTypes as $keyType) { + $newArrayBuilder->setOffsetValueType( + $keyType instanceof ConstantIntegerType ? null : $keyType, + $argType->getOffsetValueType($keyType), + !$argType->hasOffsetValueType($keyType)->yes(), + ); + } + } + + if ($newArrayBuilder !== null) { return $newArrayBuilder->getArray(); } @@ -121,6 +136,11 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return new ConstantArrayType([], []); } + $offsetTypes = []; + foreach ($nonOptionalConstKeys as $constKey) { + $offsetTypes[] = new HasOffsetType($constKey); + } + $arrayType = new ArrayType( $keyType, TypeCombinator::union(...$valueTypes), @@ -132,6 +152,9 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if ($isList) { $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); } + if ($offsetTypes !== []) { + $arrayType = TypeCombinator::intersect($arrayType, ...$offsetTypes); + } return $arrayType; } diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index c9fe4f942a..b848224d61 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -4697,11 +4697,11 @@ public static function dataArrayFunctions(): array 'array_merge($generalStringKeys, $generalDateTimeValues)', ], [ - 'non-empty-array<1|string, int|stdClass>', + "non-empty-array<1|string, int|stdClass>&hasOffset('foo')&hasOffset(1)", 'array_merge($generalStringKeys, $stringOrIntegerKeys)', ], [ - 'non-empty-array<1|string, int|stdClass>', + "non-empty-array<1|string, int|stdClass>&hasOffset('foo')&hasOffset(1)", 'array_merge($stringOrIntegerKeys, $generalStringKeys)', ], [ diff --git a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php new file mode 100644 index 0000000000..af97f7f539 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php @@ -0,0 +1,28 @@ + 1, 'b' => false], $post)); +} + +function doBar(array $array): void { + assertType("non-empty-array&hasOffset('a')&hasOffset('b')", array_merge($array, ['a' => 1, 'b' => false])); +} + +function doFooBar(array $array): void { + assertType("non-empty-array&hasOffset('a')&hasOffset('b')&hasOffset('c')", array_merge(['c' => 'd'], $array, ['a' => 1, 'b' => false, 'c' => 'e'])); +} + +function doFooInts(array $array): void { + assertType("non-empty-array&hasOffset('a')&hasOffset('c')&hasOffset(1)&hasOffset(3)", array_merge([1 => 'd'], $array, ['a' => 1, 3 => false, 'c' => 'e'])); +} + +/** + * @param array $array + */ +function floatKey(array $array): void { + assertType("non-empty-array&hasOffset('a')&hasOffset('c')&hasOffset(3)&hasOffset(4)", array_merge([4.23 => 'd'], $array, ['a' => '1', 3 => 'false', 'c' => 'e'])); +} diff --git a/tests/PHPStan/Analyser/nsrt/bug-2911.php b/tests/PHPStan/Analyser/nsrt/bug-2911.php index 3cfbd308f1..1b189148b3 100644 --- a/tests/PHPStan/Analyser/nsrt/bug-2911.php +++ b/tests/PHPStan/Analyser/nsrt/bug-2911.php @@ -49,23 +49,23 @@ public function __construct(MutatorConfig $config) private function getResultSettings(array $settings): array { $settings = array_merge(self::DEFAULT_SETTINGS, $settings); - assertType('non-empty-array', $settings); + assertType("non-empty-array&hasOffset('limit')&hasOffset('remove')", $settings); if (!is_string($settings['remove'])) { throw $this->configException($settings, 'remove'); } - assertType("non-empty-array&hasOffsetValue('remove', string)", $settings); + assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', string)", $settings); $settings['remove'] = strtolower($settings['remove']); - assertType("non-empty-array&hasOffsetValue('remove', lowercase-string)", $settings); + assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', lowercase-string)", $settings); if (!in_array($settings['remove'], ['first', 'last', 'all'], true)) { throw $this->configException($settings, 'remove'); } - assertType("non-empty-array&hasOffsetValue('remove', 'all'|'first'|'last')", $settings); + assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', 'all'|'first'|'last')", $settings); if (!is_numeric($settings['limit']) || $settings['limit'] < 1) { throw $this->configException($settings, 'limit'); @@ -110,13 +110,13 @@ private function getResultSettings(array $settings): array { $settings = array_merge(self::DEFAULT_SETTINGS, $settings); - assertType('non-empty-array', $settings); + assertType("non-empty-array&hasOffset('limit')&hasOffset('remove')", $settings); if (!is_string($settings['remove'])) { throw new Exception(); } - assertType("non-empty-array&hasOffsetValue('remove', string)", $settings); + assertType("non-empty-array&hasOffset('limit')&hasOffsetValue('remove', string)", $settings); if (!is_int($settings['limit'])) { throw new Exception(); From a0ef976487ae1341171fee5fd043f6e8baedf87b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Thu, 13 Nov 2025 22:11:17 +0100 Subject: [PATCH 02/12] regression test --- .../Rules/Methods/ReturnTypeRuleTest.php | 5 ++++ tests/PHPStan/Rules/Methods/data/bug-8438.php | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+) create mode 100644 tests/PHPStan/Rules/Methods/data/bug-8438.php diff --git a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php index dd51c3df3a..0d4161ee54 100644 --- a/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php +++ b/tests/PHPStan/Rules/Methods/ReturnTypeRuleTest.php @@ -1276,4 +1276,9 @@ public function testBug9494(): void $this->analyse([__DIR__ . '/data/bug-9494.php'], []); } + public function testBug8438(): void + { + $this->analyse([__DIR__ . '/data/bug-8438.php'], []); + } + } diff --git a/tests/PHPStan/Rules/Methods/data/bug-8438.php b/tests/PHPStan/Rules/Methods/data/bug-8438.php new file mode 100644 index 0000000000..2459ff616e --- /dev/null +++ b/tests/PHPStan/Rules/Methods/data/bug-8438.php @@ -0,0 +1,26 @@ + $array + * + * @return array{expr: mixed, ...} + */ + protected function foo(array $array): array + { + $rnd = mt_rand(); + if ($rnd === 0) { + return ['expr' => 'test']; + } elseif ($rnd === 1) { + // no error with checkBenevolentUnionTypes: false (default even with l9 + strict rules) + return ['expr' => 'test', 1 => 'ok']; + } else { + // phpstan must understand 'expr' key is always present in the result, + // then there will be no error here neither + return array_merge($array, ['expr' => 'test', 1 => 'ok']); + } + } +} From 9b16f74083051602126f5aa580c79b437177d5cf Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 14 Nov 2025 06:25:23 +0100 Subject: [PATCH 03/12] more tests --- .../Analyser/nsrt/array-merge-const-non-const.php | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php index af97f7f539..2f3d64794b 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php @@ -26,3 +26,17 @@ function doFooInts(array $array): void { function floatKey(array $array): void { assertType("non-empty-array&hasOffset('a')&hasOffset('c')&hasOffset(3)&hasOffset(4)", array_merge([4.23 => 'd'], $array, ['a' => '1', 3 => 'false', 'c' => 'e'])); } + +function doOptKeys(array $array, array $arr2): void { + if (rand(0, 1)) { + $array['abc'] = 'def'; + } + assertType("array", array_merge($arr2, $array)); +} + +/** + * @param array{a?: 1, b: 2} $array + */ +function doOptShapeKeys(array $array, array $arr2): void { + assertType("non-empty-array&hasOffset('b')", array_merge($arr2, $array)); +} From 89cb92582574db5d420868c2c71adb243935bc02 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 14 Nov 2025 07:23:09 +0100 Subject: [PATCH 04/12] Update HasOffsetType.php --- src/Type/Accessory/HasOffsetType.php | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/Type/Accessory/HasOffsetType.php b/src/Type/Accessory/HasOffsetType.php index da6c176655..6eb0ea07c2 100644 --- a/src/Type/Accessory/HasOffsetType.php +++ b/src/Type/Accessory/HasOffsetType.php @@ -52,10 +52,7 @@ public function __construct(private ConstantStringType|ConstantIntegerType $offs { } - /** - * @return ConstantStringType|ConstantIntegerType - */ - public function getOffsetType(): Type + public function getOffsetType(): ConstantStringType|ConstantIntegerType { return $this->offsetType; } From 93d77442f6b8477461c3da488f33a3fec0ecf6aa Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 14 Nov 2025 07:34:32 +0100 Subject: [PATCH 05/12] support HasOffset* types --- ...ergeFunctionDynamicReturnTypeExtension.php | 24 +++++++++++++------ .../nsrt/array-merge-const-non-const.php | 12 ++++++++++ 2 files changed, 29 insertions(+), 7 deletions(-) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index a60058e09a..f544fa4317 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -9,6 +9,7 @@ use PHPStan\TrinaryLogic; use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\HasOffsetType; +use PHPStan\Type\Accessory\HasOffsetValueType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; use PHPStan\Type\Constant\ConstantArrayType; @@ -20,6 +21,7 @@ use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use PHPStan\Type\TypeUtils; use function array_keys; use function count; use function in_array; @@ -74,7 +76,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, static fn (Type $argType) => $argType->isConstantArray(), ); - $nonOptionalConstKeys = []; + $offsetTypes = []; $newArrayBuilder = null; if ($allConstant->yes()) { $newArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); @@ -90,11 +92,24 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, continue; } - $nonOptionalConstKeys[] = $keyType; + $offsetTypes[$keyType->getValue()] = new HasOffsetType($keyType); } } if ($newArrayBuilder === null) { + foreach (TypeUtils::getAccessoryTypes($argType) as $accessoryType) { + if ( + !($accessoryType instanceof HasOffsetType) + && !($accessoryType instanceof HasOffsetValueType) + ) { + continue; + } + + $offsetType = $accessoryType->getOffsetType(); + $offsetTypes[$offsetType->getValue()] = new HasOffsetType($offsetType); + + } + continue; } @@ -136,11 +151,6 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return new ConstantArrayType([], []); } - $offsetTypes = []; - foreach ($nonOptionalConstKeys as $constKey) { - $offsetTypes[] = new HasOffsetType($constKey); - } - $arrayType = new ArrayType( $keyType, TypeCombinator::union(...$valueTypes), diff --git a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php index 2f3d64794b..afe3fcf22e 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php @@ -40,3 +40,15 @@ function doOptKeys(array $array, array $arr2): void { function doOptShapeKeys(array $array, array $arr2): void { assertType("non-empty-array&hasOffset('b')", array_merge($arr2, $array)); } + +function hasOffsetKeys(array $array, array $arr2): void { + if (array_key_exists('b', $array)) { + assertType("non-empty-array&hasOffset('b')", array_merge($arr2, $array)); + } +} + +function hasOffsetValueKeys(array $array, array $arr2): void { + $array['b'] = 123; + + assertType("non-empty-array&hasOffset('b')", array_merge($arr2, $array)); +} From 00aa84aa0f4beabb897f3eedffb4904d320b3b8c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 14 Nov 2025 07:50:00 +0100 Subject: [PATCH 06/12] Update ArrayMergeFunctionDynamicReturnTypeExtension.php --- src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index f544fa4317..f4aefcb688 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -23,6 +23,7 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; use function array_keys; +use function array_values; use function count; use function in_array; @@ -163,7 +164,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); } if ($offsetTypes !== []) { - $arrayType = TypeCombinator::intersect($arrayType, ...$offsetTypes); + $arrayType = TypeCombinator::intersect($arrayType, ...array_values($offsetTypes)); } return $arrayType; From 0ba56e7ec2e55397d49542bbd88ad334a0acd19b Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 14 Nov 2025 10:45:50 +0100 Subject: [PATCH 07/12] infer HasOffsetValueType for last occuring string-keys --- ...ergeFunctionDynamicReturnTypeExtension.php | 44 ++++++++++++++++--- .../Analyser/LegacyNodeScopeResolverTest.php | 4 +- .../nsrt/array-merge-const-non-const.php | 24 ++++++---- 3 files changed, 57 insertions(+), 15 deletions(-) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index f4aefcb688..5c8b59e2e0 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -23,9 +23,10 @@ use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; use function array_keys; -use function array_values; use function count; use function in_array; +use function is_int; +use function is_string; #[AutowiredService] final class ArrayMergeFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -93,10 +94,18 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, continue; } - $offsetTypes[$keyType->getValue()] = new HasOffsetType($keyType); + $offsetValueType = $constantArray->getOffsetValueType($keyType); + $offsetTypes[$keyType->getValue()] = [false, $offsetValueType]; } } + if ($keyTypes === []) { + foreach ($offsetTypes as [&$generalize, $offsetType]) { + $generalize = true; + } + unset($generalize); + } + if ($newArrayBuilder === null) { foreach (TypeUtils::getAccessoryTypes($argType) as $accessoryType) { if ( @@ -107,8 +116,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $offsetType = $accessoryType->getOffsetType(); - $offsetTypes[$offsetType->getValue()] = new HasOffsetType($offsetType); - + $offsetValueType = $argType->getOffsetValueType($offsetType); + $offsetTypes[$offsetType->getValue()] = [false, $offsetValueType]; } continue; @@ -164,7 +173,32 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); } if ($offsetTypes !== []) { - $arrayType = TypeCombinator::intersect($arrayType, ...array_values($offsetTypes)); + $knownOffsetValues = []; + foreach ($offsetTypes as $key => [$generalize, $offsetType]) { + if (is_int($key)) { + // int keys will be appended and renumbered. + // at this point we can't reason about them, because unknown arrays are in the mix. + continue; + } + $keyType = new ConstantStringType($key); + + if (!$generalize && is_string($key)) { + // the last string-keyed offset will overwrite previous values + $hasOffsetType = new HasOffsetValueType( + $keyType, + $offsetType, + ); + } else { + $hasOffsetType = new HasOffsetType( + $keyType, + ); + } + + $knownOffsetValues[] = $hasOffsetType; + } + if ($knownOffsetValues !== []) { + $arrayType = TypeCombinator::intersect($arrayType, ...$knownOffsetValues); + } } return $arrayType; diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index b848224d61..17c38c25aa 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -4697,11 +4697,11 @@ public static function dataArrayFunctions(): array 'array_merge($generalStringKeys, $generalDateTimeValues)', ], [ - "non-empty-array<1|string, int|stdClass>&hasOffset('foo')&hasOffset(1)", + "non-empty-array<1|string, int|stdClass>&hasOffsetValue('foo', stdClass)", 'array_merge($generalStringKeys, $stringOrIntegerKeys)', ], [ - "non-empty-array<1|string, int|stdClass>&hasOffset('foo')&hasOffset(1)", + "non-empty-array<1|string, int|stdClass>&hasOffset('foo')", 'array_merge($stringOrIntegerKeys, $generalStringKeys)', ], [ diff --git a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php index afe3fcf22e..d7f1e9fa4e 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php @@ -5,26 +5,26 @@ use function PHPStan\Testing\assertType; function doFoo(array $post): void { - assertType("non-empty-array&hasOffset('a')&hasOffset('b')", array_merge(['a' => 1, 'b' => false], $post)); + assertType("non-empty-array&hasOffset('a')&hasOffset('b')", array_merge(['a' => 1, 'b' => false, 10 => 99], $post)); } function doBar(array $array): void { - assertType("non-empty-array&hasOffset('a')&hasOffset('b')", array_merge($array, ['a' => 1, 'b' => false])); + assertType("non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('b', false)", array_merge($array, ['a' => 1, 'b' => false, 10 => 99])); } function doFooBar(array $array): void { - assertType("non-empty-array&hasOffset('a')&hasOffset('b')&hasOffset('c')", array_merge(['c' => 'd'], $array, ['a' => 1, 'b' => false, 'c' => 'e'])); + assertType("non-empty-array&hasOffset('x')&hasOffsetValue('a', 1)&hasOffsetValue('b', false)&hasOffsetValue('c', 'e')", array_merge(['c' => 'd', 'x' => 'y'], $array, ['a' => 1, 'b' => false, 'c' => 'e'])); } function doFooInts(array $array): void { - assertType("non-empty-array&hasOffset('a')&hasOffset('c')&hasOffset(1)&hasOffset(3)", array_merge([1 => 'd'], $array, ['a' => 1, 3 => false, 'c' => 'e'])); + assertType("non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('c', 'e')", array_merge([1 => 'd'], $array, ['a' => 1, 3 => false, 'c' => 'e'])); } /** * @param array $array */ function floatKey(array $array): void { - assertType("non-empty-array&hasOffset('a')&hasOffset('c')&hasOffset(3)&hasOffset(4)", array_merge([4.23 => 'd'], $array, ['a' => '1', 3 => 'false', 'c' => 'e'])); + assertType("non-empty-array&hasOffsetValue('a', '1')&hasOffsetValue('c', 'e')", array_merge([4.23 => 'd'], $array, ['a' => '1', 3 => 'false', 'c' => 'e'])); } function doOptKeys(array $array, array $arr2): void { @@ -38,17 +38,25 @@ function doOptKeys(array $array, array $arr2): void { * @param array{a?: 1, b: 2} $array */ function doOptShapeKeys(array $array, array $arr2): void { - assertType("non-empty-array&hasOffset('b')", array_merge($arr2, $array)); + assertType("non-empty-array&hasOffsetValue('b', 2)", array_merge($arr2, $array)); } function hasOffsetKeys(array $array, array $arr2): void { if (array_key_exists('b', $array)) { - assertType("non-empty-array&hasOffset('b')", array_merge($arr2, $array)); + assertType("non-empty-array&hasOffsetValue('b', mixed)", array_merge($arr2, $array)); } } function hasOffsetValueKeys(array $array, array $arr2): void { $array['b'] = 123; - assertType("non-empty-array&hasOffset('b')", array_merge($arr2, $array)); + assertType("non-empty-array&hasOffsetValue('b', 123)", array_merge($arr2, $array)); +} + +/** + * @param array{a?: 1, b?: 2} $allOptional + */ +function doAllOptional(array $allOptional, array $arr2): void { + assertType("array", array_merge($arr2, $allOptional)); + assertType("array", array_merge($allOptional, $arr2)); } From 0e4a696e5050bf5976cb98f1706a1aad2582f541 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 14 Nov 2025 11:10:36 +0100 Subject: [PATCH 08/12] refactor --- ...ergeFunctionDynamicReturnTypeExtension.php | 32 +++++++++---------- .../nsrt/array-merge-const-non-const.php | 4 +++ 2 files changed, 19 insertions(+), 17 deletions(-) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 5c8b59e2e0..59dd5d1173 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -26,7 +26,6 @@ use function count; use function in_array; use function is_int; -use function is_string; #[AutowiredService] final class ArrayMergeFunctionDynamicReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -100,26 +99,25 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } if ($keyTypes === []) { - foreach ($offsetTypes as [&$generalize, $offsetType]) { - $generalize = true; + foreach ($offsetTypes as $key => [$generalize, $offsetValueType]) { + $offsetTypes[$key][0] = true; } - unset($generalize); } - if ($newArrayBuilder === null) { - foreach (TypeUtils::getAccessoryTypes($argType) as $accessoryType) { - if ( - !($accessoryType instanceof HasOffsetType) - && !($accessoryType instanceof HasOffsetValueType) - ) { - continue; - } - - $offsetType = $accessoryType->getOffsetType(); - $offsetValueType = $argType->getOffsetValueType($offsetType); - $offsetTypes[$offsetType->getValue()] = [false, $offsetValueType]; + foreach (TypeUtils::getAccessoryTypes($argType) as $accessoryType) { + if ( + !($accessoryType instanceof HasOffsetType) + && !($accessoryType instanceof HasOffsetValueType) + ) { + continue; } + $offsetType = $accessoryType->getOffsetType(); + $offsetValueType = $argType->getOffsetValueType($offsetType); + $offsetTypes[$offsetType->getValue()] = [false, $offsetValueType]; + } + + if ($newArrayBuilder === null) { continue; } @@ -182,7 +180,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $keyType = new ConstantStringType($key); - if (!$generalize && is_string($key)) { + if (!$generalize) { // the last string-keyed offset will overwrite previous values $hasOffsetType = new HasOffsetValueType( $keyType, diff --git a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php index d7f1e9fa4e..dca0f791c9 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php @@ -32,6 +32,7 @@ function doOptKeys(array $array, array $arr2): void { $array['abc'] = 'def'; } assertType("array", array_merge($arr2, $array)); + assertType("array", array_merge($array, $arr2)); } /** @@ -39,11 +40,13 @@ function doOptKeys(array $array, array $arr2): void { */ function doOptShapeKeys(array $array, array $arr2): void { assertType("non-empty-array&hasOffsetValue('b', 2)", array_merge($arr2, $array)); + assertType("non-empty-array&hasOffset('b')", array_merge($array, $arr2)); } function hasOffsetKeys(array $array, array $arr2): void { if (array_key_exists('b', $array)) { assertType("non-empty-array&hasOffsetValue('b', mixed)", array_merge($arr2, $array)); + assertType("non-empty-array&hasOffset('b')", array_merge($array, $arr2)); } } @@ -51,6 +54,7 @@ function hasOffsetValueKeys(array $array, array $arr2): void { $array['b'] = 123; assertType("non-empty-array&hasOffsetValue('b', 123)", array_merge($arr2, $array)); + assertType("non-empty-array&hasOffset('b')", array_merge($array, $arr2)); } /** From 152453c4b8de43a8c800791df27f1e525a3c9580 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 14 Nov 2025 11:17:12 +0100 Subject: [PATCH 09/12] more tests --- .../Analyser/nsrt/array-merge-const-non-const.php | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php index dca0f791c9..81b0de2bb2 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php @@ -50,11 +50,18 @@ function hasOffsetKeys(array $array, array $arr2): void { } } -function hasOffsetValueKeys(array $array, array $arr2): void { - $array['b'] = 123; +function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { + $hasB['b'] = 123; + $hasC['c'] = 'def'; - assertType("non-empty-array&hasOffsetValue('b', 123)", array_merge($arr2, $array)); - assertType("non-empty-array&hasOffset('b')", array_merge($array, $arr2)); + assertType("non-empty-array&hasOffsetValue('b', 123)", array_merge($mixedArray, $hasB)); + assertType("non-empty-array&hasOffset('b')", array_merge($hasB, $mixedArray)); + + assertType("non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", array_merge($mixedArray, $hasB, $hasC)); + assertType("non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", array_merge($hasB, $mixedArray, $hasC)); + + assertType("non-empty-array&hasOffset('c')&hasOffsetValue('b', 123)", array_merge($hasC, $mixedArray, $hasB)); + assertType("non-empty-array&hasOffset('b')&hasOffset('c')", array_merge($hasC, $hasB, $mixedArray)); } /** From 8896025eeb5535ca7bc363247fff791156ad9bc8 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 14 Nov 2025 13:38:12 +0100 Subject: [PATCH 10/12] test unions --- ...ergeFunctionDynamicReturnTypeExtension.php | 35 +++++++++++-------- .../nsrt/array-merge-const-non-const.php | 23 ++++++++++++ 2 files changed, 44 insertions(+), 14 deletions(-) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 59dd5d1173..0839df298c 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -18,6 +18,7 @@ use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; use PHPStan\Type\IntegerType; +use PHPStan\Type\MixedType; use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; @@ -86,21 +87,23 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, /** @var array $keyTypes */ $keyTypes = []; foreach ($argType->getConstantArrays() as $constantArray) { - foreach ($constantArray->getKeyTypes() as $i => $keyType) { + foreach ($constantArray->getKeyTypes() as $keyType) { $keyTypes[$keyType->getValue()] = $keyType; - if ($constantArray->isOptionalKey($i)) { - continue; - } - - $offsetValueType = $constantArray->getOffsetValueType($keyType); - $offsetTypes[$keyType->getValue()] = [false, $offsetValueType]; + $hasOffsetValue = TrinaryLogic::createFromBoolean($argType->hasOffsetValueType($keyType)->yes()); + $offsetTypes[$keyType->getValue()] = [ + $hasOffsetValue, + $argType->getOffsetValueType($keyType), + ]; } } if ($keyTypes === []) { - foreach ($offsetTypes as $key => [$generalize, $offsetValueType]) { - $offsetTypes[$key][0] = true; + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { + $offsetTypes[$key] = [ + $hasOffsetValue->and(TrinaryLogic::createMaybe()), + new MixedType(), + ]; } } @@ -113,8 +116,10 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $offsetType = $accessoryType->getOffsetType(); - $offsetValueType = $argType->getOffsetValueType($offsetType); - $offsetTypes[$offsetType->getValue()] = [false, $offsetValueType]; + $offsetTypes[$offsetType->getValue()] = [ + TrinaryLogic::createYes(), + $argType->getOffsetValueType($offsetType), + ]; } if ($newArrayBuilder === null) { @@ -172,7 +177,7 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } if ($offsetTypes !== []) { $knownOffsetValues = []; - foreach ($offsetTypes as $key => [$generalize, $offsetType]) { + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetType]) { if (is_int($key)) { // int keys will be appended and renumbered. // at this point we can't reason about them, because unknown arrays are in the mix. @@ -180,16 +185,18 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } $keyType = new ConstantStringType($key); - if (!$generalize) { + if ($hasOffsetValue->yes()) { // the last string-keyed offset will overwrite previous values $hasOffsetType = new HasOffsetValueType( $keyType, $offsetType, ); - } else { + } elseif ($hasOffsetValue->maybe()) { $hasOffsetType = new HasOffsetType( $keyType, ); + } else { + continue; } $knownOffsetValues[] = $hasOffsetType; diff --git a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php index 81b0de2bb2..2aade5e573 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php @@ -62,6 +62,29 @@ function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { assertType("non-empty-array&hasOffset('c')&hasOffsetValue('b', 123)", array_merge($hasC, $mixedArray, $hasB)); assertType("non-empty-array&hasOffset('b')&hasOffset('c')", array_merge($hasC, $hasB, $mixedArray)); + + if (rand(0, 1)) { + $hasBorC = ['b' => 1]; + } else { + $hasBorC = ['c' => 2]; + } + assertType('array{b: 1}|array{c: 2}', $hasBorC); + assertType("non-empty-array", array_merge($mixedArray, $hasBorC)); + assertType("non-empty-array", array_merge($hasBorC, $mixedArray)); + + if (rand(0, 1)) { + $differentCs = ['c' => 10]; + } else { + $differentCs = ['c' => 20]; + } + assertType('array{c: 10}|array{c: 20}', $differentCs); + assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($mixedArray, $differentCs)); + assertType("non-empty-array&hasOffset('c')", array_merge($differentCs, $mixedArray)); + + assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($mixedArray, $hasBorC, $differentCs)); + assertType("non-empty-array", array_merge($differentCs, $mixedArray, $hasBorC)); // could be non-empty-array&hasOffset('c') + assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_merge($hasBorC, $mixedArray, $differentCs)); + assertType("non-empty-array", array_merge($differentCs, $hasBorC, $mixedArray)); // could be non-empty-array&hasOffset('c') } /** From 87c3cb2c3fb4c8489179d9a0215fcc754a61e8ef Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 14 Nov 2025 19:30:12 +0100 Subject: [PATCH 11/12] refactor --- ...ergeFunctionDynamicReturnTypeExtension.php | 61 +++++++++---------- 1 file changed, 30 insertions(+), 31 deletions(-) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index 0839df298c..1d280be17b 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -78,27 +78,42 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, static fn (Type $argType) => $argType->isConstantArray(), ); - $offsetTypes = []; - $newArrayBuilder = null; if ($allConstant->yes()) { $newArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); - } - foreach ($argTypes as $argType) { - /** @var array $keyTypes */ - $keyTypes = []; - foreach ($argType->getConstantArrays() as $constantArray) { - foreach ($constantArray->getKeyTypes() as $keyType) { - $keyTypes[$keyType->getValue()] = $keyType; - - $hasOffsetValue = TrinaryLogic::createFromBoolean($argType->hasOffsetValueType($keyType)->yes()); - $offsetTypes[$keyType->getValue()] = [ - $hasOffsetValue, + foreach ($argTypes as $argType) { + /** @var array $keyTypes */ + $keyTypes = []; + foreach ($argType->getConstantArrays() as $constantArray) { + foreach ($constantArray->getKeyTypes() as $keyType) { + $keyTypes[$keyType->getValue()] = $keyType; + } + } + + foreach ($keyTypes as $keyType) { + $newArrayBuilder->setOffsetValueType( + $keyType instanceof ConstantIntegerType ? null : $keyType, $argType->getOffsetValueType($keyType), - ]; + !$argType->hasOffsetValueType($keyType)->yes(), + ); } } - if ($keyTypes === []) { + return $newArrayBuilder->getArray(); + } + + $offsetTypes = []; + foreach ($argTypes as $argType) { + if ($argType->isConstantArray()->yes()) { + foreach ($argType->getConstantArrays() as $constantArray) { + foreach ($constantArray->getKeyTypes() as $keyType) { + $hasOffsetValue = TrinaryLogic::createFromBoolean($argType->hasOffsetValueType($keyType)->yes()); + $offsetTypes[$keyType->getValue()] = [ + $hasOffsetValue, + $argType->getOffsetValueType($keyType), + ]; + } + } + } else { foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { $offsetTypes[$key] = [ $hasOffsetValue->and(TrinaryLogic::createMaybe()), @@ -121,22 +136,6 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $argType->getOffsetValueType($offsetType), ]; } - - if ($newArrayBuilder === null) { - continue; - } - - foreach ($keyTypes as $keyType) { - $newArrayBuilder->setOffsetValueType( - $keyType instanceof ConstantIntegerType ? null : $keyType, - $argType->getOffsetValueType($keyType), - !$argType->hasOffsetValueType($keyType)->yes(), - ); - } - } - - if ($newArrayBuilder !== null) { - return $newArrayBuilder->getArray(); } $keyTypes = []; From 059cc60ce7a8e92642d1e8ae098095b86610828e Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Fri, 14 Nov 2025 19:37:51 +0100 Subject: [PATCH 12/12] cs --- .../nsrt/array-merge-const-non-const.php | 47 +++++++++++++++---- 1 file changed, 37 insertions(+), 10 deletions(-) diff --git a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php index 2aade5e573..586d2b593b 100644 --- a/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-merge-const-non-const.php @@ -5,26 +5,41 @@ use function PHPStan\Testing\assertType; function doFoo(array $post): void { - assertType("non-empty-array&hasOffset('a')&hasOffset('b')", array_merge(['a' => 1, 'b' => false, 10 => 99], $post)); + assertType( + "non-empty-array&hasOffset('a')&hasOffset('b')", + array_merge(['a' => 1, 'b' => false, 10 => 99], $post) + ); } function doBar(array $array): void { - assertType("non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('b', false)", array_merge($array, ['a' => 1, 'b' => false, 10 => 99])); + assertType( + "non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('b', false)", + array_merge($array, ['a' => 1, 'b' => false, 10 => 99]) + ); } function doFooBar(array $array): void { - assertType("non-empty-array&hasOffset('x')&hasOffsetValue('a', 1)&hasOffsetValue('b', false)&hasOffsetValue('c', 'e')", array_merge(['c' => 'd', 'x' => 'y'], $array, ['a' => 1, 'b' => false, 'c' => 'e'])); + assertType( + "non-empty-array&hasOffset('x')&hasOffsetValue('a', 1)&hasOffsetValue('b', false)&hasOffsetValue('c', 'e')", + array_merge(['c' => 'd', 'x' => 'y'], $array, ['a' => 1, 'b' => false, 'c' => 'e']) + ); } function doFooInts(array $array): void { - assertType("non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('c', 'e')", array_merge([1 => 'd'], $array, ['a' => 1, 3 => false, 'c' => 'e'])); + assertType( + "non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('c', 'e')", + array_merge([1 => 'd'], $array, ['a' => 1, 3 => false, 'c' => 'e']) + ); } /** * @param array $array */ function floatKey(array $array): void { - assertType("non-empty-array&hasOffsetValue('a', '1')&hasOffsetValue('c', 'e')", array_merge([4.23 => 'd'], $array, ['a' => '1', 3 => 'false', 'c' => 'e'])); + assertType( + "non-empty-array&hasOffsetValue('a', '1')&hasOffsetValue('c', 'e')", + array_merge([4.23 => 'd'], $array, ['a' => '1', 3 => 'false', 'c' => 'e']) + ); } function doOptKeys(array $array, array $arr2): void { @@ -57,11 +72,23 @@ function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { assertType("non-empty-array&hasOffsetValue('b', 123)", array_merge($mixedArray, $hasB)); assertType("non-empty-array&hasOffset('b')", array_merge($hasB, $mixedArray)); - assertType("non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", array_merge($mixedArray, $hasB, $hasC)); - assertType("non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", array_merge($hasB, $mixedArray, $hasC)); - - assertType("non-empty-array&hasOffset('c')&hasOffsetValue('b', 123)", array_merge($hasC, $mixedArray, $hasB)); - assertType("non-empty-array&hasOffset('b')&hasOffset('c')", array_merge($hasC, $hasB, $mixedArray)); + assertType( + "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + array_merge($mixedArray, $hasB, $hasC) + ); + assertType( + "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + array_merge($hasB, $mixedArray, $hasC) + ); + + assertType( + "non-empty-array&hasOffset('c')&hasOffsetValue('b', 123)", + array_merge($hasC, $mixedArray, $hasB) + ); + assertType( + "non-empty-array&hasOffset('b')&hasOffset('c')", + array_merge($hasC, $hasB, $mixedArray) + ); if (rand(0, 1)) { $hasBorC = ['b' => 1];