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;
|
||||
$inferred_first_class_callable_type = null;
|
||||
|
||||
if (($arg->value instanceof PhpParser\Node\Expr\FuncCall
|
||||
|| $arg->value instanceof PhpParser\Node\Expr\MethodCall
|
||||
@ -204,12 +205,26 @@ class ArgumentsAnalyzer
|
||||
&& $param
|
||||
&& $function_storage = self::getHighOrderFuncStorage($context, $statements_analyzer, $arg->value)
|
||||
) {
|
||||
$high_order_template_result = self::handleHighOrderFuncCallArg(
|
||||
$statements_analyzer,
|
||||
$template_result ?? new TemplateResult([], []),
|
||||
$function_storage,
|
||||
$param,
|
||||
);
|
||||
if (!$arg->value->isFirstClassCallable()) {
|
||||
$high_order_template_result = self::handleHighOrderFuncCallArg(
|
||||
$statements_analyzer,
|
||||
$template_result ?? new TemplateResult([], []),
|
||||
$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
|
||||
|| $arg->value instanceof PhpParser\Node\Expr\ArrowFunction)
|
||||
&& $param
|
||||
@ -231,7 +246,9 @@ class ArgumentsAnalyzer
|
||||
|
||||
$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,
|
||||
$arg->value,
|
||||
$context,
|
||||
@ -353,9 +370,7 @@ class ArgumentsAnalyzer
|
||||
$codebase = $statements_analyzer->getCodebase();
|
||||
|
||||
try {
|
||||
if ($function_like_call instanceof PhpParser\Node\Expr\FuncCall &&
|
||||
!$function_like_call->isFirstClassCallable()
|
||||
) {
|
||||
if ($function_like_call instanceof PhpParser\Node\Expr\FuncCall) {
|
||||
$function_id = strtolower((string) $function_like_call->name->getAttribute('resolvedName'));
|
||||
|
||||
if (empty($function_id)) {
|
||||
@ -412,6 +427,88 @@ class ArgumentsAnalyzer
|
||||
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)
|
||||
* by previous template args ($inferred_template_result).
|
||||
|
@ -101,6 +101,7 @@ class CallableTest extends TestCase
|
||||
$a = $calc(
|
||||
foo: fn($_a, $_b) => $_a + $_b,
|
||||
bar: fn($_a, $_b) => $_a + $_b,
|
||||
baz: fn($_a, $_b) => $_a + $_b,
|
||||
);',
|
||||
'assertions' => [
|
||||
'$a' => 'int',
|
||||
@ -514,6 +515,279 @@ class CallableTest extends TestCase
|
||||
'ignored_issues' => [],
|
||||
'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' => [
|
||||
'code' => '<?php
|
||||
$add_one = function(int $a) : int {
|
||||
@ -1727,6 +2001,47 @@ class CallableTest extends TestCase
|
||||
}',
|
||||
'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