Skip to content

Commit

Permalink
Better pipe support
Browse files Browse the repository at this point in the history
  • Loading branch information
veewee committed Nov 30, 2022
1 parent bf223f6 commit 02b9e19
Showing 1 changed file with 62 additions and 243 deletions.
305 changes: 62 additions & 243 deletions src/EventHandler/Fun/Pipe/PipeArgumentsProvider.php
Expand Up @@ -2,277 +2,96 @@

declare(strict_types=1);

namespace Psl\Psalm\EventHandler\Fun\Pipe;
namespace Psalm\Tests\Config\Plugin\Hook;

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\TCallable;
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 PipeFunctionPlugin implements DynamicFunctionStorageProviderInterface
{
/**
* @return array<lowercase-string>
*/
public static function getFunctionIds(): array
{
return [
'psl\fun\pipe'
];
return ['psl\fun\pipe'];
}

/**
* @return list<FunctionLikeParameter>|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');
$template_provider = $event->getTemplateProvider();
$callable_args_count = count($event->getArgs()) - 1;

if ($callable_args_count < 1) {
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()
);
}
// All expected callables for pipe
$pipe_callables = array_map(
static fn($callable_offset) => self::createABCallable($callable_offset, $template_provider),
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 = [
// First pipe param
self::createParam(
'pipe_input',
new Union([$template_provider->createTemplate('T1')])
),
// Rest pipe callable params
...array_map(
static fn(TCallable $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;
}
$pipe_callables,
array_keys($pipe_callables)
)
];

return is_string($var->name) ? $var->name : $default;
}
// Pipe return type from last callable
$pipe_storage->return_type = $pipe_callables[array_key_last($pipe_callables)]->return_type;

/**
* @param Stages $stages
*/
private static function pipeInputType(array $stages): Type\Union
{
$firstStage = array_shift($stages);
[$in, $_, $_] = $firstStage;
// Pipe template list for each callable
$pipe_storage->templates = array_map(
static fn($offset) => $template_provider->createTemplate('T' . $offset),
range(1, $callable_args_count + 1),
);

return $in;
return $pipe_storage;
}

/**
* @param Stages $stages
*/
private static function pipeOutputType(array $stages): Type\Union
{
$lastStage = array_pop($stages);
[$_, $out, $_] = $lastStage;

return $out;
}
private static function createABCallable(
int $callable_offset,
DynamicTemplateProvider $template_provider
): TCallable {
$a = self::createParam(
'input',
new Union([
$template_provider->createTemplate('T' . $callable_offset),
])
);

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,
)
$b = new Union([
$template_provider->createTemplate('T' . ($callable_offset + 1)),
]);
}

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 createSimpleType(string $type): Type\Union
{
return new Type\Union([Type\Atomic::create($type)]);
return new TCallable('callable', [$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);
}
}

0 comments on commit 02b9e19

Please sign in to comment.