From f26c16d2abbb7b201233501e08fe2bba6f0e82e1 Mon Sep 17 00:00:00 2001 From: adrew Date: Sun, 26 Mar 2023 19:07:20 +0300 Subject: [PATCH 1/4] Contextually resolve templates of first-class-callable arg during call --- .../Expression/Call/ArgumentsAnalyzer.php | 96 ++++++++- tests/CallableTest.php | 202 ++++++++++++++++++ 2 files changed, 288 insertions(+), 10 deletions(-) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php index 33fbdbcae..9ef8b788c 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php @@ -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,21 @@ 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, + $template_result ?? new TemplateResult([], []), + $function_storage, + $param, + ); + } } elseif (($arg->value instanceof PhpParser\Node\Expr\Closure || $arg->value instanceof PhpParser\Node\Expr\ArrowFunction) && $param @@ -231,7 +241,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 +365,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 +422,72 @@ class ArgumentsAnalyzer return null; } + private static function handleFirstClassCallableCallArg( + StatementsAnalyzer $statements_analyzer, + TemplateResult $inferred_template_result, + FunctionLikeStorage $storage, + FunctionLikeParameter $actual_func_param + ): ?Union { + $expected_func_type = $actual_func_param->type ?? Type::getMixed(); + if (!$expected_func_type->isSingle()) { + return null; + } + + $expected_func_atomic = $expected_func_type->getSingleAtomic(); + if (!$expected_func_atomic instanceof TClosure && !$expected_func_atomic instanceof TCallable) { + return null; + } + + $remapped_lower_bounds = []; + + foreach ($expected_func_atomic->params ?? [] as $offset => $expected_param) { + if (!isset($storage->params[$offset])) { + continue; + } + + $actual_param = $storage->params[$offset]; + + $expected_param_type = $expected_param->type ?? Type::getMixed(); + $actual_param_type = $actual_param->type ?? Type::getMixed(); + + if (!$expected_param_type->isSingle() || !$actual_param_type->isSingle()) { + continue; + } + + $expected_atomic = $expected_param_type->getSingleAtomic(); + $actual_atomic = $actual_param_type->getSingleAtomic(); + + if (!$expected_atomic instanceof TTemplateParam || !$actual_atomic instanceof TTemplateParam) { + continue; + } + + $remapped_lower_bounds[$actual_atomic->param_name][$actual_atomic->defining_class] = new Union([ + $expected_atomic, + ]); + } + + $replaced_container_hof_atomic = new Union([ + new TClosure( + 'Closure', + $storage->params, + $storage->return_type, + $storage->pure, + ), + ]); + + $codebase = $statements_analyzer->getCodebase(); + + return TemplateInferredTypeReplacer::replace( + TemplateInferredTypeReplacer::replace( + $replaced_container_hof_atomic, + new TemplateResult($inferred_template_result->template_types, $remapped_lower_bounds), + $codebase, + ), + $inferred_template_result, + $codebase, + ); + } + /** * Compiles TemplateResult for high-order functions ($func_call) * by previous template args ($inferred_template_result). diff --git a/tests/CallableTest.php b/tests/CallableTest.php index 1f1c07409..2ee9aec2c 100644 --- a/tests/CallableTest.php +++ b/tests/CallableTest.php @@ -514,6 +514,208 @@ class CallableTest extends TestCase 'ignored_issues' => [], 'php_version' => '8.0', ], + 'inferPipelineWithPartiallyAppliedFunctionsAndFirstClassCallable' => [ + 'code' => '): list + */ + function map(callable $callback): Closure + { + return fn($array) => array_map($callback, $array); + } + + /** + * @return list + */ + 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> + */ + 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', + '$wrapped_id' => 'list', + '$id_nested' => 'list>', + '$id_nested_simple' => 'list>', + ], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], + 'inferFirstClassCallableOnMethodCall' => [ + 'code' => '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' => '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 doubleId(mixed $value1, mixed $value2, mixed $value3): array + { + return [$value1, $value2, $value3]; + } + + $processor = new Processor(a: 1, b: 2, c: 3); + + $test = $processor->process(doubleId(...)); + ', + 'assertions' => [ + '$test' => 'list{int, int, int}', + ], + 'ignored_issues' => [], + 'php_version' => '8.1', + ], 'varReturnType' => [ 'code' => ' Date: Sun, 26 Mar 2023 22:47:17 +0300 Subject: [PATCH 2/4] Test with invalid first-class-callable --- .../Expression/Call/ArgumentsAnalyzer.php | 63 ++++++++++--------- tests/CallableTest.php | 45 ++++++++++++- 2 files changed, 77 insertions(+), 31 deletions(-) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php index 9ef8b788c..6598330c2 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php @@ -425,37 +425,44 @@ class ArgumentsAnalyzer private static function handleFirstClassCallableCallArg( StatementsAnalyzer $statements_analyzer, TemplateResult $inferred_template_result, - FunctionLikeStorage $storage, - FunctionLikeParameter $actual_func_param + FunctionLikeStorage $first_class_callable_storage, + FunctionLikeParameter $expected_callable_param ): ?Union { - $expected_func_type = $actual_func_param->type ?? Type::getMixed(); - if (!$expected_func_type->isSingle()) { + $expected_callable_type = $expected_callable_param->type ?? Type::getMixed(); + + if (!$expected_callable_type->isSingle()) { return null; } - $expected_func_atomic = $expected_func_type->getSingleAtomic(); - if (!$expected_func_atomic instanceof TClosure && !$expected_func_atomic instanceof TCallable) { + $expected_callable_atomic = $expected_callable_type->getSingleAtomic(); + + if (!$expected_callable_atomic instanceof TClosure && !$expected_callable_atomic instanceof TCallable) { return null; } + $codebase = $statements_analyzer->getCodebase(); $remapped_lower_bounds = []; - foreach ($expected_func_atomic->params ?? [] as $offset => $expected_param) { - if (!isset($storage->params[$offset])) { + foreach ($expected_callable_atomic->params ?? [] as $offset => $expected_callable_param) { + if (!isset($first_class_callable_storage->params[$offset])) { continue; } - $actual_param = $storage->params[$offset]; + $actual_callable_storage_param = $first_class_callable_storage->params[$offset]; - $expected_param_type = $expected_param->type ?? Type::getMixed(); - $actual_param_type = $actual_param->type ?? Type::getMixed(); + $expected_callable_param_type = $expected_callable_param->type ?? Type::getMixed(); + $actual_callable_param_type = $actual_callable_storage_param->type ?? Type::getMixed(); - if (!$expected_param_type->isSingle() || !$actual_param_type->isSingle()) { + if (!$codebase->isTypeContainedByType($actual_callable_param_type, $expected_callable_param_type)) { continue; } - $expected_atomic = $expected_param_type->getSingleAtomic(); - $actual_atomic = $actual_param_type->getSingleAtomic(); + if (!$expected_callable_param_type->isSingle() || !$actual_callable_param_type->isSingle()) { + continue; + } + + $expected_atomic = $expected_callable_param_type->getSingleAtomic(); + $actual_atomic = $actual_callable_param_type->getSingleAtomic(); if (!$expected_atomic instanceof TTemplateParam || !$actual_atomic instanceof TTemplateParam) { continue; @@ -466,23 +473,21 @@ class ArgumentsAnalyzer ]); } - $replaced_container_hof_atomic = new Union([ - new TClosure( - 'Closure', - $storage->params, - $storage->return_type, - $storage->pure, - ), - ]); - - $codebase = $statements_analyzer->getCodebase(); + $remapped_first_class_callable = TemplateInferredTypeReplacer::replace( + new Union([ + new TClosure( + 'Closure', + $first_class_callable_storage->params, + $first_class_callable_storage->return_type, + $first_class_callable_storage->pure, + ), + ]), + new TemplateResult($inferred_template_result->template_types, $remapped_lower_bounds), + $codebase, + ); return TemplateInferredTypeReplacer::replace( - TemplateInferredTypeReplacer::replace( - $replaced_container_hof_atomic, - new TemplateResult($inferred_template_result->template_types, $remapped_lower_bounds), - $codebase, - ), + $remapped_first_class_callable, $inferred_template_result, $codebase, ); diff --git a/tests/CallableTest.php b/tests/CallableTest.php index 2ee9aec2c..6d9778b0f 100644 --- a/tests/CallableTest.php +++ b/tests/CallableTest.php @@ -701,14 +701,14 @@ class CallableTest extends TestCase * @param C $value3 * @return list{A, B, C} */ - function doubleId(mixed $value1, mixed $value2, mixed $value3): array + 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(doubleId(...)); + $test = $processor->process(tripleId(...)); ', 'assertions' => [ '$test' => 'list{int, int, int}', @@ -1929,6 +1929,47 @@ class CallableTest extends TestCase }', 'error_message' => 'InvalidArgument', ], + 'invalidFirstClassCallableCannotBeInferred' => [ + 'code' => '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', + ], ]; } } From 72e5709ef2bc487c3b3c95be6895288e587dc45b Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Mar 2023 18:29:22 +0300 Subject: [PATCH 3/4] Handle partially templated first-class-callables --- .../Expression/Call/ArgumentsAnalyzer.php | 98 +++++++++++-------- tests/CallableTest.php | 74 +++++++++++++- 2 files changed, 130 insertions(+), 42 deletions(-) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php index 6598330c2..ca19cffd6 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php @@ -214,9 +214,14 @@ class ArgumentsAnalyzer ); } else { $inferred_first_class_callable_type = self::handleFirstClassCallableCallArg( - $statements_analyzer, + $statements_analyzer->getCodebase(), $template_result ?? new TemplateResult([], []), - $function_storage, + new TClosure( + 'Closure', + $function_storage->params, + $function_storage->return_type, + $function_storage->pure, + ), $param, ); } @@ -422,70 +427,81 @@ class ArgumentsAnalyzer return null; } + /** + * Infers type for first-class-callable call. + */ private static function handleFirstClassCallableCallArg( - StatementsAnalyzer $statements_analyzer, + Codebase $codebase, TemplateResult $inferred_template_result, - FunctionLikeStorage $first_class_callable_storage, - FunctionLikeParameter $expected_callable_param + TClosure $input_first_class_callable, + FunctionLikeParameter $container_callable_param ): ?Union { - $expected_callable_type = $expected_callable_param->type ?? Type::getMixed(); + $container_callable_atomic = $container_callable_param->type && $container_callable_param->type->isSingle() + ? $container_callable_param->type->getSingleAtomic() + : null; - if (!$expected_callable_type->isSingle()) { + if (!$container_callable_atomic instanceof TClosure && !$container_callable_atomic instanceof TCallable) { return null; } - $expected_callable_atomic = $expected_callable_type->getSingleAtomic(); - - if (!$expected_callable_atomic instanceof TClosure && !$expected_callable_atomic instanceof TCallable) { + // 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; } - $codebase = $statements_analyzer->getCodebase(); $remapped_lower_bounds = []; - foreach ($expected_callable_atomic->params ?? [] as $offset => $expected_callable_param) { - if (!isset($first_class_callable_storage->params[$offset])) { + // 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; } - $actual_callable_storage_param = $first_class_callable_storage->params[$offset]; + $input_param_type = $input_first_class_callable->params[$offset]->type ?? Type::getMixed(); + $container_param_type = $container_param->type ?? Type::getMixed(); - $expected_callable_param_type = $expected_callable_param->type ?? Type::getMixed(); - $actual_callable_param_type = $actual_callable_storage_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] ?? []; - if (!$codebase->isTypeContainedByType($actual_callable_param_type, $expected_callable_param_type)) { - continue; + 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]); + } } - - if (!$expected_callable_param_type->isSingle() || !$actual_callable_param_type->isSingle()) { - continue; - } - - $expected_atomic = $expected_callable_param_type->getSingleAtomic(); - $actual_atomic = $actual_callable_param_type->getSingleAtomic(); - - if (!$expected_atomic instanceof TTemplateParam || !$actual_atomic instanceof TTemplateParam) { - continue; - } - - $remapped_lower_bounds[$actual_atomic->param_name][$actual_atomic->defining_class] = new Union([ - $expected_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([ - new TClosure( - 'Closure', - $first_class_callable_storage->params, - $first_class_callable_storage->return_type, - $first_class_callable_storage->pure, - ), - ]), + 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, diff --git a/tests/CallableTest.php b/tests/CallableTest.php index 6d9778b0f..2eb623048 100644 --- a/tests/CallableTest.php +++ b/tests/CallableTest.php @@ -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', @@ -716,6 +717,77 @@ class CallableTest extends TestCase 'ignored_issues' => [], 'php_version' => '8.1', ], + 'inferFirstClassCallableOnMethodCallWithTemplatedAndNonTemplatedParams' => [ + 'code' => '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' => ' $param1]; } - $result = (new App(param1: 42))->run(appHandler(...)); + $result = (new App(param1: [42]))->run(appHandler(...)); ', 'error_message' => 'InvalidArgument', 'ignored_issues' => [], From 2b5faaa02f05d5fbe033568e4dcf4a39880b291c Mon Sep 17 00:00:00 2001 From: andrew Date: Mon, 27 Mar 2023 18:37:11 +0300 Subject: [PATCH 4/4] Fix psalm errors --- .../Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php index ca19cffd6..dfae55740 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php @@ -446,7 +446,7 @@ class ArgumentsAnalyzer // 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)) { + if (count($container_callable_atomic->params ?? []) < count($input_first_class_callable->params ?? [])) { return null; }