diff --git a/docs/running_psalm/plugins/authoring_plugins.md b/docs/running_psalm/plugins/authoring_plugins.md index 494313970f1..9690a1b4b5e 100644 --- a/docs/running_psalm/plugins/authoring_plugins.md +++ b/docs/running_psalm/plugins/authoring_plugins.md @@ -92,6 +92,7 @@ class SomePlugin implements \Psalm\Plugin\EventHandler\AfterStatementAnalysisInt - `PropertyExistenceProviderInterface` - can be used to override Psalm's builtin property existence checks for one or more classes. - `PropertyTypeProviderInterface` - can be used to override Psalm's builtin property type lookup for one or more classes. - `PropertyVisibilityProviderInterface` - can be used to override Psalm's builtin property visibility checks for one or more classes. +- `DynamicFunctionStorageProviderInterface` - can be used to override signature for one or more functions. It means you can define variadic param list. Infer return type by input args. Define function templates. Also check out `Psalm\Plugin\DynamicFunctionStorage` to find out what api it brings for you to change function's definition. Here are a couple of example plugins: - [StringChecker](https://github.com/vimeo/psalm/blob/master/examples/plugins/StringChecker.php) - checks class references in strings diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php index 0b8e84cdd09..ed02fa5205f 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/ArgumentsAnalyzer.php @@ -346,13 +346,25 @@ private static function getHighOrderFuncStorage( $codebase = $statements_analyzer->getCodebase(); 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')); if (empty($function_id)) { return null; } + if ($codebase->functions->dynamic_storage_provider->has($function_id)) { + return $codebase->functions->dynamic_storage_provider->getFunctionStorage( + $function_like_call, + $statements_analyzer, + $function_id, + $context, + new CodeLocation($statements_analyzer, $function_like_call), + ); + } + return $codebase->functions->getStorage($statements_analyzer, $function_id); } diff --git a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php index ce2198ac666..c30646bd8b4 100644 --- a/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php +++ b/src/Psalm/Internal/Analyzer/Statements/Expression/Call/FunctionCallAnalyzer.php @@ -526,9 +526,26 @@ private static function handleNamedFunction( $function_call_info->defined_constants = []; $function_call_info->global_variables = []; $args = $stmt->isFirstClassCallable() ? [] : $stmt->getArgs(); + $dynamic_function_storage = null; + + if ($codebase->functions->dynamic_storage_provider->has($function_call_info->function_id)) { + $dynamic_function_storage = $codebase->functions->dynamic_storage_provider->getFunctionStorage( + $stmt, + $statements_analyzer, + $function_call_info->function_id, + $context, + $code_location + ); + } if ($function_call_info->function_exists) { - if (!$function_call_info->in_call_map || $function_call_info->is_stubbed) { + if ($dynamic_function_storage) { + $function_call_info->function_storage = $dynamic_function_storage; + $function_call_info->function_params = $dynamic_function_storage->params; + $function_call_info->allow_named_args = $dynamic_function_storage->allow_named_arg_calls; + $function_call_info->defined_constants = $dynamic_function_storage->defined_constants; + $function_call_info->global_variables = $dynamic_function_storage->global_variables; + } elseif (!$function_call_info->in_call_map || $function_call_info->is_stubbed) { try { $function_call_info->function_storage = $function_storage = $codebase_functions->getStorage( $statements_analyzer, @@ -562,15 +579,6 @@ private static function handleNamedFunction( } if ($codebase->functions->params_provider->has($function_call_info->function_id)) { - ArgumentsAnalyzer::analyze( - $statements_analyzer, - $stmt->getArgs(), - $function_call_info->function_params, - $function_call_info->function_id, - $function_call_info->allow_named_args, - $context - ); - $function_call_info->function_params = $codebase->functions->params_provider->getFunctionParams( $statements_analyzer, $function_call_info->function_id, diff --git a/src/Psalm/Internal/Codebase/Functions.php b/src/Psalm/Internal/Codebase/Functions.php index c29ce9815fd..323e5fa5580 100644 --- a/src/Psalm/Internal/Codebase/Functions.php +++ b/src/Psalm/Internal/Codebase/Functions.php @@ -8,6 +8,7 @@ use Psalm\Codebase; use Psalm\Internal\Analyzer\StatementsAnalyzer; use Psalm\Internal\MethodIdentifier; +use Psalm\Internal\Provider\DynamicFunctionStorageProvider; use Psalm\Internal\Provider\FileStorageProvider; use Psalm\Internal\Provider\FunctionExistenceProvider; use Psalm\Internal\Provider\FunctionParamsProvider; @@ -55,6 +56,9 @@ class Functions /** @var FunctionParamsProvider */ public $params_provider; + /** @var DynamicFunctionStorageProvider */ + public $dynamic_storage_provider; + /** * @var Reflection */ @@ -67,6 +71,7 @@ public function __construct(FileStorageProvider $storage_provider, Reflection $r $this->return_type_provider = new FunctionReturnTypeProvider(); $this->existence_provider = new FunctionExistenceProvider(); $this->params_provider = new FunctionParamsProvider(); + $this->dynamic_storage_provider = new DynamicFunctionStorageProvider(); self::$stubbed_functions = []; } diff --git a/src/Psalm/Internal/Provider/DynamicFunctionStorageProvider.php b/src/Psalm/Internal/Provider/DynamicFunctionStorageProvider.php new file mode 100644 index 00000000000..d3f37a4f469 --- /dev/null +++ b/src/Psalm/Internal/Provider/DynamicFunctionStorageProvider.php @@ -0,0 +1,101 @@ +> */ + private static $handlers = []; + + /** @var array */ + private static $dynamic_storages = []; + + /** + * @param class-string $class + */ + public function registerClass(string $class): void + { + $callable = Closure::fromCallable([$class, 'getFunctionStorage']); + + foreach ($class::getFunctionIds() as $function_id) { + $this->registerClosure($function_id, $callable); + } + } + + /** + * @param Closure(DynamicFunctionStorageProviderEvent): ?DynamicFunctionStorage $c + */ + public function registerClosure(string $fq_function_name, Closure $c): void + { + self::$handlers[strtolower($fq_function_name)][] = $c; + } + + public function has(string $fq_function_name): bool + { + return isset(self::$handlers[strtolower($fq_function_name)]); + } + + public function getFunctionStorage( + PhpParser\Node\Expr\FuncCall $stmt, + StatementsAnalyzer $statements_analyzer, + string $function_id, + Context $context, + CodeLocation $code_location + ): ?FunctionStorage { + if ($stmt->isFirstClassCallable()) { + return null; + } + + $dynamic_storage_id = strtolower($statements_analyzer->getFilePath()) + . ':' . $stmt->getLine() + . ':' . (int)$stmt->getAttribute('startFilePos') + . ':dynamic-storage' + . ':-:' . strtolower($function_id); + + if (isset(self::$dynamic_storages[$dynamic_storage_id])) { + return self::$dynamic_storages[$dynamic_storage_id]; + } + + foreach (self::$handlers[strtolower($function_id)] ?? [] as $class_handler) { + $event = new DynamicFunctionStorageProviderEvent( + new ArgTypeInferer($context, $statements_analyzer), + new DynamicTemplateProvider('fn-' . strtolower($function_id)), + $statements_analyzer, + $function_id, + $stmt, + $context, + $code_location, + ); + + $result = $class_handler($event); + + return self::$dynamic_storages[$dynamic_storage_id] = $result + ? $result->toFunctionStorage($function_id) + : null; + } + + return null; + } +} diff --git a/src/Psalm/Plugin/ArgTypeInferer.php b/src/Psalm/Plugin/ArgTypeInferer.php new file mode 100644 index 00000000000..00bd911dab7 --- /dev/null +++ b/src/Psalm/Plugin/ArgTypeInferer.php @@ -0,0 +1,45 @@ +context = $context; + $this->statements_analyzer = $statements_analyzer; + } + + /** + * @return false|Union + */ + public function infer(PhpParser\Node\Arg $arg) + { + $already_inferred_type = $this->statements_analyzer->node_data->getType($arg->value); + + if ($already_inferred_type) { + return $already_inferred_type; + } + + if (ExpressionAnalyzer::analyze($this->statements_analyzer, $arg->value, $this->context) === false) { + return false; + } + + return $this->statements_analyzer->node_data->getType($arg->value) ?? Type::getMixed(); + } +} diff --git a/src/Psalm/Plugin/DynamicFunctionStorage.php b/src/Psalm/Plugin/DynamicFunctionStorage.php new file mode 100644 index 00000000000..5d5256c7eef --- /dev/null +++ b/src/Psalm/Plugin/DynamicFunctionStorage.php @@ -0,0 +1,76 @@ + + */ + public array $params = []; + + /** + * A function return type. Maybe null. + * That means we can infer it in {@see FunctionReturnTypeProviderInterface} hook. + */ + public ?Union $return_type = null; + + /** + * A function can have template args or return type. + * Plugin hook must fill all used templates here. + * + * @var list + */ + public array $templates = []; + + /** + * Determines if a function can be called with named arguments. + */ + public bool $allow_named_arg_calls = true; + + /** + * Function purity. + * If function is pure then plugin hook should set it to true. + */ + public bool $pure = false; + + /** + * Determines if a function can be called with a various number of arguments. + */ + public bool $variadic = false; + + /** + * @internal + */ + public function toFunctionStorage(string $function_cased_name): FunctionStorage + { + $storage = new FunctionStorage(); + $storage->cased_name = $function_cased_name; + $storage->setParams($this->params); + $storage->return_type = $this->return_type; + $storage->allow_named_arg_calls = $this->allow_named_arg_calls; + $storage->pure = $this->pure; + $storage->variadic = $this->variadic; + + if (!empty($this->templates)) { + $storage->template_types = []; + + foreach ($this->templates as $template) { + $storage->template_types[$template->param_name] = [ + $template->defining_class => $template->as + ]; + } + } + + return $storage; + } +} diff --git a/src/Psalm/Plugin/DynamicTemplateProvider.php b/src/Psalm/Plugin/DynamicTemplateProvider.php new file mode 100644 index 00000000000..fbbcb3a36fd --- /dev/null +++ b/src/Psalm/Plugin/DynamicTemplateProvider.php @@ -0,0 +1,30 @@ +defining_class = $defining_class; + } + + /** + * If {@see DynamicFunctionStorage} requires template params this method can create it. + */ + public function createTemplate(string $param_name, Union $as = null): TTemplateParam + { + return new TTemplateParam($param_name, $as ?? Type::getMixed(), $this->defining_class); + } +} diff --git a/src/Psalm/Plugin/EventHandler/DynamicFunctionStorageProviderInterface.php b/src/Psalm/Plugin/EventHandler/DynamicFunctionStorageProviderInterface.php new file mode 100644 index 00000000000..ee57cb5e13b --- /dev/null +++ b/src/Psalm/Plugin/EventHandler/DynamicFunctionStorageProviderInterface.php @@ -0,0 +1,18 @@ + + */ + public static function getFunctionIds(): array; + + public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage; +} diff --git a/src/Psalm/Plugin/EventHandler/Event/DynamicFunctionStorageProviderEvent.php b/src/Psalm/Plugin/EventHandler/Event/DynamicFunctionStorageProviderEvent.php new file mode 100644 index 00000000000..639d2746e32 --- /dev/null +++ b/src/Psalm/Plugin/EventHandler/Event/DynamicFunctionStorageProviderEvent.php @@ -0,0 +1,88 @@ +statement_source = $statements_source; + $this->function_id = $function_id; + $this->func_call = $func_call; + $this->context = $context; + $this->code_location = $code_location; + $this->arg_type_inferer = $arg_type_inferer; + $this->template_provider = $template_provider; + } + + public function getArgTypeInferer(): ArgTypeInferer + { + return $this->arg_type_inferer; + } + + public function getTemplateProvider(): DynamicTemplateProvider + { + return $this->template_provider; + } + + public function getCodebase(): Codebase + { + return $this->statement_source->getCodebase(); + } + + public function getStatementSource(): StatementsSource + { + return $this->statement_source; + } + + public function getFunctionId(): string + { + return $this->function_id; + } + + /** + * @return list + */ + public function getArgs(): array + { + return $this->func_call->getArgs(); + } + + public function getContext(): Context + { + return $this->context; + } + + public function getCodeLocation(): CodeLocation + { + return $this->code_location; + } +} diff --git a/src/Psalm/PluginRegistrationSocket.php b/src/Psalm/PluginRegistrationSocket.php index 0b0b4cbcc7f..7f2927adda5 100644 --- a/src/Psalm/PluginRegistrationSocket.php +++ b/src/Psalm/PluginRegistrationSocket.php @@ -6,6 +6,7 @@ use LogicException; use Psalm\Internal\Analyzer\FileAnalyzer; use Psalm\Internal\Scanner\FileScanner; +use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface; use Psalm\Plugin\EventHandler\FunctionExistenceProviderInterface; use Psalm\Plugin\EventHandler\FunctionParamsProviderInterface; use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface; @@ -108,6 +109,10 @@ public function registerHooksFromClass(string $handler): void if (is_subclass_of($handler, FunctionReturnTypeProviderInterface::class)) { $this->codebase->functions->return_type_provider->registerClass($handler); } + + if (is_subclass_of($handler, DynamicFunctionStorageProviderInterface::class)) { + $this->codebase->functions->dynamic_storage_provider->registerClass($handler); + } } /** diff --git a/tests/Config/Plugin/Hook/CustomArrayMapFunctionStorageProvider.php b/tests/Config/Plugin/Hook/CustomArrayMapFunctionStorageProvider.php new file mode 100644 index 00000000000..d3b938f8833 --- /dev/null +++ b/tests/Config/Plugin/Hook/CustomArrayMapFunctionStorageProvider.php @@ -0,0 +1,171 @@ +getTemplateProvider(); + $arg_type_inferer = $event->getArgTypeInferer(); + $call_args = $event->getArgs(); + $args_count = count($call_args); + $expected_callable_args_count = $args_count - 1; + + if ($expected_callable_args_count < 1) { + return null; + } + + $last_arg = $call_args[$args_count - 1]; + + if (!($input_array_type = $arg_type_inferer->infer($last_arg)) || + !($input_value_type = self::toValueType($event->getCodebase(), $input_array_type)) + ) { + return null; + } + + $all_expected_callables = [ + self::createExpectedCallable($input_value_type, $template_provider), + ...self::createRestCallables($template_provider, $expected_callable_args_count), + ]; + + $custom_array_map_storage = new DynamicFunctionStorage(); + $custom_array_map_storage->templates = self::createTemplates($template_provider, $expected_callable_args_count); + $custom_array_map_storage->return_type = self::createReturnType($all_expected_callables); + $custom_array_map_storage->params = [ + ...array_map( + function (TCallable $expected, int $offset) { + $param = new FunctionLikeParameter('fn' . $offset, false, new Union([$expected])); + $param->is_optional = false; + + return $param; + }, + $all_expected_callables, + array_keys($all_expected_callables) + ), + self::createLastArrayMapParam($input_array_type) + ]; + + return $custom_array_map_storage; + } + + private static function createLastArrayMapParam(Union $input_array_type): FunctionLikeParameter + { + $last_array_map_param = new FunctionLikeParameter('input', false, $input_array_type); + $last_array_map_param->is_optional = false; + + return $last_array_map_param; + } + + /** + * Resolves value type from array-like type: + * list -> int + * list -> int|string + */ + private static function toValueType(Codebase $codebase, Union $array_like_type): ?Union + { + $value_types = []; + + foreach ($array_like_type->getAtomicTypes() as $atomic) { + if ($atomic instanceof Type\Atomic\TList) { + $value_types[] = $atomic->type_param; + } elseif ($atomic instanceof Type\Atomic\TArray) { + $value_types[] = $atomic->type_params[1]; + } elseif ($atomic instanceof Type\Atomic\TKeyedArray) { + $value_types[] = $atomic->getGenericValueType(); + } else { + return null; + } + } + + return Type::combineUnionTypeArray($value_types, $codebase); + } + + private static function createExpectedCallable( + Union $input_type, + DynamicTemplateProvider $template_provider, + int $return_template_offset = 0 + ): TCallable { + $expected_callable = new TCallable('callable'); + $expected_callable->params = [new FunctionLikeParameter('a', false, $input_type)]; + $expected_callable->return_type = new Union([ + $template_provider->createTemplate('T' . $return_template_offset) + ]); + + return $expected_callable; + } + + /** + * @return list + */ + private static function createRestCallables( + DynamicTemplateProvider $template_provider, + int $expected_callable_args_count + ): array { + $rest_callable_params = []; + + for ($template_offset = 0; $template_offset < $expected_callable_args_count - 1; $template_offset++) { + $rest_callable_params[] = self::createExpectedCallable( + new Union([ + $template_provider->createTemplate('T' . $template_offset) + ]), + $template_provider, + $template_offset + 1 + ); + } + + return $rest_callable_params; + } + + /** + * Extracts return type for custom_array_map from last callable arg. + * + * @param list $all_expected_callables + */ + private static function createReturnType(array $all_expected_callables): Union + { + $last_callable_arg = $all_expected_callables[count($all_expected_callables) - 1]; + + return new Union([ + new Type\Atomic\TList($last_callable_arg->return_type ?? Type::getMixed()) + ]); + } + + /** + * Creates variadic template list for custom_array_map function. + * + * @return list + */ + private static function createTemplates( + DynamicTemplateProvider $template_provider, + int $expected_callable_count + ): array { + $template_params = []; + + for ($i = 0; $i < $expected_callable_count; $i++) { + $template_params[] = $template_provider->createTemplate('T' . $i); + } + + return $template_params; + } +} diff --git a/tests/Config/Plugin/StoragePlugin.php b/tests/Config/Plugin/StoragePlugin.php new file mode 100644 index 00000000000..d5967486737 --- /dev/null +++ b/tests/Config/Plugin/StoragePlugin.php @@ -0,0 +1,19 @@ +registerHooksFromClass(CustomArrayMapFunctionStorageProvider::class); + } +} diff --git a/tests/Config/PluginTest.php b/tests/Config/PluginTest.php index 0de42408ea9..8968f00f948 100644 --- a/tests/Config/PluginTest.php +++ b/tests/Config/PluginTest.php @@ -1022,4 +1022,58 @@ function output(array $build) {} $this->analyzeFile($file_path, new Context()); } + + public function testFunctionDynamicStorageProviderHook(): void + { + require_once __DIR__ . '/Plugin/StoragePlugin.php'; + + $this->project_analyzer = $this->getProjectAnalyzerWithConfig( + TestConfig::loadFromXML( + dirname(__DIR__, 2) . DIRECTORY_SEPARATOR, + ' + + + + + + + + ' + ) + ); + + $this->project_analyzer->getCodebase()->config->initializePlugins($this->project_analyzer); + + $file_path = getcwd() . '/src/somefile.php'; + + $this->addFile( + $file_path, + ' $_list + */ + function acceptsList(array $_list): void { } + + /** @var list $list */ + $list = [1, 2, 3]; + + $tuples = custom_array_map( + fn($a) => $a + 1, + fn($a) => ["num" => $a], + $list + ); + + acceptsList($tuples);' + ); + + $this->analyzeFile($file_path, new Context()); + } }