mirror of
https://github.com/danog/psalm-plugin.git
synced 2024-11-26 12:25:05 +01:00
chore: better pipe support (#8)
This commit is contained in:
parent
bf223f6865
commit
613569397e
@ -5,274 +5,91 @@ declare(strict_types=1);
|
||||
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<Stage>
|
||||
* @psalm-type Stages = non-empty-list<Stage>
|
||||
*/
|
||||
class PipeArgumentsProvider implements FunctionParamsProviderInterface, FunctionReturnTypeProviderInterface
|
||||
use function array_map;
|
||||
use function count;
|
||||
use function range;
|
||||
|
||||
final class PipeArgumentsProvider implements DynamicFunctionStorageProviderInterface
|
||||
{
|
||||
/**
|
||||
* @return array<lowercase-string>
|
||||
*/
|
||||
public static function getFunctionIds(): array
|
||||
{
|
||||
return [
|
||||
'psl\fun\pipe'
|
||||
return ['psl\fun\pipe'];
|
||||
}
|
||||
|
||||
public static function getFunctionStorage(DynamicFunctionStorageProviderEvent $event): ?DynamicFunctionStorage
|
||||
{
|
||||
$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)
|
||||
);
|
||||
|
||||
$pipe_storage = new DynamicFunctionStorage();
|
||||
$pipe_storage->params = [
|
||||
...array_map(
|
||||
static fn(TClosure $callable, int $offset) => self::createParam(
|
||||
"fn_${offset}",
|
||||
new Union([$callable]),
|
||||
),
|
||||
$pipe_callables,
|
||||
array_keys($pipe_callables)
|
||||
)
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return list<FunctionLikeParameter>|null
|
||||
*/
|
||||
public static function getFunctionParams(FunctionParamsProviderEvent $event): ?array
|
||||
{
|
||||
$stages = self::parseStages($event->getStatementsSource(), $event->getCallArgs());
|
||||
if (!$stages) {
|
||||
return [];
|
||||
}
|
||||
// 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),
|
||||
);
|
||||
|
||||
$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<array-key, Arg> $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()
|
||||
);
|
||||
}
|
||||
|
||||
// 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])
|
||||
),
|
||||
$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;
|
||||
|
||||
return $out;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
@ -4,6 +4,7 @@ declare(strict_types=1);
|
||||
|
||||
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 @@ final class Plugin implements PluginEntryPointInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* @return iterable<class-string<FunctionReturnTypeProviderInterface>>
|
||||
* @template T
|
||||
*
|
||||
* @return iterable<class-string<FunctionReturnTypeProviderInterface>|class-string<DynamicFunctionStorageProviderInterface>>
|
||||
*/
|
||||
private function getHooks(): iterable
|
||||
{
|
||||
|
Loading…
Reference in New Issue
Block a user