1
0
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:
orklah 2023-03-28 20:40:04 +02:00 committed by GitHub
commit f78bf32417
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
2 changed files with 422 additions and 10 deletions

View File

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

View File

@ -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',
],
];
}
}