1
0
mirror of https://github.com/danog/psalm.git synced 2025-01-21 21:31:13 +01:00

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
This commit is contained in:
Pavel Batečko 2019-11-26 20:48:49 +01:00 committed by Matthew Brown
parent 2f02da62c1
commit 4e594e0a65
8 changed files with 450 additions and 82 deletions

View File

@ -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);

View File

@ -0,0 +1,49 @@
<?php declare(strict_types=1);
namespace Psalm\Internal\Provider\ReturnTypeProvider;
use function count;
use Psalm\CodeLocation;
use Psalm\Context;
use Psalm\Internal\Type\ArrayType;
use Psalm\StatementsSource;
use Psalm\Type;
class ArrayChunkReturnTypeProvider implements \Psalm\Plugin\Hook\FunctionReturnTypeProviderInterface
{
public static function getFunctionIds(): array
{
return ['array_chunk'];
}
public static function getFunctionReturnType(
StatementsSource $statements_source,
string $function_id,
array $call_args,
Context $context,
CodeLocation $code_location
) {
if (count($call_args) >= 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())]);
}
}

View File

@ -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())
]);
}
}

View File

@ -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();
}
}

View File

@ -0,0 +1,61 @@
<?php declare(strict_types=1);
namespace Psalm\Internal\Provider\ReturnTypeProvider;
use function count;
use Psalm\CodeLocation;
use Psalm\Context;
use Psalm\Internal\Type\ArrayType;
use Psalm\StatementsSource;
use Psalm\Type;
class ArrayPadReturnTypeProvider implements \Psalm\Plugin\Hook\FunctionReturnTypeProviderInterface
{
public static function getFunctionIds(): array
{
return ['array_pad'];
}
public static function getFunctionReturnType(
StatementsSource $statements_source,
string $function_id,
array $call_args,
Context $context,
CodeLocation $code_location
) {
$type_provider = $statements_source->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();
}
}

View File

@ -152,18 +152,6 @@ function array_change_key_case(array $arr, int $case = CASE_LOWER)
{
}
/**
* @psalm-template T
*
* @param array<array-key, T> $arr
*
* @return array<int, array<array-key, T>>
* @psalm-pure
*/
function array_chunk(array $arr, int $size, bool $preserve_keys = false)
{
}
/**
* @psalm-template TKey as array-key
*

View File

@ -0,0 +1,59 @@
<?php declare(strict_types=1);
namespace Psalm\Internal\Type;
use Psalm\Type;
/**
* @internal
*/
class ArrayType
{
/** @var Type\Union */
public $key;
/** @var Type\Union */
public $value;
/** @var bool */
public $is_list;
public function __construct(Type\Union $key, Type\Union $value, bool $is_list)
{
$this->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;
}
}

View File

@ -819,6 +819,16 @@ class FunctionCallTest extends TestCase
'$foo' => 'float|int',
],
],
'arrayMapWithArrayAndCallable' => [
'<?php
/**
* @psalm-return array<array-key, int>
*/
function foo(array $v): array {
$r = array_map("intval", $v);
return $r;
}',
],
'arrayMapObjectLikeAndCallable' => [
'<?php
/**
@ -830,6 +840,18 @@ class FunctionCallTest extends TestCase
return $r;
}',
],
'arrayMapObjectLikeListAndCallable' => [
'<?php
/** @param list<int> $list */
function takesList(array $list): void {}
takesList(
array_map(
"intval",
["1", "2", "3"]
)
);',
],
'arrayMapObjectLikeAndClosure' => [
'<?php
/**
@ -846,6 +868,50 @@ class FunctionCallTest extends TestCase
'MixedTypeCoercion',
],
],
'arrayMapObjectLikeListAndClosure' => [
'<?php
/** @param list<string> $list */
function takesList(array $list): void {}
takesList(
array_map(
function (string $str): string { return $str . "x"; },
["foo", "bar", "baz"]
)
);',
],
'arrayMapUntypedCallable' => [
'<?php
/**
* @var callable $callable
* @var array<string, int> $array
*/
$a = array_map($callable, $array);
/**
* @var callable $callable
* @var array<string, int> $array
*/
$b = array_map($callable, $array, $array);
/**
* @var callable $callable
* @var list<string> $list
*/
$c = array_map($callable, $list);
/**
* @var callable $callable
* @var list<string> $list
*/
$d = array_map($callable, $list, $list);',
'assertions' => [
'$a' => 'array<string, mixed>',
'$b' => 'list<mixed>',
'$c' => 'list<mixed>',
'$d' => 'list<mixed>',
],
],
'arrayFilterGoodArgs' => [
'<?php
function fooFoo(int $i) : bool {
@ -1123,19 +1189,23 @@ class FunctionCallTest extends TestCase
$c = array_column([["k" => "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<array-key, int>',
'$b' => 'array<array-key, int>',
'$a' => 'list<int>',
'$b' => 'list<int>',
'$c' => 'array<string, int>',
'$d' => 'array<array-key, mixed>',
'$e' => 'array<array-key, mixed>',
'$d' => 'list<mixed>',
'$e' => 'list<mixed>',
'$f' => 'array<array-key, mixed>',
'$g' => 'array<array-key, string>',
'$h' => 'array<array-key, mixed>',
'$g' => 'list<mixed>',
'$h' => 'list<mixed>',
'$i' => 'list<string>',
'$j' => 'list<mixed>',
],
],
'strtrWithPossiblyFalseFirstArg' => [
@ -2068,6 +2138,151 @@ class FunctionCallTest extends TestCase
$mysqli = mysqli_init();
mysqli_real_connect($mysqli, null, \'test\', null);',
],
'arrayPad' => [
'<?php
$a = array_pad(["foo" => 1, "bar" => 2], 10, 123);
$b = array_pad(["a", "b", "c"], 10, "x");
/** @var list<int> $list */
$c = array_pad($list, 10, 0);
/** @var array<string, string> $array */
$d = array_pad($array, 10, "");',
'assertions' => [
'$a' => 'non-empty-array<int|string, int>',
'$b' => 'non-empty-list<string>',
'$c' => 'non-empty-list<int>',
'$d' => 'non-empty-array<int|string, string>',
],
],
'arrayPadDynamicSize' => [
'<?php
function getSize(): int { return random_int(1, 10); }
$a = array_pad(["foo" => 1, "bar" => 2], getSize(), 123);
$b = array_pad(["a", "b", "c"], getSize(), "x");
/** @var list<int> $list */
$c = array_pad($list, getSize(), 0);
/** @var array<string, string> $array */
$d = array_pad($array, getSize(), "");',
'assertions' => [
'$a' => 'array<int|string, int>',
'$b' => 'list<string>',
'$c' => 'list<int>',
'$d' => 'array<int|string, string>',
],
],
'arrayPadZeroSize' => [
'<?php
/** @var array $arr */
$result = array_pad($arr, 0, null);',
'assertions' => [
'$result' => 'array<array-key, mixed|null>',
],
],
'arrayPadTypeCombination' => [
'<?php
$a = array_pad(["foo" => 1, "bar" => "two"], 5, false);
$b = array_pad(["a", 2, 3.14], 5, null);
/** @var list<string|bool> $list */
$c = array_pad($list, 5, 0);
/** @var array<string, string> $array */
$d = array_pad($array, 5, null);',
'assertions' => [
'$a' => 'non-empty-array<int|string, false|int|string>',
'$b' => 'non-empty-list<float|int|null|string>',
'$c' => 'non-empty-list<bool|int|string>',
'$d' => 'non-empty-array<int|string, null|string>',
],
],
'arrayPadMixed' => [
'<?php
/** @var array{foo: mixed, bar: mixed} $arr */
$a = array_pad($arr, 5, null);
/** @var mixed $mixed */
$b = array_pad([$mixed, $mixed], 5, null);
/** @var list $list */
$c = array_pad($list, 5, null);
/** @var mixed[] $array */
$d = array_pad($array, 5, null);',
'assertions' => [
'$a' => 'non-empty-array<int|string, mixed|null>',
'$b' => 'non-empty-list<mixed|null>',
'$c' => 'non-empty-list<mixed|null>',
'$d' => 'non-empty-array<array-key, mixed|null>',
],
],
'arrayPadFallback' => [
'<?php
/**
* @var mixed $mixed
* @psalm-suppress MixedArgument
*/
$result = array_pad($mixed, $mixed, $mixed);',
'assertions' => [
'$result' => 'array<array-key, mixed>',
],
],
'arrayChunk' => [
'<?php
/** @var array{a: int, b: int, c: int, d: int} $arr */
$a = array_chunk($arr, 2);
/** @var list<string> $list */
$b = array_chunk($list, 2);
/** @var array<string, float> $arr */
$c = array_chunk($arr, 2);
',
'assertions' => [
'$a' => 'list<non-empty-list<int>>',
'$b' => 'list<non-empty-list<string>>',
'$c' => 'list<non-empty-list<float>>',
],
],
'arrayChunkPreservedKeys' => [
'<?php
/** @var array{a: int, b: int, c: int, d: int} $arr */
$a = array_chunk($arr, 2, true);
/** @var list<string> $list */
$b = array_chunk($list, 2, true);
/** @var array<string, float> $arr */
$c = array_chunk($arr, 2, true);',
'assertions' => [
'$a' => 'list<non-empty-array<string, int>>',
'$b' => 'list<non-empty-array<int, string>>',
'$c' => 'list<non-empty-array<string, float>>',
],
],
'arrayChunkPreservedKeysExplicitFalse' => [
'<?php
/** @var array<string, string> $arr */
$result = array_chunk($arr, 2, false);',
'assertions' => [
'$result' => 'list<non-empty-list<string>>',
],
],
'arrayChunkMixed' => [
'<?php
/** @var array{a: mixed, b: mixed, c: mixed} $arr */
$a = array_chunk($arr, 2);
/** @var list<mixed> $list */
$b = array_chunk($list, 2);
/** @var mixed[] $arr */
$c = array_chunk($arr, 2);',
'assertions' => [
'$a' => 'list<non-empty-list<mixed>>',
'$b' => 'list<non-empty-list<mixed>>',
'$c' => 'list<non-empty-list<mixed>>',
],
],
'arrayChunkFallback' => [
'<?php
/**
* @var mixed $mixed
* @psalm-suppress MixedArgument
*/
$result = array_chunk($mixed, $mixed, $mixed);',
'assertions' => [
'$result' => 'list<array<array-key, mixed>>',
],
],
];
}