From b278de7c5d84eaf1e8de0a9c6ea022ca491fae73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Michael=20Vo=C5=99=C3=AD=C5=A1ek?= Date: Sun, 1 Oct 2023 17:50:07 +0200 Subject: [PATCH] Add callable template support --- doc/grammars/type.abnf | 20 +++-- src/Ast/Type/CallableTypeNode.php | 12 ++- src/Ast/Type/CallableTypeTemplateNode.php | 35 ++++++++ src/Parser/TypeParser.php | 82 +++++++++++++++++-- src/Printer/Printer.php | 14 +++- tests/PHPStan/Parser/TypeParserTest.php | 99 +++++++++++++++++++++++ tests/PHPStan/Printer/PrinterTest.php | 29 +++++++ 7 files changed, 273 insertions(+), 18 deletions(-) create mode 100644 src/Ast/Type/CallableTypeTemplateNode.php diff --git a/doc/grammars/type.abnf b/doc/grammars/type.abnf index 0b3247ba..36118d2b 100644 --- a/doc/grammars/type.abnf +++ b/doc/grammars/type.abnf @@ -35,7 +35,13 @@ GenericTypeArgument / TokenWildcard Callable - = TokenParenthesesOpen [CallableParameters] TokenParenthesesClose TokenColon CallableReturnType + = [CallableTemplate] TokenParenthesesOpen [CallableParameters] TokenParenthesesClose TokenColon CallableReturnType + +CallableTemplate + = TokenAngleBracketOpen CallableTemplateArgument *(TokenComma CallableTemplateArgument) TokenAngleBracketClose + +CallableTemplateArgument + = TokenIdentifier [1*ByteHorizontalWs TokenOf Type] CallableParameters = CallableParameter *(TokenComma CallableParameter) @@ -192,6 +198,9 @@ TokenIs TokenNot = %s"not" 1*ByteHorizontalWs +TokenOf + = %s"of" 1*ByteHorizontalWs + TokenContravariant = %s"contravariant" 1*ByteHorizontalWs @@ -211,7 +220,7 @@ TokenIdentifier ByteHorizontalWs = %x09 ; horizontal tab - / %x20 ; space + / " " ByteNumberSign = "+" @@ -238,11 +247,8 @@ ByteIdentifierFirst / %x80-FF ByteIdentifierSecond - = %x30-39 ; 0-9 - / %x41-5A ; A-Z - / "_" - / %x61-7A ; a-z - / %x80-FF + = ByteIdentifierFirst + / %x30-39 ; 0-9 ByteSingleQuote = %x27 ; ' diff --git a/src/Ast/Type/CallableTypeNode.php b/src/Ast/Type/CallableTypeNode.php index e57e5f82..87a484e9 100644 --- a/src/Ast/Type/CallableTypeNode.php +++ b/src/Ast/Type/CallableTypeNode.php @@ -13,6 +13,9 @@ class CallableTypeNode implements TypeNode /** @var IdentifierTypeNode */ public $identifier; + /** @var CallableTypeTemplateNode[] */ + public $templates; + /** @var CallableTypeParameterNode[] */ public $parameters; @@ -21,12 +24,14 @@ class CallableTypeNode implements TypeNode /** * @param CallableTypeParameterNode[] $parameters + * @param CallableTypeTemplateNode[] $templates */ - public function __construct(IdentifierTypeNode $identifier, array $parameters, TypeNode $returnType) + public function __construct(IdentifierTypeNode $identifier, array $parameters, TypeNode $returnType, array $templates = []) { $this->identifier = $identifier; $this->parameters = $parameters; $this->returnType = $returnType; + $this->templates = $templates; } @@ -36,8 +41,11 @@ public function __toString(): string if ($returnType instanceof self) { $returnType = "({$returnType})"; } + $template = $this->templates !== [] + ? '<' . implode(', ', $this->templates) . '>' + : ''; $parameters = implode(', ', $this->parameters); - return "{$this->identifier}({$parameters}): {$returnType}"; + return "{$this->identifier}{$template}({$parameters}): {$returnType}"; } } diff --git a/src/Ast/Type/CallableTypeTemplateNode.php b/src/Ast/Type/CallableTypeTemplateNode.php new file mode 100644 index 00000000..5f1d6cce --- /dev/null +++ b/src/Ast/Type/CallableTypeTemplateNode.php @@ -0,0 +1,35 @@ +identifier = $identifier; + $this->bound = $bound; + } + + public function __toString(): string + { + $res = (string) $this->identifier; + if ($this->bound !== null) { + $res .= ' of ' . $this->bound; + } + + return $res; + } + +} diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 46de7aae..91083582 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -162,13 +162,17 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode return $type; } - $type = $this->parseGeneric($tokens, $type); + $origType = $type; + $type = $this->tryParseCallable($tokens, $type, true); + if ($type === $origType) { + $type = $this->parseGeneric($tokens, $type); - if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) { - $type = $this->tryParseArrayOrOffsetAccess($tokens, $type); + if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) { + $type = $this->tryParseArrayOrOffsetAccess($tokens, $type); + } } } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_PARENTHESES)) { - $type = $this->tryParseCallable($tokens, $type); + $type = $this->tryParseCallable($tokens, $type, false); } elseif ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_SQUARE_BRACKET)) { $type = $this->tryParseArrayOrOffsetAccess($tokens, $type); @@ -458,8 +462,12 @@ public function parseGenericTypeArgument(TokenIterator $tokens): array /** @phpstan-impure */ - private function parseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $identifier): Ast\Type\TypeNode + private function parseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $identifier, bool $hasTemplate): Ast\Type\TypeNode { + $templates = $hasTemplate + ? $this->parseCallableTemplates($tokens) + : []; + $tokens->consumeTokenType(Lexer::TOKEN_OPEN_PARENTHESES); $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); @@ -484,7 +492,65 @@ private function parseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNod $startIndex = $tokens->currentTokenIndex(); $returnType = $this->enrichWithAttributes($tokens, $this->parseCallableReturnType($tokens), $startLine, $startIndex); - return new Ast\Type\CallableTypeNode($identifier, $parameters, $returnType); + return new Ast\Type\CallableTypeNode($identifier, $parameters, $returnType, $templates); + } + + + /** + * @return Ast\Type\CallableTypeTemplateNode[] + * + * @phpstan-impure + */ + private function parseCallableTemplates(TokenIterator $tokens): array + { + $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); + + $templates = []; + + $isFirst = true; + while ($isFirst || $tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + + // trailing comma case + if (!$isFirst && $tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET)) { + break; + } + $isFirst = false; + + $templates[] = $this->parseCallableTemplateArgument($tokens); + $tokens->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + } + + $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); + + return $templates; + } + + + private function parseCallableTemplateArgument(TokenIterator $tokens): Ast\Type\CallableTypeTemplateNode + { + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + + $identifier = $this->enrichWithAttributes( + $tokens, + new Ast\Type\IdentifierTypeNode($tokens->currentTokenValue()), + $startLine, + $startIndex + ); + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + + $bound = null; + if ($tokens->tryConsumeTokenValue('of')) { + $bound = $this->parse($tokens); + } + + return $this->enrichWithAttributes( + $tokens, + new Ast\Type\CallableTypeTemplateNode($identifier, $bound), + $startLine, + $startIndex + ); } @@ -662,11 +728,11 @@ private function parseCallableReturnType(TokenIterator $tokens): Ast\Type\TypeNo /** @phpstan-impure */ - private function tryParseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $identifier): Ast\Type\TypeNode + private function tryParseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $identifier, bool $hasTemplate): Ast\Type\TypeNode { try { $tokens->pushSavePoint(); - $type = $this->parseCallable($tokens, $identifier); + $type = $this->parseCallable($tokens, $identifier, $hasTemplate); $tokens->dropSavePoint(); } catch (ParserException $e) { diff --git a/src/Printer/Printer.php b/src/Printer/Printer.php index d7feaf91..00220eac 100644 --- a/src/Printer/Printer.php +++ b/src/Printer/Printer.php @@ -41,6 +41,7 @@ use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeTemplateNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; @@ -97,6 +98,7 @@ final class Printer ArrayShapeNode::class . '->items' => ', ', ObjectShapeNode::class . '->items' => ', ', CallableTypeNode::class . '->parameters' => ', ', + CallableTypeNode::class . '->templates' => ', ', GenericTypeNode::class . '->genericTypes' => ', ', ConstExprArrayNode::class . '->items' => ', ', MethodTagValueNode::class . '->parameters' => ', ', @@ -224,6 +226,11 @@ function (PhpDocChildNode $child): string { $isOptional = $node->isOptional ? '=' : ''; return trim("{$type}{$isReference}{$isVariadic}{$node->parameterName}") . $isOptional; } + if ($node instanceof CallableTypeTemplateNode) { + $identifier = $this->printType($node->identifier); + $bound = $node->bound !== null ? ' of ' . $this->printType($node->bound) : ''; + return "{$identifier}{$bound}"; + } if ($node instanceof DoctrineAnnotation) { return (string) $node; } @@ -370,10 +377,15 @@ private function printType(TypeNode $node): string } else { $returnType = $this->printType($node->returnType); } + $template = $node->templates !== [] + ? '<' . implode(', ', array_map(function (CallableTypeTemplateNode $templateNode): string { + return $this->print($templateNode); + }, $node->templates)) . '>' + : ''; $parameters = implode(', ', array_map(function (CallableTypeParameterNode $parameterNode): string { return $this->print($parameterNode); }, $node->parameters)); - return "{$node->identifier}({$parameters}): {$returnType}"; + return "{$node->identifier}{$template}({$parameters}): {$returnType}"; } if ($node instanceof ConditionalTypeForParameterNode) { return sprintf( diff --git a/tests/PHPStan/Parser/TypeParserTest.php b/tests/PHPStan/Parser/TypeParserTest.php index 2c66d98d..88658fa2 100644 --- a/tests/PHPStan/Parser/TypeParserTest.php +++ b/tests/PHPStan/Parser/TypeParserTest.php @@ -15,6 +15,7 @@ use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeTemplateNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeForParameterNode; use PHPStan\PhpDocParser\Ast\Type\ConditionalTypeNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; @@ -897,6 +898,104 @@ public function provideParseData(): array new IdentifierTypeNode('Foo') ), ], + [ + 'callable(B): C', + new CallableTypeNode( + new IdentifierTypeNode('callable'), + [ + new CallableTypeParameterNode( + new IdentifierTypeNode('B'), + false, + false, + '', + false + ), + ], + new IdentifierTypeNode('C'), + [ + new CallableTypeTemplateNode(new IdentifierTypeNode('A'), null), + ] + ), + ], + [ + 'callable<>(): void', + new ParserException( + '>', + Lexer::TOKEN_END, + 9, + Lexer::TOKEN_IDENTIFIER + ), + ], + [ + 'Closure(T, int): (T|false)', + new CallableTypeNode( + new IdentifierTypeNode('Closure'), + [ + new CallableTypeParameterNode( + new IdentifierTypeNode('T'), + false, + false, + '', + false + ), + new CallableTypeParameterNode( + new IdentifierTypeNode('int'), + false, + false, + '', + false + ), + ], + new UnionTypeNode([ + new IdentifierTypeNode('T'), + new IdentifierTypeNode('false'), + ]), + [ + new CallableTypeTemplateNode(new IdentifierTypeNode('T'), new IdentifierTypeNode('Model')), + ] + ), + ], + [ + '\Closure(Tx, Ty): array{ Ty, Tx }', + new CallableTypeNode( + new IdentifierTypeNode('\Closure'), + [ + new CallableTypeParameterNode( + new IdentifierTypeNode('Tx'), + false, + false, + '', + false + ), + new CallableTypeParameterNode( + new IdentifierTypeNode('Ty'), + false, + false, + '', + false + ), + ], + new ArrayShapeNode([ + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('Ty') + ), + new ArrayShapeItemNode( + null, + false, + new IdentifierTypeNode('Tx') + ), + ]), + [ + new CallableTypeTemplateNode(new IdentifierTypeNode('Tx'), new UnionTypeNode([ + new IdentifierTypeNode('X'), + new IdentifierTypeNode('Z'), + ])), + new CallableTypeTemplateNode(new IdentifierTypeNode('Ty'), new IdentifierTypeNode('Y')), + ] + ), + ], [ '(Foo\\Bar, (int | (string & bar)[])> | Lorem)', new UnionTypeNode([ diff --git a/tests/PHPStan/Printer/PrinterTest.php b/tests/PHPStan/Printer/PrinterTest.php index 746ad027..5d6fabc0 100644 --- a/tests/PHPStan/Printer/PrinterTest.php +++ b/tests/PHPStan/Printer/PrinterTest.php @@ -28,6 +28,7 @@ use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeNode; use PHPStan\PhpDocParser\Ast\Type\CallableTypeParameterNode; +use PHPStan\PhpDocParser\Ast\Type\CallableTypeTemplateNode; use PHPStan\PhpDocParser\Ast\Type\ConstTypeNode; use PHPStan\PhpDocParser\Ast\Type\GenericTypeNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; @@ -589,6 +590,34 @@ public function enterNode(Node $node) }; + $addCallableTemplateType = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof CallableTypeNode) { + $node->templates[] = new CallableTypeTemplateNode( + new IdentifierTypeNode('T'), + new IdentifierTypeNode('int') + ); + } + + return $node; + } + + }; + + yield [ + '/** @var Closure(): T */', + '/** @var Closure(): T */', + $addCallableTemplateType, + ]; + + yield [ + '/** @var \Closure(U): T */', + '/** @var \Closure(U): T */', + $addCallableTemplateType, + ]; + yield [ '/** * @param callable(): void $cb