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..4c913198 100644 --- a/src/Ast/Type/CallableTypeNode.php +++ b/src/Ast/Type/CallableTypeNode.php @@ -3,6 +3,7 @@ namespace PHPStan\PhpDocParser\Ast\Type; use PHPStan\PhpDocParser\Ast\NodeAttributes; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use function implode; class CallableTypeNode implements TypeNode @@ -13,6 +14,9 @@ class CallableTypeNode implements TypeNode /** @var IdentifierTypeNode */ public $identifier; + /** @var TemplateTagValueNode[] */ + public $templateTypes; + /** @var CallableTypeParameterNode[] */ public $parameters; @@ -21,12 +25,14 @@ class CallableTypeNode implements TypeNode /** * @param CallableTypeParameterNode[] $parameters + * @param TemplateTagValueNode[] $templateTypes */ - public function __construct(IdentifierTypeNode $identifier, array $parameters, TypeNode $returnType) + public function __construct(IdentifierTypeNode $identifier, array $parameters, TypeNode $returnType, array $templateTypes = []) { $this->identifier = $identifier; $this->parameters = $parameters; $this->returnType = $returnType; + $this->templateTypes = $templateTypes; } @@ -36,8 +42,11 @@ public function __toString(): string if ($returnType instanceof self) { $returnType = "({$returnType})"; } + $template = $this->templateTypes !== [] + ? '<' . implode(', ', $this->templateTypes) . '>' + : ''; $parameters = implode(', ', $this->parameters); - return "{$this->identifier}({$parameters}): {$returnType}"; + return "{$this->identifier}{$template}({$parameters}): {$returnType}"; } } diff --git a/src/Parser/PhpDocParser.php b/src/Parser/PhpDocParser.php index e87d92c4..b6356408 100644 --- a/src/Parser/PhpDocParser.php +++ b/src/Parser/PhpDocParser.php @@ -449,7 +449,12 @@ public function parseTagValue(TokenIterator $tokens, string $tag): Ast\PhpDoc\Ph case '@template-contravariant': case '@phpstan-template-contravariant': case '@psalm-template-contravariant': - $tagValue = $this->parseTemplateTagValue($tokens, true); + $tagValue = $this->typeParser->parseTemplateTagValue( + $tokens, + function ($tokens) { + return $this->parseOptionalDescription($tokens); + } + ); break; case '@extends': @@ -947,7 +952,12 @@ private function parseMethodTagValue(TokenIterator $tokens): Ast\PhpDoc\MethodTa do { $startLine = $tokens->currentTokenLine(); $startIndex = $tokens->currentTokenIndex(); - $templateTypes[] = $this->enrichWithAttributes($tokens, $this->parseTemplateTagValue($tokens, false), $startLine, $startIndex); + $templateTypes[] = $this->enrichWithAttributes( + $tokens, + $this->typeParser->parseTemplateTagValue($tokens), + $startLine, + $startIndex + ); } while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); } @@ -1003,33 +1013,6 @@ private function parseMethodTagValueParameter(TokenIterator $tokens): Ast\PhpDoc ); } - private function parseTemplateTagValue(TokenIterator $tokens, bool $parseDescription): Ast\PhpDoc\TemplateTagValueNode - { - $name = $tokens->currentTokenValue(); - $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); - - if ($tokens->tryConsumeTokenValue('of') || $tokens->tryConsumeTokenValue('as')) { - $bound = $this->typeParser->parse($tokens); - - } else { - $bound = null; - } - - if ($tokens->tryConsumeTokenValue('=')) { - $default = $this->typeParser->parse($tokens); - } else { - $default = null; - } - - if ($parseDescription) { - $description = $this->parseOptionalDescription($tokens); - } else { - $description = ''; - } - - return new Ast\PhpDoc\TemplateTagValueNode($name, $bound, $description, $default); - } - private function parseExtendsTagValue(string $tagName, TokenIterator $tokens): Ast\PhpDoc\PhpDocTagValueNode { $startLine = $tokens->currentTokenLine(); diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index 79e70275..ebc2fbab 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -4,6 +4,7 @@ use LogicException; use PHPStan\PhpDocParser\Ast; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Lexer\Lexer; use function in_array; use function str_replace; @@ -164,13 +165,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); @@ -464,10 +469,48 @@ public function parseGenericTypeArgument(TokenIterator $tokens): array return [$type, $variance]; } + /** + * @throws ParserException + * @param ?callable(TokenIterator): string $parseDescription + */ + public function parseTemplateTagValue( + TokenIterator $tokens, + ?callable $parseDescription = null + ): TemplateTagValueNode + { + $name = $tokens->currentTokenValue(); + $tokens->consumeTokenType(Lexer::TOKEN_IDENTIFIER); + + if ($tokens->tryConsumeTokenValue('of') || $tokens->tryConsumeTokenValue('as')) { + $bound = $this->parse($tokens); + + } else { + $bound = null; + } + + if ($tokens->tryConsumeTokenValue('=')) { + $default = $this->parse($tokens); + } else { + $default = null; + } + + if ($parseDescription !== null) { + $description = $parseDescription($tokens); + } else { + $description = ''; + } + + return new Ast\PhpDoc\TemplateTagValueNode($name, $bound, $description, $default); + } + /** @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); @@ -492,7 +535,52 @@ 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\PhpDoc\TemplateTagValueNode[] + * + * @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\PhpDoc\TemplateTagValueNode + { + $startLine = $tokens->currentTokenLine(); + $startIndex = $tokens->currentTokenIndex(); + + return $this->enrichWithAttributes( + $tokens, + $this->parseTemplateTagValue($tokens), + $startLine, + $startIndex + ); } @@ -670,11 +758,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 0093e6ca..d9a060b3 100644 --- a/src/Printer/Printer.php +++ b/src/Printer/Printer.php @@ -99,6 +99,7 @@ final class Printer ArrayShapeNode::class . '->items' => ', ', ObjectShapeNode::class . '->items' => ', ', CallableTypeNode::class . '->parameters' => ', ', + CallableTypeNode::class . '->templateTypes' => ', ', GenericTypeNode::class . '->genericTypes' => ', ', ConstExprArrayNode::class . '->items' => ', ', MethodTagValueNode::class . '->parameters' => ', ', @@ -380,10 +381,15 @@ private function printType(TypeNode $node): string } else { $returnType = $this->printType($node->returnType); } + $template = $node->templateTypes !== [] + ? '<' . implode(', ', array_map(function (TemplateTagValueNode $templateNode): string { + return $this->print($templateNode); + }, $node->templateTypes)) . '>' + : ''; $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 0cc294f5..f4d656dd 100644 --- a/tests/PHPStan/Parser/TypeParserTest.php +++ b/tests/PHPStan/Parser/TypeParserTest.php @@ -10,6 +10,7 @@ use PHPStan\PhpDocParser\Ast\ConstExpr\QuoteAwareConstExprStringNode; use PHPStan\PhpDocParser\Ast\Node; use PHPStan\PhpDocParser\Ast\NodeTraverser; +use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; use PHPStan\PhpDocParser\Ast\Type\ArrayTypeNode; @@ -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 TemplateTagValueNode('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 TemplateTagValueNode('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 TemplateTagValueNode('Tx', new UnionTypeNode([ + new IdentifierTypeNode('X'), + new IdentifierTypeNode('Z'), + ]), ''), + new TemplateTagValueNode('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 2307cdb2..34a2e893 100644 --- a/tests/PHPStan/Printer/PrinterTest.php +++ b/tests/PHPStan/Printer/PrinterTest.php @@ -590,6 +590,35 @@ public function enterNode(Node $node) }; + $addCallableTemplateType = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof CallableTypeNode) { + $node->templateTypes[] = new TemplateTagValueNode( + '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