mirror of
https://github.com/danog/psalm.git
synced 2024-11-26 20:34:47 +01:00
Merge pull request #9570 from klimick/first-class-callable-contextual-inference
Contextual inference for first-class-callable
This commit is contained in:
commit
f78bf32417
@ -197,6 +197,7 @@ class ArgumentsAnalyzer
|
|||||||
}
|
}
|
||||||
|
|
||||||
$high_order_template_result = null;
|
$high_order_template_result = null;
|
||||||
|
$inferred_first_class_callable_type = null;
|
||||||
|
|
||||||
if (($arg->value instanceof PhpParser\Node\Expr\FuncCall
|
if (($arg->value instanceof PhpParser\Node\Expr\FuncCall
|
||||||
|| $arg->value instanceof PhpParser\Node\Expr\MethodCall
|
|| $arg->value instanceof PhpParser\Node\Expr\MethodCall
|
||||||
@ -204,12 +205,26 @@ class ArgumentsAnalyzer
|
|||||||
&& $param
|
&& $param
|
||||||
&& $function_storage = self::getHighOrderFuncStorage($context, $statements_analyzer, $arg->value)
|
&& $function_storage = self::getHighOrderFuncStorage($context, $statements_analyzer, $arg->value)
|
||||||
) {
|
) {
|
||||||
$high_order_template_result = self::handleHighOrderFuncCallArg(
|
if (!$arg->value->isFirstClassCallable()) {
|
||||||
$statements_analyzer,
|
$high_order_template_result = self::handleHighOrderFuncCallArg(
|
||||||
$template_result ?? new TemplateResult([], []),
|
$statements_analyzer,
|
||||||
$function_storage,
|
$template_result ?? new TemplateResult([], []),
|
||||||
$param,
|
$function_storage,
|
||||||
);
|
$param,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
$inferred_first_class_callable_type = self::handleFirstClassCallableCallArg(
|
||||||
|
$statements_analyzer->getCodebase(),
|
||||||
|
$template_result ?? new TemplateResult([], []),
|
||||||
|
new TClosure(
|
||||||
|
'Closure',
|
||||||
|
$function_storage->params,
|
||||||
|
$function_storage->return_type,
|
||||||
|
$function_storage->pure,
|
||||||
|
),
|
||||||
|
$param,
|
||||||
|
);
|
||||||
|
}
|
||||||
} elseif (($arg->value instanceof PhpParser\Node\Expr\Closure
|
} elseif (($arg->value instanceof PhpParser\Node\Expr\Closure
|
||||||
|| $arg->value instanceof PhpParser\Node\Expr\ArrowFunction)
|
|| $arg->value instanceof PhpParser\Node\Expr\ArrowFunction)
|
||||||
&& $param
|
&& $param
|
||||||
@ -231,7 +246,9 @@ class ArgumentsAnalyzer
|
|||||||
|
|
||||||
$context->inside_call = true;
|
$context->inside_call = true;
|
||||||
|
|
||||||
if (ExpressionAnalyzer::analyze(
|
if ($inferred_first_class_callable_type) {
|
||||||
|
$statements_analyzer->node_data->setType($arg->value, $inferred_first_class_callable_type);
|
||||||
|
} elseif (ExpressionAnalyzer::analyze(
|
||||||
$statements_analyzer,
|
$statements_analyzer,
|
||||||
$arg->value,
|
$arg->value,
|
||||||
$context,
|
$context,
|
||||||
@ -353,9 +370,7 @@ class ArgumentsAnalyzer
|
|||||||
$codebase = $statements_analyzer->getCodebase();
|
$codebase = $statements_analyzer->getCodebase();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if ($function_like_call instanceof PhpParser\Node\Expr\FuncCall &&
|
if ($function_like_call instanceof PhpParser\Node\Expr\FuncCall) {
|
||||||
!$function_like_call->isFirstClassCallable()
|
|
||||||
) {
|
|
||||||
$function_id = strtolower((string) $function_like_call->name->getAttribute('resolvedName'));
|
$function_id = strtolower((string) $function_like_call->name->getAttribute('resolvedName'));
|
||||||
|
|
||||||
if (empty($function_id)) {
|
if (empty($function_id)) {
|
||||||
@ -412,6 +427,88 @@ class ArgumentsAnalyzer
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Infers type for first-class-callable call.
|
||||||
|
*/
|
||||||
|
private static function handleFirstClassCallableCallArg(
|
||||||
|
Codebase $codebase,
|
||||||
|
TemplateResult $inferred_template_result,
|
||||||
|
TClosure $input_first_class_callable,
|
||||||
|
FunctionLikeParameter $container_callable_param
|
||||||
|
): ?Union {
|
||||||
|
$container_callable_atomic = $container_callable_param->type && $container_callable_param->type->isSingle()
|
||||||
|
? $container_callable_param->type->getSingleAtomic()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!$container_callable_atomic instanceof TClosure && !$container_callable_atomic instanceof TCallable) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has no sense to analyse 'input' function
|
||||||
|
// when 'container' function has more arguments than 'input'
|
||||||
|
if (count($container_callable_atomic->params ?? []) < count($input_first_class_callable->params ?? [])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$remapped_lower_bounds = [];
|
||||||
|
|
||||||
|
// Traverse side by side 'container' params and 'input' params.
|
||||||
|
// This maps 'input' templates to 'container' templates.
|
||||||
|
//
|
||||||
|
// Example:
|
||||||
|
// 'input' => Closure(C:Bar, D:Bar): array{C:Bar, D:Bar}
|
||||||
|
// 'container' => Closure(A:Foo, B:Foo): array{A:Foo, B:Foo}
|
||||||
|
//
|
||||||
|
// $remapped_lower_bounds will be: [
|
||||||
|
// 'C' => ['Bar' => ['A:Foo']],
|
||||||
|
// 'D' => ['Bar' => ['B:Foo']]
|
||||||
|
// ].
|
||||||
|
foreach ($container_callable_atomic->params ?? [] as $offset => $container_param) {
|
||||||
|
if (!isset($input_first_class_callable->params[$offset])) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
$input_param_type = $input_first_class_callable->params[$offset]->type ?? Type::getMixed();
|
||||||
|
$container_param_type = $container_param->type ?? Type::getMixed();
|
||||||
|
|
||||||
|
foreach ($input_param_type->getTemplateTypes() as $input_atomic) {
|
||||||
|
foreach ($container_param_type->getTemplateTypes() as $container_atomic) {
|
||||||
|
$inferred_lower_bounds = $inferred_template_result->lower_bounds
|
||||||
|
[$container_atomic->param_name]
|
||||||
|
[$container_atomic->defining_class] ?? [];
|
||||||
|
|
||||||
|
foreach ($inferred_lower_bounds as $lower_bound) {
|
||||||
|
// Check template constraint of input first-class-callable.
|
||||||
|
// Correct type cannot be inferred if constraint check failed.
|
||||||
|
if (!$codebase->isTypeContainedByType($lower_bound->type, $input_atomic->as)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$remapped_lower_bounds
|
||||||
|
[$input_atomic->param_name]
|
||||||
|
[$input_atomic->defining_class] = new Union([$container_atomic]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Turns Closure(C:Bar, D:Bar): array{C:Bar, D:Bar}
|
||||||
|
// to Closure(A:Foo, B:Foo): array{A:Foo, B:Foo}
|
||||||
|
$remapped_first_class_callable = TemplateInferredTypeReplacer::replace(
|
||||||
|
new Union([$input_first_class_callable]),
|
||||||
|
new TemplateResult($inferred_template_result->template_types, $remapped_lower_bounds),
|
||||||
|
$codebase,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Turns Closure(A:Foo, B:Foo): array{A:Foo, B:Foo}
|
||||||
|
// to fully inferred Closure (thanks to $inferred_template_result)
|
||||||
|
return TemplateInferredTypeReplacer::replace(
|
||||||
|
$remapped_first_class_callable,
|
||||||
|
$inferred_template_result,
|
||||||
|
$codebase,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Compiles TemplateResult for high-order functions ($func_call)
|
* Compiles TemplateResult for high-order functions ($func_call)
|
||||||
* by previous template args ($inferred_template_result).
|
* by previous template args ($inferred_template_result).
|
||||||
|
@ -101,6 +101,7 @@ class CallableTest extends TestCase
|
|||||||
$a = $calc(
|
$a = $calc(
|
||||||
foo: fn($_a, $_b) => $_a + $_b,
|
foo: fn($_a, $_b) => $_a + $_b,
|
||||||
bar: fn($_a, $_b) => $_a + $_b,
|
bar: fn($_a, $_b) => $_a + $_b,
|
||||||
|
baz: fn($_a, $_b) => $_a + $_b,
|
||||||
);',
|
);',
|
||||||
'assertions' => [
|
'assertions' => [
|
||||||
'$a' => 'int',
|
'$a' => 'int',
|
||||||
@ -514,6 +515,279 @@ class CallableTest extends TestCase
|
|||||||
'ignored_issues' => [],
|
'ignored_issues' => [],
|
||||||
'php_version' => '8.0',
|
'php_version' => '8.0',
|
||||||
],
|
],
|
||||||
|
'inferPipelineWithPartiallyAppliedFunctionsAndFirstClassCallable' => [
|
||||||
|
'code' => '<?php
|
||||||
|
/**
|
||||||
|
* @template T
|
||||||
|
* @param T $value
|
||||||
|
* @return T
|
||||||
|
*/
|
||||||
|
function id(mixed $value): mixed
|
||||||
|
{
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template A
|
||||||
|
* @template B
|
||||||
|
* @param A $a
|
||||||
|
* @param callable(A): B $ab
|
||||||
|
* @return B
|
||||||
|
*/
|
||||||
|
function pipe(mixed $a, callable $ab): mixed
|
||||||
|
{
|
||||||
|
return $ab($a);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template A
|
||||||
|
* @template B
|
||||||
|
* @param callable(A): B $callback
|
||||||
|
* @return Closure(list<A>): list<B>
|
||||||
|
*/
|
||||||
|
function map(callable $callback): Closure
|
||||||
|
{
|
||||||
|
return fn($array) => array_map($callback, $array);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<int>
|
||||||
|
*/
|
||||||
|
function getNums(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T of float|int
|
||||||
|
*/
|
||||||
|
final class ObjectNum
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @psalm-param T $value
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly float|int $value,
|
||||||
|
) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return list<ObjectNum<int>>
|
||||||
|
*/
|
||||||
|
function getObjectNums(): array
|
||||||
|
{
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
$id = pipe(getNums(), id(...));
|
||||||
|
$wrapped_id = pipe(getNums(), map(id(...)));
|
||||||
|
$id_nested = pipe(getObjectNums(), map(id(...)));
|
||||||
|
$id_nested_simple = pipe(getObjectNums(), id(...));
|
||||||
|
',
|
||||||
|
'assertions' => [
|
||||||
|
'$id' => 'list<int>',
|
||||||
|
'$wrapped_id' => 'list<int>',
|
||||||
|
'$id_nested' => 'list<ObjectNum<int>>',
|
||||||
|
'$id_nested_simple' => 'list<ObjectNum<int>>',
|
||||||
|
],
|
||||||
|
'ignored_issues' => [],
|
||||||
|
'php_version' => '8.1',
|
||||||
|
],
|
||||||
|
'inferFirstClassCallableOnMethodCall' => [
|
||||||
|
'code' => '<?php
|
||||||
|
/**
|
||||||
|
* @template A
|
||||||
|
* @template B
|
||||||
|
*/
|
||||||
|
final class Processor
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param A $a
|
||||||
|
* @param B $b
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly mixed $a,
|
||||||
|
public readonly mixed $b,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template AProcessed
|
||||||
|
* @template BProcessed
|
||||||
|
* @param callable(A): AProcessed $processA
|
||||||
|
* @param callable(B): BProcessed $processB
|
||||||
|
* @return list{AProcessed, BProcessed}
|
||||||
|
*/
|
||||||
|
public function process(callable $processA, callable $processB): array
|
||||||
|
{
|
||||||
|
return [$processA($this->a), $processB($this->b)];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template A
|
||||||
|
* @param A $value
|
||||||
|
* @return A
|
||||||
|
*/
|
||||||
|
function id(mixed $value): mixed
|
||||||
|
{
|
||||||
|
return $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
function intToString(int $value): string
|
||||||
|
{
|
||||||
|
return (string) $value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template A
|
||||||
|
* @param A $value
|
||||||
|
* @return list{A}
|
||||||
|
*/
|
||||||
|
function singleToList(mixed $value): array
|
||||||
|
{
|
||||||
|
return [$value];
|
||||||
|
}
|
||||||
|
|
||||||
|
$processor = new Processor(a: 1, b: 2);
|
||||||
|
|
||||||
|
$test_id = $processor->process(id(...), id(...));
|
||||||
|
$test_complex = $processor->process(intToString(...), singleToList(...));
|
||||||
|
',
|
||||||
|
'assertions' => [
|
||||||
|
'$test_id' => 'list{int, int}',
|
||||||
|
'$test_complex' => 'list{string, list{int}}',
|
||||||
|
],
|
||||||
|
'ignored_issues' => [],
|
||||||
|
'php_version' => '8.1',
|
||||||
|
],
|
||||||
|
'inferFirstClassCallableOnMethodCallWithMultipleParams' => [
|
||||||
|
'code' => '<?php
|
||||||
|
/**
|
||||||
|
* @template A
|
||||||
|
* @template B
|
||||||
|
* @template C
|
||||||
|
*/
|
||||||
|
final class Processor
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param A $a
|
||||||
|
* @param B $b
|
||||||
|
* @param C $c
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
public readonly mixed $a,
|
||||||
|
public readonly mixed $b,
|
||||||
|
public readonly mixed $c,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template AProcessed
|
||||||
|
* @template BProcessed
|
||||||
|
* @template CProcessed
|
||||||
|
* @param callable(A, B, C): list{AProcessed, BProcessed, CProcessed} $processAB
|
||||||
|
* @return list{AProcessed, BProcessed, CProcessed}
|
||||||
|
*/
|
||||||
|
public function process(callable $processAB): array
|
||||||
|
{
|
||||||
|
return $processAB($this->a, $this->b, $this->c);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template A
|
||||||
|
* @template B
|
||||||
|
* @template C
|
||||||
|
* @param A $value1
|
||||||
|
* @param B $value2
|
||||||
|
* @param C $value3
|
||||||
|
* @return list{A, B, C}
|
||||||
|
*/
|
||||||
|
function tripleId(mixed $value1, mixed $value2, mixed $value3): array
|
||||||
|
{
|
||||||
|
return [$value1, $value2, $value3];
|
||||||
|
}
|
||||||
|
|
||||||
|
$processor = new Processor(a: 1, b: 2, c: 3);
|
||||||
|
|
||||||
|
$test = $processor->process(tripleId(...));
|
||||||
|
',
|
||||||
|
'assertions' => [
|
||||||
|
'$test' => 'list{int, int, int}',
|
||||||
|
],
|
||||||
|
'ignored_issues' => [],
|
||||||
|
'php_version' => '8.1',
|
||||||
|
],
|
||||||
|
'inferFirstClassCallableOnMethodCallWithTemplatedAndNonTemplatedParams' => [
|
||||||
|
'code' => '<?php
|
||||||
|
/**
|
||||||
|
* @template T1
|
||||||
|
* @template T2
|
||||||
|
*/
|
||||||
|
final class App
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param T1 $param1
|
||||||
|
* @param T2 $param2
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly mixed $param1,
|
||||||
|
private readonly mixed $param2,
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T3
|
||||||
|
* @param callable(T1, T2): T3 $callback
|
||||||
|
* @return T3
|
||||||
|
*/
|
||||||
|
public function run(callable $callback): mixed
|
||||||
|
{
|
||||||
|
return $callback($this->param1, $this->param2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T of int|float
|
||||||
|
* @param T $param2
|
||||||
|
* @return array{param1: int, param2: T}
|
||||||
|
*/
|
||||||
|
function appHandler1(int $param1, int|float $param2): array
|
||||||
|
{
|
||||||
|
return ["param1" => $param1, "param2" => $param2];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T of int|float
|
||||||
|
* @param T $param1
|
||||||
|
* @return array{param1: T, param2: int}
|
||||||
|
*/
|
||||||
|
function appHandler2(int|float $param1, int $param2): array
|
||||||
|
{
|
||||||
|
return ["param1" => $param1, "param2" => $param2];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array{param1: int, param2: int}
|
||||||
|
*/
|
||||||
|
function appHandler3(int $param1, int $param2): array
|
||||||
|
{
|
||||||
|
return ["param1" => $param1, "param2" => $param2];
|
||||||
|
}
|
||||||
|
|
||||||
|
$app = new App(param1: 42, param2: 42);
|
||||||
|
|
||||||
|
$result1 = $app->run(appHandler1(...));
|
||||||
|
$result2 = $app->run(appHandler2(...));
|
||||||
|
$result3 = $app->run(appHandler3(...));
|
||||||
|
',
|
||||||
|
'assertions' => [
|
||||||
|
'$result1===' => 'array{param1: int, param2: 42}',
|
||||||
|
'$result2===' => 'array{param1: 42, param2: int}',
|
||||||
|
'$result3===' => 'array{param1: int, param2: int}',
|
||||||
|
],
|
||||||
|
'ignored_issues' => [],
|
||||||
|
'php_version' => '8.1',
|
||||||
|
],
|
||||||
'varReturnType' => [
|
'varReturnType' => [
|
||||||
'code' => '<?php
|
'code' => '<?php
|
||||||
$add_one = function(int $a) : int {
|
$add_one = function(int $a) : int {
|
||||||
@ -1727,6 +2001,47 @@ class CallableTest extends TestCase
|
|||||||
}',
|
}',
|
||||||
'error_message' => 'InvalidArgument',
|
'error_message' => 'InvalidArgument',
|
||||||
],
|
],
|
||||||
|
'invalidFirstClassCallableCannotBeInferred' => [
|
||||||
|
'code' => '<?php
|
||||||
|
/**
|
||||||
|
* @template T1
|
||||||
|
*/
|
||||||
|
final class App
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @param T1 $param1
|
||||||
|
*/
|
||||||
|
public function __construct(
|
||||||
|
private readonly mixed $param1,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template T2
|
||||||
|
* @param callable(T1): T2 $callback
|
||||||
|
* @return T2
|
||||||
|
*/
|
||||||
|
public function run(callable $callback): mixed
|
||||||
|
{
|
||||||
|
return $callback($this->param1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @template P1 of int|float
|
||||||
|
* @param P1 $param1
|
||||||
|
* @return array{param1: P1}
|
||||||
|
*/
|
||||||
|
function appHandler(mixed $param1): array
|
||||||
|
{
|
||||||
|
return ["param1" => $param1];
|
||||||
|
}
|
||||||
|
|
||||||
|
$result = (new App(param1: [42]))->run(appHandler(...));
|
||||||
|
',
|
||||||
|
'error_message' => 'InvalidArgument',
|
||||||
|
'ignored_issues' => [],
|
||||||
|
'php_version' => '8.1',
|
||||||
|
],
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user