diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php b/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php index 1ed859dd3e4..bd373377c2b 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/AssertionFinder.php @@ -227,7 +227,7 @@ public static function scrapeAssertions( ); } - if ($conditional instanceof PhpParser\Node\Expr\FuncCall) { + if ($conditional instanceof PhpParser\Node\Expr\FuncCall && !$conditional->isFirstClassCallable()) { return self::processFunctionCall( $conditional, $this_class_name, @@ -237,8 +237,9 @@ public static function scrapeAssertions( ); } - if ($conditional instanceof PhpParser\Node\Expr\MethodCall - || $conditional instanceof PhpParser\Node\Expr\StaticCall + if (($conditional instanceof PhpParser\Node\Expr\MethodCall + || $conditional instanceof PhpParser\Node\Expr\StaticCall) + && !$conditional->isFirstClassCallable() ) { $custom_assertions = self::processCustomAssertion($conditional, $this_class_name, $source); diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php index f108cff5589..6ff81fb906c 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php @@ -20,6 +20,7 @@ use Psalm\Internal\MethodIdentifier; use Psalm\Internal\Type\Comparator\CallableTypeComparator; use Psalm\Internal\Type\TemplateResult; +use Psalm\Internal\Type\TypeCombiner; use Psalm\Issue\DeprecatedFunction; use Psalm\Issue\ImpureFunctionCall; use Psalm\Issue\InvalidFunctionCall; @@ -85,6 +86,7 @@ public static function analyze( $real_stmt = $stmt; if ($function_name instanceof PhpParser\Node\Name + && !$stmt->isFirstClassCallable() && isset($stmt->getArgs()[0]) && !$stmt->getArgs()[0]->unpack ) { @@ -143,6 +145,7 @@ public static function analyze( } } + $is_first_class_callable = $stmt->isFirstClassCallable(); $set_inside_conditional = false; if ($function_name instanceof PhpParser\Node\Name @@ -153,14 +156,16 @@ public static function analyze( $set_inside_conditional = true; } - ArgumentsAnalyzer::analyze( - $statements_analyzer, - $stmt->getArgs(), - $function_call_info->function_params, - $function_call_info->function_id, - $function_call_info->allow_named_args, - $context - ); + if (!$is_first_class_callable) { + ArgumentsAnalyzer::analyze( + $statements_analyzer, + $stmt->getArgs(), + $function_call_info->function_params, + $function_call_info->function_id, + $function_call_info->allow_named_args, + $context + ); + } if ($set_inside_conditional) { $context->inside_conditional = false; @@ -168,7 +173,10 @@ public static function analyze( $function_callable = null; - if ($function_name instanceof PhpParser\Node\Name && $function_call_info->function_id) { + if (!$is_first_class_callable + && $function_name instanceof PhpParser\Node\Name + && $function_call_info->function_id + ) { if (!$function_call_info->is_stubbed && $function_call_info->in_call_map) { $function_callable = InternalCallMapHandler::getCallableFromCallMapById( $codebase, @@ -184,7 +192,7 @@ public static function analyze( $template_result = new TemplateResult([], []); // do this here to allow closure param checks - if ($function_call_info->function_params !== null) { + if (!$is_first_class_callable && $function_call_info->function_params !== null) { ArgumentsAnalyzer::checkArgumentsMatch( $statements_analyzer, $stmt->getArgs(), @@ -235,6 +243,45 @@ public static function analyze( ); $config->eventDispatcher->dispatchAfterEveryFunctionCallAnalysis($event); + + if ($is_first_class_callable) { + return true; + } + } + + if ($is_first_class_callable) { + $type_provider = $statements_analyzer->getNodeTypeProvider(); + $closure_types = []; + + if ($input_type = $type_provider->getType($function_name)) { + foreach ($input_type->getAtomicTypes() as $atomic_type) { + $candidate_callable = CallableTypeComparator::getCallableFromAtomic( + $codebase, + $atomic_type, + null, + $statements_analyzer + ); + + if ($candidate_callable) { + $closure_types[] = new Type\Atomic\TClosure( + 'Closure', + $candidate_callable->params, + $candidate_callable->return_type, + $candidate_callable->is_pure + ); + } + } + } + + if ($closure_types) { + $stmt_type = TypeCombiner::combine($closure_types, $codebase); + } else { + $stmt_type = Type::getClosure(); + } + + $statements_analyzer->node_data->setType($real_stmt, $stmt_type); + + return true; } foreach ($function_call_info->defined_constants as $const_name => $const_type) { @@ -457,13 +504,14 @@ private static function handleNamedFunction( $function_call_info->function_params = null; $function_call_info->defined_constants = []; $function_call_info->global_variables = []; + $args = $stmt->isFirstClassCallable() ? [] : $stmt->getArgs(); if ($function_call_info->function_exists) { if ($codebase->functions->params_provider->has($function_call_info->function_id)) { $function_call_info->function_params = $codebase->functions->params_provider->getFunctionParams( $statements_analyzer, $function_call_info->function_id, - $stmt->getArgs(), + $args, null, $code_location ); @@ -496,7 +544,7 @@ private static function handleNamedFunction( $function_callable = InternalCallMapHandler::getCallableFromCallMapById( $codebase, $function_call_info->function_id, - $stmt->getArgs(), + $args, $statements_analyzer->node_data ); @@ -789,7 +837,7 @@ private static function analyzeInvokeCall( $fake_method_call = new VirtualMethodCall( $function_name, new VirtualIdentifier('__invoke', $function_name->getAttributes()), - $stmt->getArgs() + $stmt->args ); $suppressed_issues = $statements_analyzer->getSuppressedIssues(); @@ -948,7 +996,7 @@ private static function checkFunctionCallPurity( $codebase, $statements_analyzer->node_data, $function_call_info->function_id, - $stmt->getArgs(), + $stmt->isFirstClassCallable() ? [] : $stmt->getArgs(), $must_use ) : null; diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php index eeb8e8db6c8..e0134327cb7 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallReturnTypeFetcher.php @@ -13,6 +13,7 @@ use Psalm\Internal\DataFlow\DataFlowNode; use Psalm\Internal\DataFlow\TaintSource; use Psalm\Internal\FileManipulation\FileManipulationBuffer; +use Psalm\Internal\Type\Comparator\CallableTypeComparator; use Psalm\Internal\Type\TemplateBound; use Psalm\Internal\Type\TemplateInferredTypeReplacer; use Psalm\Internal\Type\TemplateResult; @@ -58,7 +59,26 @@ public static function fetch( $stmt_type = null; $config = $codebase->config; - if ($codebase->functions->return_type_provider->has($function_id)) { + if ($stmt->isFirstClassCallable()) { + $candidate_callable = CallableTypeComparator::getCallableFromAtomic( + $codebase, + new Type\Atomic\TLiteralString($function_id), + null, + $statements_analyzer, + true + ); + + if ($candidate_callable) { + $stmt_type = new Type\Union([new Type\Atomic\TClosure( + 'Closure', + $candidate_callable->params, + $candidate_callable->return_type, + $candidate_callable->is_pure + )]); + } else { + $stmt_type = Type::getClosure(); + } + } elseif ($codebase->functions->return_type_provider->has($function_id)) { $stmt_type = $codebase->functions->return_type_provider->getReturnType( $statements_analyzer, $function_id, diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/AtomicMethodCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/AtomicMethodCallAnalyzer.php index f464f99065b..9e05e0e2bb0 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/AtomicMethodCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/AtomicMethodCallAnalyzer.php @@ -193,7 +193,7 @@ public static function analyze( $method_id = new MethodIdentifier($fq_class_name, $method_name_lc); - $args = $stmt->getArgs(); + $args = $stmt->isFirstClassCallable() ? [] : $stmt->getArgs(); $naive_method_id = $method_id; diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/ExistingAtomicMethodCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/ExistingAtomicMethodCallAnalyzer.php index ecae3b31a61..4110e8c48e2 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/ExistingAtomicMethodCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/ExistingAtomicMethodCallAnalyzer.php @@ -196,7 +196,9 @@ public static function analyze( ); } - if (self::checkMethodArgs( + $is_first_class_callable = $stmt->isFirstClassCallable(); + + if (!$is_first_class_callable && self::checkMethodArgs( $method_id, $args, $template_result, @@ -225,6 +227,10 @@ public static function analyze( $template_result ); + if ($is_first_class_callable) { + return $return_type_candidate; + } + $in_call_map = InternalCallMapHandler::inCallMap((string) ($declaring_method_id ?? $method_id)); if (!$in_call_map) { diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php index 54ee8053b91..0b7ab2a04db 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/Method/MethodCallReturnTypeFetcher.php @@ -137,80 +137,95 @@ public static function fetch( } else { $self_fq_class_name = $fq_class_name; - $return_type_candidate = $codebase->methods->getMethodReturnType( - $method_id, - $self_fq_class_name, - $statements_analyzer, - $args - ); + if ($stmt->isFirstClassCallable()) { + $method_storage = ($class_storage->methods[$method_id->method_name] ?? null); + + if ($method_storage) { + $return_type_candidate = new Type\Union([new Type\Atomic\TClosure( + 'Closure', + $method_storage->params, + $method_storage->return_type, + $method_storage->pure + )]); + } else { + $return_type_candidate = Type::getClosure(); + } + } else { + $return_type_candidate = $codebase->methods->getMethodReturnType( + $method_id, + $self_fq_class_name, + $statements_analyzer, + $args + ); - if ($return_type_candidate) { - $return_type_candidate = clone $return_type_candidate; + if ($return_type_candidate) { + $return_type_candidate = clone $return_type_candidate; + + if ($template_result->lower_bounds) { + $return_type_candidate = TypeExpander::expandUnion( + $codebase, + $return_type_candidate, + $fq_class_name, + null, + $class_storage->parent_class, + true, + false, + $static_type instanceof Type\Atomic\TNamedObject + && $codebase->classlike_storage_provider->get($static_type->value)->final, + true + ); + } + + $return_type_candidate = self::replaceTemplateTypes( + $return_type_candidate, + $template_result, + $method_id, + count($stmt->getArgs()), + $codebase + ); - if ($template_result->lower_bounds) { $return_type_candidate = TypeExpander::expandUnion( $codebase, $return_type_candidate, - $fq_class_name, - null, + $self_fq_class_name, + $static_type, $class_storage->parent_class, true, false, $static_type instanceof Type\Atomic\TNamedObject - && $codebase->classlike_storage_provider->get($static_type->value)->final, + && $codebase->classlike_storage_provider->get($static_type->value)->final, true ); - } - $return_type_candidate = self::replaceTemplateTypes( - $return_type_candidate, - $template_result, - $method_id, - count($stmt->getArgs()), - $codebase - ); - - $return_type_candidate = TypeExpander::expandUnion( - $codebase, - $return_type_candidate, - $self_fq_class_name, - $static_type, - $class_storage->parent_class, - true, - false, - $static_type instanceof Type\Atomic\TNamedObject - && $codebase->classlike_storage_provider->get($static_type->value)->final, - true - ); - - $return_type_location = $codebase->methods->getMethodReturnTypeLocation( - $method_id, - $secondary_return_type_location - ); - - if ($secondary_return_type_location) { - $return_type_location = $secondary_return_type_location; - } + $return_type_location = $codebase->methods->getMethodReturnTypeLocation( + $method_id, + $secondary_return_type_location + ); - $config = Config::getInstance(); + if ($secondary_return_type_location) { + $return_type_location = $secondary_return_type_location; + } - // only check the type locally if it's defined externally - if ($return_type_location && !$config->isInProjectDirs($return_type_location->file_path)) { - $return_type_candidate->check( - $statements_analyzer, - new CodeLocation($statements_analyzer, $stmt), - $statements_analyzer->getSuppressedIssues(), - $context->phantom_classes, - true, - false, - false, - $context->calling_method_id - ); - } - } else { - $result->returns_by_ref = - $result->returns_by_ref + $config = Config::getInstance(); + + // only check the type locally if it's defined externally + if ($return_type_location && !$config->isInProjectDirs($return_type_location->file_path)) { + $return_type_candidate->check( + $statements_analyzer, + new CodeLocation($statements_analyzer, $stmt), + $statements_analyzer->getSuppressedIssues(), + $context->phantom_classes, + true, + false, + false, + $context->calling_method_id + ); + } + } else { + $result->returns_by_ref = + $result->returns_by_ref || $codebase->methods->getMethodReturnsByRef($method_id); + } } } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php index 6e33e4dcc12..ff15829d5d1 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/MethodCallAnalyzer.php @@ -198,7 +198,10 @@ public static function analyze( $possible_new_class_types[] = $context->vars_in_scope[$lhs_var_id]; } } - if (!$stmt->getArgs() && $lhs_var_id && $stmt->name instanceof PhpParser\Node\Identifier) { + if (!$stmt->isFirstClassCallable() + && !$stmt->getArgs() + && $lhs_var_id && $stmt->name instanceof PhpParser\Node\Identifier + ) { if ($codebase->config->memoize_method_calls || $result->can_memoize) { $method_var_id = $lhs_var_id . '->' . strtolower($stmt->name->name) . '()'; diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php index bb3fa53c14d..8ec079d921f 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/NamedFunctionCallHandler.php @@ -69,6 +69,10 @@ public static function handle( return; } + if ($stmt->isFirstClassCallable()) { + return; + } + $first_arg = $stmt->getArgs()[0] ?? null; if ($function_id === 'method_exists') { diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php index da80dbed86c..32ecb344e11 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticCallAnalyzer.php @@ -220,7 +220,7 @@ public static function analyze( ); } - if (!$has_existing_method) { + if (!$stmt->isFirstClassCallable() && !$has_existing_method) { return self::checkMethodArgs( $method_id, $stmt->getArgs(), diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php index b9c580c689a..9ea98bdebe1 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/StaticMethod/AtomicStaticCallAnalyzer.php @@ -274,7 +274,7 @@ private static function handleNamedCall( ); } - $args = $stmt->getArgs(); + $args = $stmt->isFirstClassCallable() ? [] : $stmt->getArgs(); if ($intersection_types && !$codebase->methods->methodExists($method_id) @@ -776,6 +776,25 @@ function (PhpParser\Node\Arg $arg): PhpParser\Node\Expr\ArrayItem { $has_existing_method = true; + if ($stmt->isFirstClassCallable()) { + $method_storage = ($class_storage->methods[$method_id->method_name] ?? null); + + if ($method_storage) { + $return_type_candidate = new Type\Union([new Type\Atomic\TClosure( + 'Closure', + $method_storage->params, + $method_storage->return_type, + $method_storage->pure + )]); + } else { + $return_type_candidate = Type::getClosure(); + } + + $statements_analyzer->node_data->setType($stmt, $return_type_candidate); + + return true; + } + ExistingAtomicStaticCallAnalyzer::analyze( $statements_analyzer, $stmt, diff --git a/src/Psalm/Internal/Provider/AddRemoveTaints/HtmlFunctionTainter.php b/src/Psalm/Internal/Provider/AddRemoveTaints/HtmlFunctionTainter.php index 7fd4b741906..e5197871f20 100644 --- a/src/Psalm/Internal/Provider/AddRemoveTaints/HtmlFunctionTainter.php +++ b/src/Psalm/Internal/Provider/AddRemoveTaints/HtmlFunctionTainter.php @@ -26,6 +26,7 @@ public static function addTaints(AddRemoveTaintsEvent $event): array if (!$statements_analyzer instanceof StatementsAnalyzer || !$item instanceof PhpParser\Node\Expr\FuncCall + || $item->isFirstClassCallable() || !$item->name instanceof PhpParser\Node\Name || count($item->name->parts) !== 1 || count($item->getArgs()) === 0 @@ -74,6 +75,7 @@ public static function removeTaints(AddRemoveTaintsEvent $event): array if (!$statements_analyzer instanceof StatementsAnalyzer || !$item instanceof PhpParser\Node\Expr\FuncCall + || $item->isFirstClassCallable() || !$item->name instanceof PhpParser\Node\Name || count($item->name->parts) !== 1 || count($item->getArgs()) === 0 diff --git a/src/Psalm/Internal/Provider/ReturnTypeProvider/ClosureFromCallableReturnTypeProvider.php b/src/Psalm/Internal/Provider/ReturnTypeProvider/ClosureFromCallableReturnTypeProvider.php index bdf5ed8a4b5..32684c50dc3 100644 --- a/src/Psalm/Internal/Provider/ReturnTypeProvider/ClosureFromCallableReturnTypeProvider.php +++ b/src/Psalm/Internal/Provider/ReturnTypeProvider/ClosureFromCallableReturnTypeProvider.php @@ -38,7 +38,8 @@ public static function getMethodReturnType(MethodReturnTypeProviderEvent $event) $codebase, $atomic_type, null, - $source + $source, + true ); if ($candidate_callable) { diff --git a/tests/ClosureTest.php b/tests/ClosureTest.php index 0ee0acd0751..8485b6dd1ad 100644 --- a/tests/ClosureTest.php +++ b/tests/ClosureTest.php @@ -412,6 +412,14 @@ public function test() : Closure { } }', ], + 'PHP71-closureFromCallableNamedFunction' => [ + ' [ + '$closure' => 'pure-Closure(string):(0|positive-int)', + ] + ], 'allowClosureWithNarrowerReturn' => [ ' 'array{stdClass}' ], ], + 'FirstClassCallable:NamedFunction:is_int' => [ + ' [ + '$closure' => 'pure-Closure(mixed):bool', + '$result' => 'bool', + ], + [], + '8.1' + ], + 'FirstClassCallable:NamedFunction:strlen' => [ + ' [ + '$closure' => 'pure-Closure(string):(0|positive-int)', + '$result' => 'int|positive-int', + ], + [], + '8.1' + ], + 'FirstClassCallable:InstanceMethod' => [ + 'string); + } + } + $test = new Test("test"); + $closure = $test->length(...); + $length = $closure(); + ', + 'assertions' => [ + '$length' => 'int', + ], + [], + '8.1' + ], + 'FirstClassCallable:StaticMethod' => [ + ' [ + '$length' => 'int', + ], + [], + '8.1' + ], + 'FirstClassCallable:InvokableObject' => [ + ' [ + '$length' => 'int', + ], + [], + '8.1' + ], + 'FirstClassCallable:FromClosure' => [ + ' strlen($string); + $closure = $closure(...); + ', + 'assertions' => [ + '$closure' => 'pure-Closure(string):(0|positive-int)', + ], + [], + '8.1' + ], ]; }