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(), diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index fdb8066f60..422aa9e2d7 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,12 +17,15 @@ 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; +use function is_string; use function strtolower; #[AutowiredService] @@ -97,6 +102,46 @@ 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]) { + // more precise values-types will be calculated elsewhere. + // just remember the offset key. + $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 +178,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..9420d6f13b --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/array-replace-const-non-const.php @@ -0,0 +1,125 @@ + 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']) + ); +} + +/** + * @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)); +} + +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); +} + +/** + * @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)); +} 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)); }