Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Better pipe plugin #8

Merged
merged 1 commit into from Dec 5, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
307 changes: 62 additions & 245 deletions src/EventHandler/Fun/Pipe/PipeArgumentsProvider.php
Expand Up @@ -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<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'];
}

/**
* @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');

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()
);
}
$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);
}
}
5 changes: 4 additions & 1 deletion src/Plugin.php
Expand Up @@ -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;
Expand All @@ -25,7 +26,9 @@ public function __invoke(RegistrationInterface $registration, ?SimpleXMLElement
}

/**
* @return iterable<class-string<FunctionReturnTypeProviderInterface>>
* @template T
*
* @return iterable<class-string<FunctionReturnTypeProviderInterface>|class-string<DynamicFunctionStorageProviderInterface>>
*/
private function getHooks(): iterable
{
Expand Down