diff --git a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php index ef48894937..ce04dc3775 100644 --- a/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php +++ b/src/Type/Php/ArrayMergeFunctionDynamicReturnTypeExtension.php @@ -8,11 +8,11 @@ use PHPStan\Reflection\ParametersAcceptorSelector; use PHPStan\Type\Accessory\NonEmptyArrayType; use PHPStan\Type\ArrayType; +use PHPStan\Type\Constant\ConstantArrayTypeBuilder; use PHPStan\Type\GeneralizePrecision; use PHPStan\Type\Type; use PHPStan\Type\TypeCombinator; use PHPStan\Type\TypeUtils; -use PHPStan\Type\UnionType; class ArrayMergeFunctionDynamicReturnTypeExtension implements \PHPStan\Type\DynamicFunctionReturnTypeExtension { @@ -30,20 +30,33 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $keyTypes = []; $valueTypes = []; + $returnedArrayBuilder = ConstantArrayTypeBuilder::createEmpty(); + $returnedArrayBuilderFilled = false; $nonEmpty = false; foreach ($functionCall->args as $arg) { $argType = $scope->getType($arg->value); + if ($arg->unpack) { $argType = $argType->getIterableValueType(); - if ($argType instanceof UnionType) { - foreach ($argType->getTypes() as $innerType) { - $argType = $innerType; + } + + $arrays = TypeUtils::getConstantArrays($argType); + if (count($arrays) > 0) { + foreach ($arrays as $constantArray) { + foreach ($constantArray->getKeyTypes() as $i => $keyType) { + $returnedArrayBuilderFilled = true; + + $returnedArrayBuilder->setOffsetValueType( + is_numeric($keyType->getValue()) ? null : $keyType, + $constantArray->getValueTypes()[$i] + ); } } - } - $keyTypes[] = TypeUtils::generalizeType($argType->getIterableKeyType(), GeneralizePrecision::moreSpecific()); - $valueTypes[] = $argType->getIterableValueType(); + } else { + $keyTypes[] = TypeUtils::generalizeType($argType->getIterableKeyType(), GeneralizePrecision::moreSpecific()); + $valueTypes[] = $argType->getIterableValueType(); + } if (!$argType->isIterableAtLeastOnce()->yes()) { continue; @@ -52,10 +65,23 @@ public function getTypeFromFunctionCall(FunctionReflection $functionReflection, $nonEmpty = true; } - $arrayType = new ArrayType( - TypeCombinator::union(...$keyTypes), - TypeCombinator::union(...$valueTypes) - ); + if (count($keyTypes) > 0) { + $arrayType = new ArrayType( + TypeCombinator::union(...$keyTypes), + TypeCombinator::union(...$valueTypes) + ); + + if ($returnedArrayBuilderFilled) { + $arrayType = TypeCombinator::union($returnedArrayBuilder->getArray(), $arrayType); + } + } elseif ($returnedArrayBuilderFilled) { + $arrayType = $returnedArrayBuilder->getArray(); + } else { + $arrayType = new ArrayType( + TypeCombinator::union(...$keyTypes), + TypeCombinator::union(...$valueTypes) + ); + } if ($nonEmpty) { $arrayType = TypeCombinator::intersect($arrayType, new NonEmptyArrayType()); diff --git a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php index ef8ff856e2..c12cd88fa2 100644 --- a/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/LegacyNodeScopeResolverTest.php @@ -5153,7 +5153,7 @@ public function dataArrayFunctions(): array 'array_values($generalStringKeys)', ], [ - 'array&nonEmpty', + "array('foo' => stdClass, 0 => stdClass)", 'array_merge($stringOrIntegerKeys)', ], [ @@ -5161,23 +5161,36 @@ public function dataArrayFunctions(): array 'array_merge($generalStringKeys, $generalDateTimeValues)', ], [ - 'array&nonEmpty', + 'array<0|string, int|stdClass>&nonEmpty', 'array_merge($generalStringKeys, $stringOrIntegerKeys)', ], [ - 'array&nonEmpty', + 'array<0|string, int|stdClass>&nonEmpty', 'array_merge($stringOrIntegerKeys, $generalStringKeys)', ], [ - 'array&nonEmpty', + "array('foo' => stdClass, 'bar' => stdClass, 0 => stdClass)", 'array_merge($stringKeys, $stringOrIntegerKeys)', ], [ - 'array&nonEmpty', + "array('foo' => 1, 'bar' => 2, 0 => 2, 1 => 3)", + "array_merge(['foo' => 4, 'bar' => 5], ...[['foo' => 1, 'bar' => 2], [2, 3]])", + ], + [ + "array('foo' => 1, 'foo2' => stdClass)", + 'array_merge([\'foo\' => new stdClass()], ...[[\'foo2\' => new stdClass()], [\'foo\' => 1]])', + ], + + [ + "array('foo' => 1, 'foo2' => stdClass)", + 'array_merge([\'foo\' => new stdClass()], ...[[\'foo2\' => new stdClass()], [\'foo\' => 1]])', + ], + [ + "array('foo' => 'foo', 0 => stdClass, 'bar' => stdClass)", 'array_merge($stringOrIntegerKeys, $stringKeys)', ], [ - 'array&nonEmpty', + "array('color' => 'green', 0 => 2, 1 => 4, 2 => 'a', 3 => 'b', 'shape' => 'trapezoid', 4 => 4)", 'array_merge(array("color" => "red", 2, 4), array("a", "b", "color" => "green", "shape" => "trapezoid", 4))', ], [ diff --git a/tests/PHPStan/Analyser/NodeScopeResolverTest.php b/tests/PHPStan/Analyser/NodeScopeResolverTest.php index 24e5ca4538..7490f3a69f 100644 --- a/tests/PHPStan/Analyser/NodeScopeResolverTest.php +++ b/tests/PHPStan/Analyser/NodeScopeResolverTest.php @@ -134,6 +134,8 @@ public function dataFileAsserts(): iterable yield from $this->gatherAssertTypes(__DIR__ . '/data/throw-expr.php'); } + yield from $this->gatherAssertTypes(__DIR__ . '/data/array-merge.php'); + yield from $this->gatherAssertTypes(__DIR__ . '/data/non-empty-array.php'); if (PHP_VERSION_ID >= 80000 || self::$useStaticReflectionProvider) { diff --git a/tests/PHPStan/Analyser/data/array-merge.php b/tests/PHPStan/Analyser/data/array-merge.php new file mode 100644 index 0000000000..960db00a48 --- /dev/null +++ b/tests/PHPStan/Analyser/data/array-merge.php @@ -0,0 +1,75 @@ + 'first', + 'limit' => PHP_INT_MAX, + ]; + + /** + * @param array $settings + */ + public function arrayMergeWithConst(array $settings): void + { + $settings = array_merge(self::DEFAULT_SETTINGS, $settings); + + assertType("array&nonEmpty", $settings); + } + + /** + * @param array{foo: '1', bar: '2', lall: '3', 2: '2', 3: '3'} $array1 + * @param array{foo: '1', bar: '4', lall2: '3', 2: '4', 3: '6'} $array2 + */ + public function arrayMergeArrayShapes($array1, $array2): void + { + assertType("array('foo' => '1', 'bar' => '2', 'lall' => '3', 0 => '2', 1 => '3')", array_merge($array1)); + assertType("array('foo' => '1', 'bar' => '2', 'lall' => '3', 0 => '2', 1 => '3')", array_merge([], $array1)); + assertType("array('foo' => '1', 'bar' => '2', 'lall' => '3', 0 => '2', 1 => '3')", array_merge($array1, [])); + assertType("array('foo' => '1', 'bar' => '2', 'lall' => '3', 0 => '2', 1 => '3', 2 => '2', 3 => '3')", array_merge($array1, $array1)); + assertType("array('foo' => '1', 'bar' => '4', 'lall' => '3', 0 => '2', 1 => '3', 'lall2' => '3', 2 => '4', 3 => '6')", array_merge($array1, $array2)); + assertType("array('foo' => '1', 'bar' => '2', 'lall2' => '3', 0 => '4', 1 => '6', 'lall' => '3', 2 => '2', 3 => '3')", array_merge($array2, $array1)); + assertType("array('foo' => 3, 'bar' => '2', 'lall2' => '3', 0 => '4', 1 => '6', 'lall' => '3', 2 => '2', 3 => '3')", array_merge($array2, $array1, ['foo' => 3])); + assertType("array('foo' => 3, 'bar' => '2', 'lall2' => '3', 0 => '4', 1 => '6', 'lall' => '3', 2 => '2', 3 => '3')", array_merge($array2, $array1, ...[['foo' => 3]])); + } + + /** + * @param int[] $array1 + * @param string[] $array2 + */ + public function arrayMergeSimple($array1, $array2): void + { + assertType("array", array_merge($array1, $array1)); + assertType("array", array_merge($array1, $array2)); + assertType("array", array_merge($array2, $array1)); + + assertType("array('foo' => '')", array_merge(['foo' => ''])); // issue #2567 + } + + /** + * @param array $array1 + * @param array $array2 + */ + public function arrayMergeUnionType($array1, $array2): void + { + assertType("array", array_merge($array1, $array1)); + assertType("array", array_merge($array1, $array2)); + assertType("array", array_merge($array2, $array1)); + } + + /** + * @param array $array1 + * @param array $array2 + */ + public function arrayMergeUnionTypeArrayShapes($array1, $array2): void + { + assertType("array '2')|array('foo' => '1')>", array_merge($array1, $array1)); + assertType("array '2'|'3')|array('foo' => '1'|'2')>", array_merge($array1, $array2)); + assertType("array '2'|'3')|array('foo' => '1'|'2')>", array_merge($array2, $array1)); + } +}