From 4e594e0a65451909e89e3109915d20b8374bb18b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Bate=C4=8Dko?= Date: Tue, 26 Nov 2019 20:48:49 +0100 Subject: [PATCH] Improve array function list handling (#2377) * array_column() returns a list unless the 3rd arg is passed * array_pad() return type provider * array_chunk() return type provider * array_map() preserve list types --- .../Provider/FunctionReturnTypeProvider.php | 2 + .../ArrayChunkReturnTypeProvider.php | 49 ++++ .../ArrayColumnReturnTypeProvider.php | 20 +- .../ArrayMapReturnTypeProvider.php | 96 ++++---- .../ArrayPadReturnTypeProvider.php | 61 +++++ .../Internal/Stubs/CoreGenericFunctions.php | 12 - src/Psalm/Internal/Type/ArrayType.php | 59 +++++ tests/FunctionCallTest.php | 233 +++++++++++++++++- 8 files changed, 450 insertions(+), 82 deletions(-) create mode 100644 src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayChunkReturnTypeProvider.php create mode 100644 src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayPadReturnTypeProvider.php create mode 100644 src/Psalm/Internal/Type/ArrayType.php diff --git a/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php b/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php index 58267624a..90e0efe15 100644 --- a/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/FunctionReturnTypeProvider.php @@ -31,10 +31,12 @@ class FunctionReturnTypeProvider { self::$handlers = []; + $this->registerClass(ReturnTypeProvider\ArrayChunkReturnTypeProvider::class); $this->registerClass(ReturnTypeProvider\ArrayColumnReturnTypeProvider::class); $this->registerClass(ReturnTypeProvider\ArrayFilterReturnTypeProvider::class); $this->registerClass(ReturnTypeProvider\ArrayMapReturnTypeProvider::class); $this->registerClass(ReturnTypeProvider\ArrayMergeReturnTypeProvider::class); + $this->registerClass(ReturnTypeProvider\ArrayPadReturnTypeProvider::class); $this->registerClass(ReturnTypeProvider\ArrayPointerAdjustmentReturnTypeProvider::class); $this->registerClass(ReturnTypeProvider\ArrayPopReturnTypeProvider::class); $this->registerClass(ReturnTypeProvider\ArrayRandReturnTypeProvider::class); diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayChunkReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayChunkReturnTypeProvider.php new file mode 100644 index 000000000..89317831c --- /dev/null +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayChunkReturnTypeProvider.php @@ -0,0 +1,49 @@ += 2 + && ($array_arg_type = $statements_source->getNodeTypeProvider()->getType($call_args[0]->value)) + && $array_arg_type->isSingle() + && $array_arg_type->hasArray() + && ($array_type = ArrayType::infer($array_arg_type->getTypes()['array'])) + ) { + $preserve_keys = isset($call_args[2]) + && ($preserve_keys_arg_type = $statements_source->getNodeTypeProvider()->getType($call_args[2]->value)) + && (string) $preserve_keys_arg_type !== 'false'; + + return new Type\Union([ + new Type\Atomic\TList( + new Type\Union([ + $preserve_keys + ? new Type\Atomic\TNonEmptyArray([$array_type->key, $array_type->value]) + : new Type\Atomic\TNonEmptyList($array_type->value) + ]) + ) + ]); + } + + return new Type\Union([new Type\Atomic\TList(Type::getArray())]); + } +} diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayColumnReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayColumnReturnTypeProvider.php index 6526ab6c1..758c0f3c7 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayColumnReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayColumnReturnTypeProvider.php @@ -67,6 +67,7 @@ class ArrayColumnReturnTypeProvider implements \Psalm\Plugin\Hook\FunctionReturn } $key_column_name = null; + $third_arg_type = null; // calculate key column name if (isset($call_args[2]) && ($third_arg_type = $statements_source->node_data->getType($call_args[2]->value)) @@ -93,19 +94,10 @@ class ArrayColumnReturnTypeProvider implements \Psalm\Plugin\Hook\FunctionReturn } } - if ($result_element_type) { - return new Type\Union([ - new Type\Atomic\TArray([ - $result_key_type, - $result_element_type, - ]), - ]); - } - - $callmap_callables = CallMap::getCallablesFromCallMap($function_id); - - assert($callmap_callables && $callmap_callables[0]->return_type); - - return $callmap_callables[0]->return_type; + return new Type\Union([ + isset($call_args[2]) && (string) $third_arg_type !== 'null' + ? new Type\Atomic\TArray([$result_key_type, $result_element_type ?? Type::getMixed()]) + : new Type\Atomic\TList($result_element_type ?? Type::getMixed()) + ]); } } diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMapReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMapReturnTypeProvider.php index 6e460ed7b..5e5ec7696 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMapReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayMapReturnTypeProvider.php @@ -11,6 +11,7 @@ use Psalm\Context; use Psalm\Internal\Analyzer\Statements\Expression\CallAnalyzer; use Psalm\Internal\Analyzer\Statements\ExpressionAnalyzer; use Psalm\Internal\Codebase\CallMap; +use Psalm\Internal\Type\ArrayType; use Psalm\StatementsSource; use Psalm\Type; use function strpos; @@ -41,16 +42,14 @@ class ArrayMapReturnTypeProvider implements \Psalm\Plugin\Hook\FunctionReturnTyp $array_arg = isset($call_args[1]->value) ? $call_args[1]->value : null; $array_arg_atomic_type = null; + $array_arg_type = null; - if ($array_arg && ($array_arg_type = $statements_source->node_data->getType($array_arg))) { - $arg_types = $array_arg_type->getTypes(); + if ($array_arg && ($array_arg_union_type = $statements_source->node_data->getType($array_arg))) { + $arg_types = $array_arg_union_type->getTypes(); - if (isset($arg_types['array']) - && ($arg_types['array'] instanceof Type\Atomic\TArray - || $arg_types['array'] instanceof Type\Atomic\ObjectLike - || $arg_types['array'] instanceof Type\Atomic\TList) - ) { + if (isset($arg_types['array'])) { $array_arg_atomic_type = $arg_types['array']; + $array_arg_type = ArrayType::infer($array_arg_atomic_type); } } @@ -58,15 +57,7 @@ class ArrayMapReturnTypeProvider implements \Psalm\Plugin\Hook\FunctionReturnTyp $function_call_arg = $call_args[0]; if (count($call_args) === 2) { - if ($array_arg_atomic_type instanceof Type\Atomic\ObjectLike) { - $generic_key_type = $array_arg_atomic_type->getGenericKeyType(); - } elseif ($array_arg_atomic_type instanceof Type\Atomic\TList) { - $generic_key_type = Type::getInt(); - } else { - $generic_key_type = $array_arg_atomic_type - ? clone $array_arg_atomic_type->type_params[0] - : Type::getArrayKey(); - } + $generic_key_type = $array_arg_type->key ?? Type::getArrayKey(); } else { $generic_key_type = Type::getInt(); } @@ -84,19 +75,20 @@ class ArrayMapReturnTypeProvider implements \Psalm\Plugin\Hook\FunctionReturnTyp $inner_type = clone $closure_return_type; if ($array_arg_atomic_type instanceof Type\Atomic\ObjectLike && count($call_args) === 2) { - return new Type\Union([ - new Type\Atomic\ObjectLike( - array_map( - /** - * @return Type\Union - */ - function (Type\Union $_) use ($inner_type) { - return clone $inner_type; - }, - $array_arg_atomic_type->properties - ) - ), - ]); + $atomic_type = new Type\Atomic\ObjectLike( + array_map( + /** + * @return Type\Union + */ + function (Type\Union $_) use ($inner_type) { + return clone $inner_type; + }, + $array_arg_atomic_type->properties + ) + ); + $atomic_type->is_list = $array_arg_atomic_type->is_list; + + return new Type\Union([$atomic_type]); } if ($array_arg_atomic_type instanceof Type\Atomic\TList) { @@ -258,31 +250,41 @@ class ArrayMapReturnTypeProvider implements \Psalm\Plugin\Hook\FunctionReturnTyp if ($mapping_return_type) { if ($array_arg_atomic_type instanceof Type\Atomic\ObjectLike && count($call_args) === 2) { - return new Type\Union([ - new Type\Atomic\ObjectLike( - array_map( - /** - * @return Type\Union - */ - function (Type\Union $_) use ($mapping_return_type) { - return clone $mapping_return_type; - }, - $array_arg_atomic_type->properties - ) - ), - ]); + $atomic_type = new Type\Atomic\ObjectLike( + array_map( + /** + * @return Type\Union + */ + function (Type\Union $_) use ($mapping_return_type) { + return clone $mapping_return_type; + }, + $array_arg_atomic_type->properties + ) + ); + $atomic_type->is_list = $array_arg_atomic_type->is_list; + + return new Type\Union([$atomic_type]); } return new Type\Union([ - new Type\Atomic\TArray([ - $generic_key_type, - $mapping_return_type, - ]), + count($call_args) === 2 && !($array_arg_type->is_list ?? false) + ? new Type\Atomic\TArray([ + $generic_key_type, + $mapping_return_type, + ]) + : new Type\Atomic\TList($mapping_return_type) ]); } } } - return Type::getArray(); + return count($call_args) === 2 && !($array_arg_type->is_list ?? false) + ? new Type\Union([ + new Type\Atomic\TArray([ + $array_arg_type->key ?? Type::getArrayKey(), + Type::getMixed(), + ]) + ]) + : Type::getList(); } } diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayPadReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayPadReturnTypeProvider.php new file mode 100644 index 000000000..dfd92bef1 --- /dev/null +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/ArrayPadReturnTypeProvider.php @@ -0,0 +1,61 @@ +getNodeTypeProvider(); + + if (count($call_args) >= 3 + && ($array_arg_type = $type_provider->getType($call_args[0]->value)) + && ($size_arg_type = $type_provider->getType($call_args[1]->value)) + && ($value_arg_type = $type_provider->getType($call_args[2]->value)) + && $array_arg_type->isSingle() + && $array_arg_type->hasArray() + && ($array_type = ArrayType::infer($array_arg_type->getTypes()['array'])) + ) { + $codebase = $statements_source->getCodebase(); + $key_type = Type::combineUnionTypes($array_type->key, Type::getInt(), $codebase); + $value_type = Type::combineUnionTypes($array_type->value, $value_arg_type, $codebase); + $can_return_empty = ( + !$size_arg_type->isSingleIntLiteral() + || $size_arg_type->getSingleIntLiteral()->value === 0 + ); + + return new Type\Union([ + $array_type->is_list + ? ( + $can_return_empty + ? new Type\Atomic\TList($value_type) + : new Type\Atomic\TNonEmptyList($value_type) + ) + : ( + $can_return_empty + ? new Type\Atomic\TArray([$key_type, $value_type]) + : new Type\Atomic\TNonEmptyArray([$key_type, $value_type]) + ) + ]); + } + + return Type::getArray(); + } +} diff --git a/src/Psalm/Internal/Stubs/CoreGenericFunctions.php b/src/Psalm/Internal/Stubs/CoreGenericFunctions.php index 9d5fc6c01..1eca61da5 100644 --- a/src/Psalm/Internal/Stubs/CoreGenericFunctions.php +++ b/src/Psalm/Internal/Stubs/CoreGenericFunctions.php @@ -152,18 +152,6 @@ function array_change_key_case(array $arr, int $case = CASE_LOWER) { } -/** - * @psalm-template T - * - * @param array $arr - * - * @return array> - * @psalm-pure - */ -function array_chunk(array $arr, int $size, bool $preserve_keys = false) -{ -} - /** * @psalm-template TKey as array-key * diff --git a/src/Psalm/Internal/Type/ArrayType.php b/src/Psalm/Internal/Type/ArrayType.php new file mode 100644 index 000000000..332a001a5 --- /dev/null +++ b/src/Psalm/Internal/Type/ArrayType.php @@ -0,0 +1,59 @@ +key = $key; + $this->value = $value; + $this->is_list = $is_list; + } + + /** + * @return static|null + */ + public static function infer(Type\Atomic $type): ?self + { + if ($type instanceof Type\Atomic\ObjectLike) { + return new static( + $type->getGenericKeyType(), + $type->getGenericValueType(), + $type->is_list + ); + } + + if ($type instanceof Type\Atomic\TList) { + return new static( + Type::getInt(), + $type->type_param, + true + ); + } + + if ($type instanceof Type\Atomic\TArray) { + return new static( + $type->type_params[0], + $type->type_params[1], + false + ); + } + + return null; + } +} diff --git a/tests/FunctionCallTest.php b/tests/FunctionCallTest.php index da0dd5e11..a82569b2e 100644 --- a/tests/FunctionCallTest.php +++ b/tests/FunctionCallTest.php @@ -819,6 +819,16 @@ class FunctionCallTest extends TestCase '$foo' => 'float|int', ], ], + 'arrayMapWithArrayAndCallable' => [ + ' + */ + function foo(array $v): array { + $r = array_map("intval", $v); + return $r; + }', + ], 'arrayMapObjectLikeAndCallable' => [ ' [ + ' $list */ + function takesList(array $list): void {} + + takesList( + array_map( + "intval", + ["1", "2", "3"] + ) + );', + ], 'arrayMapObjectLikeAndClosure' => [ ' [ + ' $list */ + function takesList(array $list): void {} + + takesList( + array_map( + function (string $str): string { return $str . "x"; }, + ["foo", "bar", "baz"] + ) + );', + ], + 'arrayMapUntypedCallable' => [ + ' $array + */ + $a = array_map($callable, $array); + + /** + * @var callable $callable + * @var array $array + */ + $b = array_map($callable, $array, $array); + + /** + * @var callable $callable + * @var list $list + */ + $c = array_map($callable, $list); + + /** + * @var callable $callable + * @var list $list + */ + $d = array_map($callable, $list, $list);', + 'assertions' => [ + '$a' => 'array', + '$b' => 'list', + '$c' => 'list', + '$d' => 'list', + ], + ], 'arrayFilterGoodArgs' => [ ' "a", "v" => 1], ["k" => "b", "v" => 2]], "v", "k"); $d = array_column([], 0); $e = array_column(makeMixedArray(), 0); - $f = array_column(makeGenericArray(), 0); - $g = array_column(makeShapeArray(), 0); - $h = array_column(makeUnionArray(), 0); + $f = array_column(makeMixedArray(), 0, "k"); + $g = array_column(makeMixedArray(), 0, null); + $h = array_column(makeGenericArray(), 0); + $i = array_column(makeShapeArray(), 0); + $j = array_column(makeUnionArray(), 0); ', 'assertions' => [ - '$a' => 'array', - '$b' => 'array', + '$a' => 'list', + '$b' => 'list', '$c' => 'array', - '$d' => 'array', - '$e' => 'array', + '$d' => 'list', + '$e' => 'list', '$f' => 'array', - '$g' => 'array', - '$h' => 'array', + '$g' => 'list', + '$h' => 'list', + '$i' => 'list', + '$j' => 'list', ], ], 'strtrWithPossiblyFalseFirstArg' => [ @@ -2068,6 +2138,151 @@ class FunctionCallTest extends TestCase $mysqli = mysqli_init(); mysqli_real_connect($mysqli, null, \'test\', null);', ], + 'arrayPad' => [ + ' 1, "bar" => 2], 10, 123); + $b = array_pad(["a", "b", "c"], 10, "x"); + /** @var list $list */ + $c = array_pad($list, 10, 0); + /** @var array $array */ + $d = array_pad($array, 10, "");', + 'assertions' => [ + '$a' => 'non-empty-array', + '$b' => 'non-empty-list', + '$c' => 'non-empty-list', + '$d' => 'non-empty-array', + ], + ], + 'arrayPadDynamicSize' => [ + ' 1, "bar" => 2], getSize(), 123); + $b = array_pad(["a", "b", "c"], getSize(), "x"); + /** @var list $list */ + $c = array_pad($list, getSize(), 0); + /** @var array $array */ + $d = array_pad($array, getSize(), "");', + 'assertions' => [ + '$a' => 'array', + '$b' => 'list', + '$c' => 'list', + '$d' => 'array', + ], + ], + 'arrayPadZeroSize' => [ + ' [ + '$result' => 'array', + ], + ], + 'arrayPadTypeCombination' => [ + ' 1, "bar" => "two"], 5, false); + $b = array_pad(["a", 2, 3.14], 5, null); + /** @var list $list */ + $c = array_pad($list, 5, 0); + /** @var array $array */ + $d = array_pad($array, 5, null);', + 'assertions' => [ + '$a' => 'non-empty-array', + '$b' => 'non-empty-list', + '$c' => 'non-empty-list', + '$d' => 'non-empty-array', + ], + ], + 'arrayPadMixed' => [ + ' [ + '$a' => 'non-empty-array', + '$b' => 'non-empty-list', + '$c' => 'non-empty-list', + '$d' => 'non-empty-array', + ], + ], + 'arrayPadFallback' => [ + ' [ + '$result' => 'array', + ], + ], + 'arrayChunk' => [ + ' $list */ + $b = array_chunk($list, 2); + /** @var array $arr */ + $c = array_chunk($arr, 2); + ', + 'assertions' => [ + '$a' => 'list>', + '$b' => 'list>', + '$c' => 'list>', + ], + ], + 'arrayChunkPreservedKeys' => [ + ' $list */ + $b = array_chunk($list, 2, true); + /** @var array $arr */ + $c = array_chunk($arr, 2, true);', + 'assertions' => [ + '$a' => 'list>', + '$b' => 'list>', + '$c' => 'list>', + ], + ], + 'arrayChunkPreservedKeysExplicitFalse' => [ + ' $arr */ + $result = array_chunk($arr, 2, false);', + 'assertions' => [ + '$result' => 'list>', + ], + ], + 'arrayChunkMixed' => [ + ' $list */ + $b = array_chunk($list, 2); + /** @var mixed[] $arr */ + $c = array_chunk($arr, 2);', + 'assertions' => [ + '$a' => 'list>', + '$b' => 'list>', + '$c' => 'list>', + ], + ], + 'arrayChunkFallback' => [ + ' [ + '$result' => 'list>', + ], + ], ]; }