diff --git a/src/EventHandler/Fun/Pipe/PipeArgumentsProvider.php b/src/EventHandler/Fun/Pipe/PipeArgumentsProvider.php index 53b70b0..63d9d3b 100644 --- a/src/EventHandler/Fun/Pipe/PipeArgumentsProvider.php +++ b/src/EventHandler/Fun/Pipe/PipeArgumentsProvider.php @@ -5,274 +5,91 @@ namespace Psl\Psalm\EventHandler\Fun\Pipe; use Closure; -use PhpParser\Node\Arg; -use PhpParser\Node\ComplexType; -use PhpParser\Node\Expr; -use PhpParser\Node\FunctionLike; -use PhpParser\Node\Identifier; -use PhpParser\Node\Name; -use PhpParser\Node\Param; -use PhpParser\Node\Stmt\Return_; -use PhpParser\NodeAbstract; -use Psalm\CodeLocation; -use Psalm\Issue\TooFewArguments; -use Psalm\Issue\TooManyArguments; -use Psalm\IssueBuffer; -use Psalm\Plugin\EventHandler\Event\FunctionParamsProviderEvent; -use Psalm\Plugin\EventHandler\Event\FunctionReturnTypeProviderEvent; -use Psalm\Plugin\EventHandler\FunctionParamsProviderInterface; -use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface; -use Psalm\StatementsSource; +use Psalm\Plugin\DynamicFunctionStorage; +use Psalm\Plugin\DynamicTemplateProvider; +use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface; +use Psalm\Plugin\EventHandler\Event\DynamicFunctionStorageProviderEvent; use Psalm\Storage\FunctionLikeParameter; -use Psalm\Type; +use Psalm\Type\Atomic\TClosure; +use Psalm\Type\Atomic\TTemplateParam; +use Psalm\Type\Union; -/** - * @psalm-type Stage = array{0: Type\Union, 1: Type\Union, 2: string} - * @psalm-type StagesOrEmpty = list - * @psalm-type Stages = non-empty-list - */ -class PipeArgumentsProvider implements FunctionParamsProviderInterface, FunctionReturnTypeProviderInterface +use function array_map; +use function count; +use function range; + +final class PipeArgumentsProvider implements DynamicFunctionStorageProviderInterface { /** * @return array */ public static function getFunctionIds(): array { - return [ - 'psl\fun\pipe' - ]; + return ['psl\fun\pipe']; } - /** - * @return list|null - */ - public static function getFunctionParams(FunctionParamsProviderEvent $event): ?array + public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage { - $stages = self::parseStages($event->getStatementsSource(), $event->getCallArgs()); - if (!$stages) { - return []; - } - - $params = []; - $previousOut = self::pipeInputType($stages); - - foreach ($stages as $stage) { - [$_, $currentOut, $paramName] = $stage; - - $params[] = self::createFunctionParameter( - 'stages', - self::createClosureStage($previousOut, $currentOut, $paramName) - ); - - $previousOut = $currentOut; - } - - return $params; - } - - public static function getFunctionReturnType(FunctionReturnTypeProviderEvent $event): ?Type\Union - { - $stages = self::parseStages($event->getStatementsSource(), $event->getCallArgs()); - if (!$stages) { - // - // @see https://github.com/vimeo/psalm/issues/7244 - // Currently, templated arguments are not being resolved in closures / callables - // For now, we fall back to the built-in types. - - // $templated = self::createTemplatedType('T', Type::getMixed(), 'fn-'.$event->getFunctionId()); - // return self::createClosureStage($templated, $templated, 'input'); - - return null; - } - - $in = self::pipeInputType($stages); - $out = self::pipeOutputType($stages); - - return self::createClosureStage($in, $out, 'input'); - } - - /** - * @param array $args - * - * @return StagesOrEmpty - */ - private static function parseStages(StatementsSource $source, array $args): array - { - $stages = []; - foreach ($args as $arg) { - $stage = $arg->value; - - if (!$stage instanceof FunctionLike) { - // The stage could also be an expression instead of a function-like. - // This plugin currently only supports function-like statements. - // All other input is considered to result in a mixed -> mixed stage - // This way we can still recover if types are known in later stages. - - // Expressions currently not covered: - - // New_ expression for invokables - // Variable for variables that can point to either FunctionLike or New_ - // Assignments during a pipe level: $x = fn () => 123 - // `x(...)` results in FuncCall(args: {0: VariadicPlaceholder}) - // ... - - // Haven't found a way to get the resulting type of an expression in psalm yet. - - $stages[] = [Type::getMixed(), Type::getMixed(), 'input']; - continue; - } - - $params = $stage->getParams(); - $paramName = self::parseNameFromParam($params[0] ?? null); - - $in = self::determineValidatedStageInputParam($source, $stage); - $out = self::parseTypeFromASTNode($source, $stage->getReturnType()); - - $stages[] = [$in, $out, $paramName]; - } - - return $stages; - } - - /** - * This function first validates the parameters of the stage. - * A stage should have exactly one required input parameter. - * - * - If there are no parameters, the input parameter is ignored. - * - If there are too many required parameters, this will result in a runtime exception. - * - * In both situations, we can continue building up the stages - * so that the user has as much analyzer info as possible. - */ - private static function determineValidatedStageInputParam(StatementsSource $source, FunctionLike $stage): Type\Union - { - $params = $stage->getParams(); - - if (count($params) === 0) { - IssueBuffer::maybeAdd( - new TooFewArguments( - 'Pipe stage functions require exactly one input parameter, none given. ' . - 'This will ignore the input value.', - new CodeLocation($source, $stage) - ), - $source->getSuppressedIssues() - ); - } + $template_provider = $event->getTemplateProvider(); + $callable_args_count = count($event->getArgs()); + + // Create AB closure pairs + $pipe_callables = array_map( + static fn(int $callable_offset) => self::createABClosure( + self::createTemplateFromOffset($template_provider, $callable_offset), + self::createTemplateFromOffset($template_provider, $callable_offset + 1), + ), + range(1, $callable_args_count) + ); - // The pipe function will crash during runtime when there are more than 1 function parameters required. - // We can still determine the stages Input / Output types at this point. - if (count($params) > 1 && !($params[1] ?? null)?->default) { - IssueBuffer::maybeAdd( - new TooManyArguments( - 'Pipe stage functions can only deal with one input parameter.', - new CodeLocation($source, $params[1]) + $pipe_storage = new DynamicFunctionStorage(); + $pipe_storage->params = [ + ...array_map( + static fn(TClosure $callable, int $offset) => self::createParam( + "fn_${offset}", + new Union([$callable]), ), - $source->getSuppressedIssues() - ); - } - - $type = $params ? $params[0]->type : null; - - return self::parseTypeFromASTNode($source, $type); - } - - /** - * This function tries parsing the node type based on psalm's NodeTypeProvider. - * If that one is not able to determine the type, this function will fall back on parsing the AST's node type. - * In case we are not able to determine the type, this function falls back to the $default type. - */ - private static function parseTypeFromASTNode( - StatementsSource $source, - ?NodeAbstract $node, - string $default = 'mixed' - ): Type\Union { - if (!$node || $node instanceof ComplexType) { - return self::createSimpleType($default); - } - - $nodeType = null; - if ($node instanceof Expr || $node instanceof Name || $node instanceof Return_) { - $nodeTypeProvider = $source->getNodeTypeProvider(); - $nodeType = $nodeTypeProvider->getType($node); - } - - if (!$nodeType && ($node instanceof Name || $node instanceof Identifier)) { - $nodeType = self::createSimpleType($node->toString() ?: $default); - } - - return $nodeType ?? self::createSimpleType($default); - } - - private static function parseNameFromParam(?Param $param, string $default = 'input'): string - { - if (!$param) { - return $default; - } - - $var = $param->var; - if (!$var instanceof Expr\Variable) { - return $default; - } - - return is_string($var->name) ? $var->name : $default; - } - - /** - * @param Stages $stages - */ - private static function pipeInputType(array $stages): Type\Union - { - $firstStage = array_shift($stages); - [$in, $_, $_] = $firstStage; - - return $in; - } - - /** - * @param Stages $stages - */ - private static function pipeOutputType(array $stages): Type\Union - { - $lastStage = array_pop($stages); - [$_, $out, $_] = $lastStage; + $pipe_callables, + array_keys($pipe_callables) + ) + ]; - return $out; - } + // Add Pipe template list for each callable + $pipe_storage->templates = array_map( + static fn($offset) => self::createTemplateFromOffset($template_provider, $offset), + range(1, $callable_args_count + 1), + ); - private static function createClosureStage(Type\Union $in, Type\Union $out, string $paramName): Type\Union - { - return new Type\Union([ - new Type\Atomic\TClosure( - value: Closure::class, - params: [ - self::createFunctionParameter($paramName, $in), - ], - return_type: $out, + // Pipe return type from templates T1 -> TLast (Where TLast could also be T1 when no arguments are provided.) + $pipe_storage->return_type = new Union([ + self::createABClosure( + current($pipe_storage->templates), + end($pipe_storage->templates) ) ]); + + return $pipe_storage; } - private static function createFunctionParameter(string $name, Type\Union $type): FunctionLikeParameter - { - return new FunctionLikeParameter( - $name, - false, - $type, - is_optional: false, - is_nullable: false, - is_variadic: false, - ); + private static function createTemplateFromOffset( + DynamicTemplateProvider $template_provider, + int $offset + ): TTemplateParam { + return $template_provider->createTemplate("T${offset}"); } - private static function createSimpleType(string $type): Type\Union - { - return new Type\Union([Type\Atomic::create($type)]); + private static function createABClosure( + TTemplateParam $aType, + TTemplateParam $bType + ): TClosure { + $a = self::createParam('input', new Union([$aType])); + $b = new Union([$bType]); + + return new TClosure(Closure::class, [$a], $b); } - private static function createTemplatedType(string $name, Type\Union $baseType, string $definingClass): Type\Union + private static function createParam(string $name, Union $type): FunctionLikeParameter { - return new Type\Union([ - new Type\Atomic\TTemplateParam($name, $baseType, $definingClass) - ]); + return new FunctionLikeParameter($name, false, $type); } } diff --git a/src/Plugin.php b/src/Plugin.php index 05d4752..d0fb502 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -4,6 +4,7 @@ namespace Psl\Psalm; +use Psalm\Plugin\EventHandler\DynamicFunctionStorageProviderInterface; use Psalm\Plugin\EventHandler\FunctionReturnTypeProviderInterface; use Psalm\Plugin\PluginEntryPointInterface; use Psalm\Plugin\RegistrationInterface; @@ -25,7 +26,9 @@ public function __invoke(RegistrationInterface $registration, ?SimpleXMLElement } /** - * @return iterable> + * @template T + * + * @return iterable|class-string> */ private function getHooks(): iterable {