diff --git a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php index e68f0338f7..d1ec7ab349 100644 --- a/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php +++ b/src/Type/Php/ArrayReplaceFunctionReturnTypeExtension.php @@ -5,12 +5,21 @@ use PhpParser\Node\Expr\FuncCall; use PHPStan\Analyser\Scope; use PHPStan\Reflection\FunctionReflection; +use PHPStan\TrinaryLogic; +use PHPStan\Type\Accessory\AccessoryArrayListType; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; +use PHPStan\Type\Constant\ConstantArrayType; +use PHPStan\Type\Constant\ConstantArrayTypeBuilder; +use PHPStan\Type\Constant\ConstantIntegerType; +use PHPStan\Type\Constant\ConstantStringType; use PHPStan\Type\DynamicFunctionReturnTypeExtension; +use PHPStan\Type\NeverType; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; +use function array_keys; use function count; +use function in_array; use function strtolower; final class ArrayReplaceFunctionReturnTypeExtension implements DynamicFunctionReturnTypeExtension @@ -23,54 +32,107 @@ public function isFunctionSupported(FunctionReflection $functionReflection): boo public function getTypeFromFunctionCall(FunctionReflection $functionReflection, FuncCall $functionCall, Scope $scope): ?Type { - $arrayTypes = $this->collectArrayTypes($functionCall, $scope); + $args = $functionCall->getArgs(); - if (count($arrayTypes) === 0) { + if (!isset($args[0])) { return null; } - return $this->getResultType(...$arrayTypes); - } + $argTypes = []; + $optionalArgTypes = []; + foreach ($args as $arg) { + $argType = $scope->getType($arg->value); - private function getResultType(Type ...$arrayTypes): Type - { - $keyTypes = []; - $valueTypes = []; - $nonEmptyArray = false; - foreach ($arrayTypes as $arrayType) { - if (!$nonEmptyArray && $arrayType->isIterableAtLeastOnce()->yes()) { - $nonEmptyArray = true; + if ($arg->unpack) { + if ($argType->isConstantArray()->yes()) { + foreach ($argType->getConstantArrays() as $constantArray) { + foreach ($constantArray->getValueTypes() as $valueType) { + $argTypes[] = $valueType; + } + } + } else { + $argTypes[] = $argType->getIterableValueType(); + } + + if (!$argType->isIterableAtLeastOnce()->yes()) { + // unpacked params can be empty, making them optional + $optionalArgTypesOffset = count($argTypes) - 1; + foreach (array_keys($argTypes) as $key) { + $optionalArgTypes[] = $optionalArgTypesOffset + $key; + } + } + } else { + $argTypes[] = $argType; } - - $keyTypes[] = $arrayType->getIterableKeyType(); - $valueTypes[] = $arrayType->getIterableValueType(); } - $keyType = TypeCombinator::union(...$keyTypes); - $valueType = TypeCombinator::union(...$valueTypes); + $allConstant = TrinaryLogic::createYes()->lazyAnd( + $argTypes, + static fn (Type $argType) => $argType->isConstantArray(), + ); + + 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 ($keyTypes as $keyType) { + $newArrayBuilder->setOffsetValueType( + $keyType, + $argType->getOffsetValueType($keyType), + !$argType->hasOffsetValueType($keyType)->yes(), + ); + } + } - $arrayType = new ArrayType($keyType, $valueType); - return $nonEmptyArray ? TypeCombinator::intersect($arrayType, new NonEmptyArrayType()) : $arrayType; - } + return $newArrayBuilder->getArray(); + } - /** - * @return Type[] - */ - private function collectArrayTypes(FuncCall $functionCall, Scope $scope): array - { - $args = $functionCall->getArgs(); + $keyTypes = []; + $valueTypes = []; + $nonEmpty = false; + $isList = true; + foreach ($argTypes as $key => $argType) { + $keyType = $argType->getIterableKeyType(); + $keyTypes[] = $keyType; + $valueTypes[] = $argType->getIterableValueType(); + + if (!$argType->isList()->yes()) { + $isList = false; + } - $arrayTypes = []; - foreach ($args as $arg) { - $argType = $scope->getType($arg->value); - if (!$argType->isArray()->yes()) { + if (in_array($key, $optionalArgTypes, true) || !$argType->isIterableAtLeastOnce()->yes()) { continue; } - $arrayTypes[] = $arg->unpack ? $argType->getIterableValueType() : $argType; + $nonEmpty = true; + } + + $keyType = TypeCombinator::union(...$keyTypes); + if ($keyType instanceof NeverType) { + return new ConstantArrayType([], []); + } + + $arrayType = new ArrayType( + $keyType, + TypeCombinator::union(...$valueTypes), + ); + + if ($nonEmpty) { + $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); + } + if ($isList) { + $arrayType = TypeCombinator::intersect($arrayType, new AccessoryArrayListType()); } - return $arrayTypes; + return $arrayType; } } diff --git a/tests/PHPStan/Analyser/nsrt/array-replace.php b/tests/PHPStan/Analyser/nsrt/array-replace.php index 436ce232fa..93990c4f5b 100644 --- a/tests/PHPStan/Analyser/nsrt/array-replace.php +++ b/tests/PHPStan/Analyser/nsrt/array-replace.php @@ -23,9 +23,9 @@ public function arrayReplace($array1, $array2): void */ public function arrayReplaceArrayShapes($array1, $array2): void { - assertType("non-empty-array<'bar'|'foo', '1'|'2'>", array_replace($array1)); - assertType("non-empty-array<'bar'|'foo', '1'|'2'>", array_replace([], $array1)); - assertType("non-empty-array<'bar'|'foo', '1'|'2'|'4'>", array_replace($array1, $array2)); + assertType("array{foo: '1', bar: '2'}", array_replace($array1)); + assertType("array{foo: '1', bar: '2'}", array_replace([], $array1)); + assertType("array{foo: '1', bar: '4'}", array_replace($array1, $array2)); } /** @@ -68,4 +68,38 @@ public function arrayReplaceUnionTypeArrayShapes($array1, $array2): void assertType("array", array_replace($array1, $array2)); assertType("array", array_replace($array2, $array1)); } + + /** + * @param array{foo: '1', bar: '2'} $array1 + * @param array $array2 + * @param array $array3 + */ + 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<'bar'|'foo'|int, string>", array_replace($array1, $array3)); + assertType("non-empty-array<'bar'|'foo'|int, string>", array_replace($array3, $array1)); + + assertType("array", array_replace($array2, $array3)); + } + + /** + * @param array{0: 1, 1: 2} $array1 + * @param array{1: 3, 2: 4} $array2 + */ + public function arrayReplaceNumericKeys($array1, $array2): void + { + assertType("array{1, 3, 4}", array_replace($array1, $array2)); + } + + /** + * @param list $array1 + * @param list $array2 + */ + public function arrayReplaceLists($array1, $array2): void + { + assertType("list", array_replace($array1, $array2)); + } } diff --git a/tests/PHPStan/Analyser/nsrt/bug-12828.php b/tests/PHPStan/Analyser/nsrt/bug-12828.php new file mode 100644 index 0000000000..db3437cc5e --- /dev/null +++ b/tests/PHPStan/Analyser/nsrt/bug-12828.php @@ -0,0 +1,10 @@ + 'def', 'hello' => 'world']; +assertType("array{abc: 'def', hello: 'world'}", $a); +$a = array_replace($a, ['hello' => 'country']); +assertType("array{abc: 'def', hello: 'country'}", $a); diff --git a/tests/PHPStan/Analyser/nsrt/native-types.php b/tests/PHPStan/Analyser/nsrt/native-types.php index 3f09d923cb..f932d6b57a 100644 --- a/tests/PHPStan/Analyser/nsrt/native-types.php +++ b/tests/PHPStan/Analyser/nsrt/native-types.php @@ -372,7 +372,7 @@ class TestPhp8Stubs public function doFoo(): void { $a = array_replace([1, 2, 3], [4, 5, 6]); - assertType('non-empty-array<0|1|2, 1|2|3|4|5|6>', $a); + assertType('array{4, 5, 6}', $a); assertNativeType('array', $a); }