From 51f37a1c0798013bc8c25856457fcffb4e0b2f2d Mon Sep 17 00:00:00 2001 From: Toon Verwerft Date: Sat, 8 Jan 2022 17:25:33 +0100 Subject: [PATCH] [Fun] calculate expected types per stage for pipe (#6) --- .github/workflows/coding-standards.yml | 6 +- .github/workflows/static-analysis.yml | 4 +- README.md | 7 + composer.json | 7 +- psalm.xml | 2 +- .../Fun/Pipe/PipeArgumentsProvider.php | 278 ++++++++++++++++++ src/Plugin.php | 3 + 7 files changed, 299 insertions(+), 8 deletions(-) create mode 100644 src/EventHandler/Fun/Pipe/PipeArgumentsProvider.php diff --git a/.github/workflows/coding-standards.yml b/.github/workflows/coding-standards.yml index 90d9ef3..5557df6 100644 --- a/.github/workflows/coding-standards.yml +++ b/.github/workflows/coding-standards.yml @@ -1,6 +1,6 @@ name: "coding standards" -on: +on: pull_request: ~ push: ~ @@ -15,7 +15,7 @@ jobs: - name: "installing PHP" uses: "shivammathur/setup-php@v2" with: - php-version: "7.4" + php-version: "8.1" ini-values: memory_limit=-1 tools: composer:v2, cs2pr extensions: bcmath, mbstring, intl, sodium, json @@ -27,4 +27,4 @@ jobs: run: "php vendor/bin/phpcs" - name: "checking coding standards ( php-cs-fixer )" - run: "php vendor/bin/php-cs-fixer fix --dry-run --diff --ansi" + run: "PHP_CS_FIXER_IGNORE_ENV=1 php vendor/bin/php-cs-fixer fix --dry-run --diff --ansi" diff --git a/.github/workflows/static-analysis.yml b/.github/workflows/static-analysis.yml index 8ba6e07..d8e9465 100644 --- a/.github/workflows/static-analysis.yml +++ b/.github/workflows/static-analysis.yml @@ -1,6 +1,6 @@ name: "static analysis" -on: +on: pull_request: ~ push: ~ schedule: @@ -17,7 +17,7 @@ jobs: - name: "installing PHP" uses: "shivammathur/setup-php@v2" with: - php-version: "7.4" + php-version: "8.1" ini-values: memory_limit=-1 tools: composer:v2, cs2pr extensions: bcmath, mbstring, intl, sodium, json diff --git a/README.md b/README.md index 96a4b26..0558deb 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,13 @@ Psalm assumes that `$input` is of type `array<"age"|"location"|"name", array<"ci If we enable the `php-standard-library/psalm-plugin` plugin, you will get a more specific and correct type of `array{name: string, age: int, location?: array{city: string, state: string, country: string}}`. +## Compatibility + +| PSL | Psalm plugin | +|-----|--------------| +| 2.x | 2.x | +| 1.x | 1.x | + ## Sponsors Thanks to our sponsors and supporters: diff --git a/composer.json b/composer.json index 7704667..d885d61 100644 --- a/composer.json +++ b/composer.json @@ -10,9 +10,12 @@ } ], "require": { - "php": "^7.4 || ^8.0", + "php": "^8.1", "vimeo/psalm": "^4.6" }, + "conflict": { + "azjezz/psl": "<2.0" + }, "require-dev": { "friendsofphp/php-cs-fixer": "^2.18", "roave/security-advisories": "dev-master", @@ -52,4 +55,4 @@ } }, "minimum-stability": "dev" -} \ No newline at end of file +} diff --git a/psalm.xml b/psalm.xml index b30f500..e924798 100644 --- a/psalm.xml +++ b/psalm.xml @@ -1,5 +1,5 @@ - + diff --git a/src/EventHandler/Fun/Pipe/PipeArgumentsProvider.php b/src/EventHandler/Fun/Pipe/PipeArgumentsProvider.php new file mode 100644 index 0000000..53b70b0 --- /dev/null +++ b/src/EventHandler/Fun/Pipe/PipeArgumentsProvider.php @@ -0,0 +1,278 @@ + + * @psalm-type Stages = non-empty-list + */ +class PipeArgumentsProvider implements FunctionParamsProviderInterface, FunctionReturnTypeProviderInterface +{ + /** + * @return array + */ + public static function getFunctionIds(): array + { + return [ + 'psl\fun\pipe' + ]; + } + + /** + * @return list|null + */ + public static function getFunctionParams(FunctionParamsProviderEvent $event): ?array + { + $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() + ); + } + + // 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, + ) + ]); + } + + 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)]); + } + + private static function createTemplatedType(string $name, Type\Union $baseType, string $definingClass): Type\Union + { + return new Type\Union([ + new Type\Atomic\TTemplateParam($name, $baseType, $definingClass) + ]); + } +} diff --git a/src/Plugin.php b/src/Plugin.php index 9379b4a..05d4752 100644 --- a/src/Plugin.php +++ b/src/Plugin.php @@ -29,6 +29,9 @@ public function __invoke(RegistrationInterface $registration, ?SimpleXMLElement */ private function getHooks(): iterable { + // Psl\Fun hooks + yield EventHandler\Fun\Pipe\PipeArgumentsProvider::class; + // Psl\Iter hooks yield EventHandler\Iter\First\FunctionReturnTypeProvider::class; yield EventHandler\Iter\FirstKey\FunctionReturnTypeProvider::class;