From 3464c8b4766bd3a3dfe00f9e6051eb0c4ba0358c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 16:37:45 +0100 Subject: [PATCH 1/7] Don't lose known offset-types in array_replace() --- ...rrayReplaceFunctionReturnTypeExtension.php | 68 +++++++++++++++++++ .../nsrt/array-replace-const-non-const.php | 27 ++++++++ tests/PHPStan/Analyser/nsrt/array-replace.php | 8 +-- 3 files changed, 99 insertions(+), 4 deletions(-) create mode 100644 tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index fdb8066f60..4dd1644936 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -8,6 +8,8 @@ use PHPStan\Reflection\FunctionReflection; 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; @@ -15,9 +17,11 @@ use PHPStan\Type\Constant\ConstantIntegerType; use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; +use PHPStan\Type\MixedType; 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; @@ -97,6 +101,45 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, return $newArrayBuilder->getArray(); } + $offsetTypes = []; + foreach ($argTypes as $argType) { + $constArrays = $argType->getConstantArrays(); + if ($constArrays !== []) { + foreach ($constArrays 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()), + new MixedType(), + ]; + } + } + + foreach (TypeUtils::getAccessoryTypes($argType) as $accessoryType) { + if ( + !($accessoryType instanceof HasOffsetType) + && !($accessoryType instanceof HasOffsetValueType) + ) { + continue; + } + + $offsetType = $accessoryType->getOffsetType(); + $offsetTypes[$offsetType->getValue()] = [ + TrinaryLogic::createYes(), + $argType->getOffsetValueType($offsetType), + ]; + } + } + + $keyTypes = []; $valueTypes = []; $nonEmpty = false; @@ -133,6 +176,31 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, if ($isList) { $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); } + if ($offsetTypes !== []) { + $knownOffsetValues = []; + foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetType]) { + $keyType = is_string($key) ? new ConstantStringType($key) : new ConstantIntegerType($key); + + if ($hasOffsetValue->yes()) { + // the last known offset will overwrite previous values + $hasOffsetType = new HasOffsetValueType( + $keyType, + $offsetType, + ); + } elseif ($hasOffsetValue->maybe()) { + $hasOffsetType = new HasOffsetType( + $keyType, + ); + } else { + continue; + } + + $knownOffsetValues[] = $hasOffsetType; + } + if ($knownOffsetValues !== []) { + $arrayType = TypeCombinator::intersect($arrayType, ...$knownOffsetValues); + } + } return $arrayType; } diff --git a/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php new file mode 100644 index 0000000000..23d1c2c287 --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php @@ -0,0 +1,27 @@ + 1, 'b' => false, 10 => 99], $post) + ); +} + +function doBar(array $array): void { + assertType( + "non-empty-array&hasOffsetValue('a', 1)&hasOffsetValue('b', false)&hasOffsetValue(10, 99)", + array_replace($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_replace(['c' => 'd', 'x' => 'y'], $array, ['a' => 1, 'b' => false, 'c' => 'e']) + ); +} diff --git a/tests/PHPStan/Analyser/nsrt/array-replace.php b/tests/PHPStan/Analyser/nsrt/array-replace.php index 93990c4f5b..0b22a8f74a 100644 --- a/tests/PHPStan/Analyser/nsrt/array-replace.php +++ b/tests/PHPStan/Analyser/nsrt/array-replace.php @@ -76,11 +76,11 @@ public function arrayReplaceUnionTypeArrayShapes($array1, $array2): void */ public function arrayReplaceArrayShapeAndGeneralArray($array1, $array2, $array3): void { - assertType("non-empty-array", array_replace($array1, $array2)); - assertType("non-empty-array", array_replace($array2, $array1)); + assertType("non-empty-array&hasOffset('bar')&hasOffset('foo')", array_replace($array1, $array2)); + assertType("non-empty-array&hasOffsetValue('bar', '2')&hasOffsetValue('foo', '1')", array_replace($array2, $array1)); - assertType("non-empty-array<'bar'|'foo'|int, string>", array_replace($array1, $array3)); - assertType("non-empty-array<'bar'|'foo'|int, string>", array_replace($array3, $array1)); + assertType("non-empty-array<'bar'|'foo'|int, string>&hasOffset('bar')&hasOffset('foo')", array_replace($array1, $array3)); + assertType("non-empty-array<'bar'|'foo'|int, string>&hasOffsetValue('bar', '2')&hasOffsetValue('foo', '1')", array_replace($array3, $array1)); assertType("array", array_replace($array2, $array3)); } From 04d40248ce839fe60b0528e3615310aa09c64964 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 17:08:03 +0100 Subject: [PATCH 2/7] more tests --- .../nsrt/array-replace-const-non-const.php | 82 +++++++++++++++++++ 1 file changed, 82 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php index 23d1c2c287..23649c51f7 100644 --- a/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php @@ -25,3 +25,85 @@ function doFooBar(array $array): void { array_replace(['c' => 'd', 'x' => 'y'], $array, ['a' => 1, 'b' => false, 'c' => 'e']) ); } + +/** + * @param array{a?: 1, b: 2} $array + */ +function doOptShapeKeys(array $array, array $arr2): void { + assertType("non-empty-array&hasOffsetValue('b', 2)", array_replace($arr2, $array)); + assertType("non-empty-array&hasOffset('b')", array_replace($array, $arr2)); +} + +function hasOffsetKeys(array $array, array $arr2): void { + if (array_key_exists('b', $array)) { + assertType("non-empty-array&hasOffsetValue('b', mixed)", array_replace($arr2, $array)); + assertType("non-empty-array&hasOffset('b')", array_replace($array, $arr2)); + } +} + +function maybeHasOffsetKeys(array $array): void { + $arr2 = []; + if (rand(0,1)) { + $arr2 ['ab'] = 'def'; + } + + assertType("array", array_replace($arr2, $array)); + assertType("array", array_replace($array, $arr2)); +} + +function hasOffsetValueKeys(array $hasB, array $mixedArray, array $hasC): void { + $hasB['b'] = 123; + $hasC['c'] = 'def'; + + assertType("non-empty-array&hasOffsetValue('b', 123)", array_replace($mixedArray, $hasB)); + assertType("non-empty-array&hasOffset('b')", array_replace($hasB, $mixedArray)); + + assertType( + "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + array_replace($mixedArray, $hasB, $hasC) + ); + assertType( + "non-empty-array&hasOffset('b')&hasOffsetValue('c', 'def')", + array_replace($hasB, $mixedArray, $hasC) + ); + + assertType( + "non-empty-array&hasOffset('c')&hasOffsetValue('b', 123)", + array_replace($hasC, $mixedArray, $hasB) + ); + assertType( + "non-empty-array&hasOffset('b')&hasOffset('c')", + array_replace($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_replace($mixedArray, $hasBorC)); + assertType("non-empty-array", array_replace($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_replace($mixedArray, $differentCs)); + assertType("non-empty-array&hasOffset('c')", array_replace($differentCs, $mixedArray)); + + assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_replace($mixedArray, $hasBorC, $differentCs)); + assertType("non-empty-array", array_replace($differentCs, $mixedArray, $hasBorC)); // could be non-empty-array&hasOffset('c') + assertType("non-empty-array&hasOffsetValue('c', 10|20)", array_replace($hasBorC, $mixedArray, $differentCs)); + assertType("non-empty-array", array_replace($differentCs, $hasBorC, $mixedArray)); // could be non-empty-array&hasOffset('c') +} + +/** + * @param array{a?: 1, b?: 2} $allOptional + */ +function doAllOptional(array $allOptional, array $arr2): void { + assertType("array", array_replace($arr2, $allOptional)); + assertType("array", array_replace($allOptional, $arr2)); +} From 4f502b33dcac9a8a4c620bb61f83362075af822c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 17:12:50 +0100 Subject: [PATCH 3/7] more tests --- .../Analyser/nsrt/array-replace-const-non-const.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php index 23649c51f7..442c0917d1 100644 --- a/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php @@ -107,3 +107,11 @@ function doAllOptional(array $allOptional, array $arr2): void { assertType("array", array_replace($arr2, $allOptional)); assertType("array", array_replace($allOptional, $arr2)); } + +function withArrayReplacement(array $base): void { + $replacements = [ 'citrus' => [ 'grapefruit' ] ]; + $replacements2 = [ 'citrus' => [ 'kumquat', 'citron' ], 'pome' => [ 'loquat' ] ]; + + $basket = array_replace($base, $replacements, $replacements2); + assertType("non-empty-array&hasOffsetValue('citrus', array{'kumquat', 'citron'})&hasOffsetValue('pome', array{'loquat'})", $basket); +} From 18c828a35f8f36ccad54abf8d182a1dc74570d08 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Tue, 27 Jan 2026 17:13:15 +0100 Subject: [PATCH 4/7] cs --- src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index 4dd1644936..7da0b428ba 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -25,6 +25,7 @@ use function array_keys; use function count; use function in_array; +use function is_string; use function strtolower; #[AutowiredService] @@ -139,7 +140,6 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } - $keyTypes = []; $valueTypes = []; $nonEmpty = false; From 13693a75df4b7c4b549d71c2db793c1353683094 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 28 Jan 2026 09:23:25 +0100 Subject: [PATCH 5/7] more tests --- .../Analyser/nsrt/array-replace-const-non-const.php | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php index 442c0917d1..9420d6f13b 100644 --- a/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php +++ b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php @@ -115,3 +115,11 @@ function withArrayReplacement(array $base): void { $basket = array_replace($base, $replacements, $replacements2); assertType("non-empty-array&hasOffsetValue('citrus', array{'kumquat', 'citron'})&hasOffsetValue('pome', array{'loquat'})", $basket); } + +/** + * @param array{foo: int, x: string}|array{foo: string, y: 1} $arr1 + */ +function doUnions(array $arr1, array $arr2): void { + assertType("non-empty-array&hasOffset('foo')", array_replace($arr1, $arr2)); + assertType("non-empty-array&hasOffsetValue('foo', int|string)", array_replace($arr2, $arr1)); +} From 146c533ae31417d071a63d9b664a5b4526cadf8c Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 28 Jan 2026 09:26:24 +0100 Subject: [PATCH 6/7] Update ArrayReplaceFunctionReturnTypeExtension.php --- src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index 7da0b428ba..422aa9e2d7 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -117,6 +117,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } else { foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { + // more precise values-types will be calculated elsewhere. + // just remember the offset key. $offsetTypes[$key] = [ $hasOffsetValue->and(TrinaryLogic::createMaybe()), new MixedType(), From dcdfcefb702decac3c53c43287ba26109f2d26c3 Mon Sep 17 00:00:00 2001 From: Markus Staab Date: Wed, 28 Jan 2026 09:28:37 +0100 Subject: [PATCH 7/7] reflect new comment --- src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index f567c0f578..1283fa610a 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -116,6 +116,8 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, } } else { foreach ($offsetTypes as $key => [$hasOffsetValue, $offsetValueType]) { + // more precise values-types will be calculated elsewhere. + // just remember the offset key. $offsetTypes[$key] = [ $hasOffsetValue->and(TrinaryLogic::createMaybe()), new MixedType(),