From 9b30d6fd026b2c132b3985ce6b23bec09ab3aa68 Mon Sep 17 00:00:00 2001 From: Max Loeb Date: Sat, 15 Feb 2025 21:50:22 +0100 Subject: [PATCH] TypeParser - support comments at EOL with `//` --- README.md | 2 +- src/Ast/Attribute.php | 2 + src/Ast/Comment.php | 28 + src/Ast/NodeAttributes.php | 4 + src/Lexer/Lexer.php | 4 + src/Parser/PhpDocParser.php | 10 + src/Parser/TokenIterator.php | 62 ++- src/Parser/TypeParser.php | 112 ++-- src/ParserConfig.php | 5 +- src/Printer/Printer.php | 85 ++- tests/PHPStan/Parser/PhpDocParserTest.php | 43 ++ tests/PHPStan/Parser/TypeParserTest.php | 233 ++++++++- tests/PHPStan/Printer/PrinterTest.php | 601 +++++++++++++++++++++- 13 files changed, 1110 insertions(+), 81 deletions(-) create mode 100644 src/Ast/Comment.php diff --git a/README.md b/README.md index 2dae7b2d..15ac9a97 100644 --- a/README.md +++ b/README.md @@ -83,7 +83,7 @@ use PHPStan\PhpDocParser\Printer\Printer; // basic setup with enabled required lexer attributes -$config = new ParserConfig(usedAttributes: ['lines' => true, 'indexes' => true]); +$config = new ParserConfig(usedAttributes: ['lines' => true, 'indexes' => true, 'comments' => true]); $lexer = new Lexer($config); $constExprParser = new ConstExprParser($config); $typeParser = new TypeParser($config, $constExprParser); diff --git a/src/Ast/Attribute.php b/src/Ast/Attribute.php index cd3a0a29..1f770ded 100644 --- a/src/Ast/Attribute.php +++ b/src/Ast/Attribute.php @@ -13,4 +13,6 @@ final class Attribute public const ORIGINAL_NODE = 'originalNode'; + public const COMMENTS = 'comments'; + } diff --git a/src/Ast/Comment.php b/src/Ast/Comment.php new file mode 100644 index 00000000..79e24ebb --- /dev/null +++ b/src/Ast/Comment.php @@ -0,0 +1,28 @@ +text = $text; + $this->startLine = $startLine; + $this->startIndex = $startIndex; + } + + public function getReformattedText(): string + { + return trim($this->text); + } + +} diff --git a/src/Ast/NodeAttributes.php b/src/Ast/NodeAttributes.php index a7ddec39..4f2ca76d 100644 --- a/src/Ast/NodeAttributes.php +++ b/src/Ast/NodeAttributes.php @@ -15,6 +15,10 @@ trait NodeAttributes */ public function setAttribute(string $key, $value): void { + if ($value === null) { + unset($this->attributes[$key]); + return; + } $this->attributes[$key] = $value; } diff --git a/src/Lexer/Lexer.php b/src/Lexer/Lexer.php index be85fa9f..b2669131 100644 --- a/src/Lexer/Lexer.php +++ b/src/Lexer/Lexer.php @@ -51,6 +51,8 @@ class Lexer public const TOKEN_NEGATED = 35; public const TOKEN_ARROW = 36; + public const TOKEN_COMMENT = 37; + public const TOKEN_LABELS = [ self::TOKEN_REFERENCE => '\'&\'', self::TOKEN_UNION => '\'|\'', @@ -66,6 +68,7 @@ class Lexer self::TOKEN_OPEN_CURLY_BRACKET => '\'{\'', self::TOKEN_CLOSE_CURLY_BRACKET => '\'}\'', self::TOKEN_COMMA => '\',\'', + self::TOKEN_COMMENT => '\'//\'', self::TOKEN_COLON => '\':\'', self::TOKEN_VARIADIC => '\'...\'', self::TOKEN_DOUBLE_COLON => '\'::\'', @@ -160,6 +163,7 @@ private function generateRegexp(): string self::TOKEN_CLOSE_CURLY_BRACKET => '\\}', self::TOKEN_COMMA => ',', + self::TOKEN_COMMENT => '\/\/[^\\r\\n]*(?=\n|\r|\*/)', self::TOKEN_VARIADIC => '\\.\\.\\.', self::TOKEN_DOUBLE_COLON => '::', self::TOKEN_DOUBLE_ARROW => '=>', diff --git a/src/Parser/PhpDocParser.php b/src/Parser/PhpDocParser.php index ac717b95..559d8fd5 100644 --- a/src/Parser/PhpDocParser.php +++ b/src/Parser/PhpDocParser.php @@ -116,9 +116,19 @@ public function parse(TokenIterator $tokens): Ast\PhpDoc\PhpDocNode $tokens->forwardToTheEnd(); + $comments = $tokens->flushComments(); + if ($comments !== []) { + throw new LogicException('Comments should already be flushed'); + } + return $this->enrichWithAttributes($tokens, new Ast\PhpDoc\PhpDocNode([$this->enrichWithAttributes($tokens, $tag, $startLine, $startIndex)]), 1, 0); } + $comments = $tokens->flushComments(); + if ($comments !== []) { + throw new LogicException('Comments should already be flushed'); + } + return $this->enrichWithAttributes($tokens, new Ast\PhpDoc\PhpDocNode($children), 1, 0); } diff --git a/src/Parser/TokenIterator.php b/src/Parser/TokenIterator.php index a9738d62..f2be3da4 100644 --- a/src/Parser/TokenIterator.php +++ b/src/Parser/TokenIterator.php @@ -3,6 +3,7 @@ namespace PHPStan\PhpDocParser\Parser; use LogicException; +use PHPStan\PhpDocParser\Ast\Comment; use PHPStan\PhpDocParser\Lexer\Lexer; use function array_pop; use function assert; @@ -19,7 +20,10 @@ class TokenIterator private int $index; - /** @var int[] */ + /** @var list */ + private array $comments = []; + + /** @var list}> */ private array $savePoints = []; /** @var list */ @@ -152,8 +156,7 @@ public function consumeTokenType(int $tokenType): void } } - $this->index++; - $this->skipIrrelevantTokens(); + $this->next(); } @@ -166,8 +169,7 @@ public function consumeTokenValue(int $tokenType, string $tokenValue): void $this->throwError($tokenType, $tokenValue); } - $this->index++; - $this->skipIrrelevantTokens(); + $this->next(); } @@ -178,12 +180,20 @@ public function tryConsumeTokenValue(string $tokenValue): bool return false; } - $this->index++; - $this->skipIrrelevantTokens(); + $this->next(); return true; } + /** + * @return list + */ + public function flushComments(): array + { + $res = $this->comments; + $this->comments = []; + return $res; + } /** @phpstan-impure */ public function tryConsumeTokenType(int $tokenType): bool @@ -198,14 +208,15 @@ public function tryConsumeTokenType(int $tokenType): bool } } - $this->index++; - $this->skipIrrelevantTokens(); + $this->next(); return true; } - /** @phpstan-impure */ + /** + * @deprecated Use skipNewLineTokensAndConsumeComments instead (when parsing a type) + */ public function skipNewLineTokens(): void { if (!$this->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) { @@ -218,6 +229,29 @@ public function skipNewLineTokens(): void } + public function skipNewLineTokensAndConsumeComments(): void + { + if ($this->currentTokenType() === Lexer::TOKEN_COMMENT) { + $this->comments[] = new Comment($this->currentTokenValue(), $this->currentTokenLine(), $this->currentTokenIndex()); + $this->next(); + } + + if (!$this->isCurrentTokenType(Lexer::TOKEN_PHPDOC_EOL)) { + return; + } + + do { + $foundNewLine = $this->tryConsumeTokenType(Lexer::TOKEN_PHPDOC_EOL); + if ($this->currentTokenType() !== Lexer::TOKEN_COMMENT) { + continue; + } + + $this->comments[] = new Comment($this->currentTokenValue(), $this->currentTokenLine(), $this->currentTokenIndex()); + $this->next(); + } while ($foundNewLine === true); + } + + private function detectNewline(): void { $value = $this->currentTokenValue(); @@ -293,7 +327,7 @@ public function forwardToTheEnd(): void public function pushSavePoint(): void { - $this->savePoints[] = $this->index; + $this->savePoints[] = [$this->index, $this->comments]; } @@ -305,9 +339,9 @@ public function dropSavePoint(): void public function rollback(): void { - $index = array_pop($this->savePoints); - assert($index !== null); - $this->index = $index; + $savepoint = array_pop($this->savePoints); + assert($savepoint !== null); + [$this->index, $this->comments] = $savepoint; } diff --git a/src/Parser/TypeParser.php b/src/Parser/TypeParser.php index b24561b6..fc225854 100644 --- a/src/Parser/TypeParser.php +++ b/src/Parser/TypeParser.php @@ -41,7 +41,7 @@ public function parse(TokenIterator $tokens): Ast\Type\TypeNode $type = $this->parseAtomic($tokens); $tokens->pushSavePoint(); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); try { $enrichedType = $this->enrichTypeOnUnionOrIntersection($tokens, $type); @@ -91,6 +91,11 @@ public function enrichWithAttributes(TokenIterator $tokens, Ast\Node $type, int $type->setAttribute(Ast\Attribute::END_LINE, $tokens->currentTokenLine()); } + $comments = $tokens->flushComments(); + if ($this->config->useCommentsAttributes) { + $type->setAttribute(Ast\Attribute::COMMENTS, $comments); + } + if ($this->config->useIndexAttributes) { $type->setAttribute(Ast\Attribute::START_INDEX, $startIndex); $type->setAttribute(Ast\Attribute::END_INDEX, $tokens->endIndexOfLastRelevantToken()); @@ -117,7 +122,7 @@ private function subParse(TokenIterator $tokens): Ast\Type\TypeNode if ($tokens->isCurrentTokenValue('is')) { $type = $this->parseConditional($tokens, $type); } else { - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); if ($tokens->isCurrentTokenType(Lexer::TOKEN_UNION)) { $type = $this->subParseUnion($tokens, $type); @@ -139,9 +144,9 @@ private function parseAtomic(TokenIterator $tokens): Ast\Type\TypeNode $startIndex = $tokens->currentTokenIndex(); if ($tokens->tryConsumeTokenType(Lexer::TOKEN_OPEN_PARENTHESES)) { - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $type = $this->subParse($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_PARENTHESES); @@ -272,7 +277,7 @@ private function parseUnion(TokenIterator $tokens, Ast\Type\TypeNode $type): Ast while ($tokens->tryConsumeTokenType(Lexer::TOKEN_UNION)) { $types[] = $this->parseAtomic($tokens); $tokens->pushSavePoint(); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); if (!$tokens->isCurrentTokenType(Lexer::TOKEN_UNION)) { $tokens->rollback(); break; @@ -291,9 +296,9 @@ private function subParseUnion(TokenIterator $tokens, Ast\Type\TypeNode $type): $types = [$type]; while ($tokens->tryConsumeTokenType(Lexer::TOKEN_UNION)) { - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $types[] = $this->parseAtomic($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); } return new Ast\Type\UnionTypeNode($types); @@ -308,7 +313,7 @@ private function parseIntersection(TokenIterator $tokens, Ast\Type\TypeNode $typ while ($tokens->tryConsumeTokenType(Lexer::TOKEN_INTERSECTION)) { $types[] = $this->parseAtomic($tokens); $tokens->pushSavePoint(); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); if (!$tokens->isCurrentTokenType(Lexer::TOKEN_INTERSECTION)) { $tokens->rollback(); break; @@ -327,9 +332,9 @@ private function subParseIntersection(TokenIterator $tokens, Ast\Type\TypeNode $ $types = [$type]; while ($tokens->tryConsumeTokenType(Lexer::TOKEN_INTERSECTION)) { - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $types[] = $this->parseAtomic($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); } return new Ast\Type\IntersectionTypeNode($types); @@ -349,15 +354,15 @@ private function parseConditional(TokenIterator $tokens, Ast\Type\TypeNode $subj $targetType = $this->parse($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $tokens->consumeTokenType(Lexer::TOKEN_NULLABLE); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $ifType = $this->parse($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $tokens->consumeTokenType(Lexer::TOKEN_COLON); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $elseType = $this->subParse($tokens); @@ -378,15 +383,15 @@ private function parseConditionalForParameter(TokenIterator $tokens, string $par $targetType = $this->parse($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $tokens->consumeTokenType(Lexer::TOKEN_NULLABLE); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $ifType = $this->parse($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $tokens->consumeTokenType(Lexer::TOKEN_COLON); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $elseType = $this->subParse($tokens); @@ -445,6 +450,7 @@ public function isHtml(TokenIterator $tokens): bool public function parseGeneric(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $baseType): Ast\Type\GenericTypeNode { $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); + $tokens->skipNewLineTokensAndConsumeComments(); $startLine = $baseType->getAttribute(Ast\Attribute::START_LINE); $startIndex = $baseType->getAttribute(Ast\Attribute::START_INDEX); @@ -456,7 +462,7 @@ public function parseGeneric(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $isFirst || $tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA) ) { - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); // trailing comma case if (!$isFirst && $tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET)) { @@ -465,7 +471,7 @@ public function parseGeneric(TokenIterator $tokens, Ast\Type\IdentifierTypeNode $isFirst = false; [$genericTypes[], $variances[]] = $this->parseGenericTypeArgument($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); } $type = new Ast\Type\GenericTypeNode($baseType, $genericTypes, $variances); @@ -556,19 +562,19 @@ private function parseCallable(TokenIterator $tokens, Ast\Type\IdentifierTypeNod : []; $tokens->consumeTokenType(Lexer::TOKEN_OPEN_PARENTHESES); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $parameters = []; if (!$tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_PARENTHESES)) { $parameters[] = $this->parseCallableParameter($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); if ($tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_PARENTHESES)) { break; } $parameters[] = $this->parseCallableParameter($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); } } @@ -596,7 +602,7 @@ private function parseCallableTemplates(TokenIterator $tokens): array $isFirst = true; while ($isFirst || $tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); // trailing comma case if (!$isFirst && $tokens->isCurrentTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET)) { @@ -605,7 +611,7 @@ private function parseCallableTemplates(TokenIterator $tokens): array $isFirst = false; $templates[] = $this->parseCallableTemplateArgument($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); } $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); @@ -875,8 +881,10 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, $sealed = true; $unsealedType = null; + $done = false; + do { - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); if ($tokens->tryConsumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET)) { return Ast\Type\ArrayShapeNode::createSealed($items, $kind); @@ -885,14 +893,14 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, if ($tokens->tryConsumeTokenType(Lexer::TOKEN_VARIADIC)) { $sealed = false; - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); if ($tokens->isCurrentTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET)) { if ($kind === Ast\Type\ArrayShapeNode::KIND_ARRAY) { $unsealedType = $this->parseArrayShapeUnsealedType($tokens); } else { $unsealedType = $this->parseListShapeUnsealedType($tokens); } - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); } $tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA); @@ -900,11 +908,19 @@ private function parseArrayShape(TokenIterator $tokens, Ast\Type\TypeNode $type, } $items[] = $this->parseArrayShapeItem($tokens); + $tokens->skipNewLineTokensAndConsumeComments(); + if (!$tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { + $done = true; + } + if ($tokens->currentTokenType() !== Lexer::TOKEN_COMMENT) { + continue; + } - $tokens->skipNewLineTokens(); - } while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)); + $tokens->next(); - $tokens->skipNewLineTokens(); + } while (!$done); + + $tokens->skipNewLineTokensAndConsumeComments(); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET); if ($sealed) { @@ -920,12 +936,17 @@ private function parseArrayShapeItem(TokenIterator $tokens): Ast\Type\ArrayShape { $startLine = $tokens->currentTokenLine(); $startIndex = $tokens->currentTokenIndex(); + + // parse any comments above the item + $tokens->skipNewLineTokensAndConsumeComments(); + try { $tokens->pushSavePoint(); $key = $this->parseArrayShapeKey($tokens); $optional = $tokens->tryConsumeTokenType(Lexer::TOKEN_NULLABLE); $tokens->consumeTokenType(Lexer::TOKEN_COLON); $value = $this->parse($tokens); + $tokens->dropSavePoint(); return $this->enrichWithAttributes( @@ -991,18 +1012,18 @@ private function parseArrayShapeUnsealedType(TokenIterator $tokens): Ast\Type\Ar $startIndex = $tokens->currentTokenIndex(); $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $valueType = $this->parse($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $keyType = null; if ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)) { - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $keyType = $valueType; $valueType = $this->parse($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); } $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); @@ -1024,10 +1045,10 @@ private function parseListShapeUnsealedType(TokenIterator $tokens): Ast\Type\Arr $startIndex = $tokens->currentTokenIndex(); $tokens->consumeTokenType(Lexer::TOKEN_OPEN_ANGLE_BRACKET); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $valueType = $this->parse($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_ANGLE_BRACKET); @@ -1049,7 +1070,7 @@ private function parseObjectShape(TokenIterator $tokens): Ast\Type\ObjectShapeNo $items = []; do { - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); if ($tokens->tryConsumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET)) { return new Ast\Type\ObjectShapeNode($items); @@ -1057,10 +1078,10 @@ private function parseObjectShape(TokenIterator $tokens): Ast\Type\ObjectShapeNo $items[] = $this->parseObjectShapeItem($tokens); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); } while ($tokens->tryConsumeTokenType(Lexer::TOKEN_COMMA)); - $tokens->skipNewLineTokens(); + $tokens->skipNewLineTokensAndConsumeComments(); $tokens->consumeTokenType(Lexer::TOKEN_CLOSE_CURLY_BRACKET); return new Ast\Type\ObjectShapeNode($items); @@ -1072,12 +1093,19 @@ private function parseObjectShapeItem(TokenIterator $tokens): Ast\Type\ObjectSha $startLine = $tokens->currentTokenLine(); $startIndex = $tokens->currentTokenIndex(); + $tokens->skipNewLineTokensAndConsumeComments(); + $key = $this->parseObjectShapeKey($tokens); $optional = $tokens->tryConsumeTokenType(Lexer::TOKEN_NULLABLE); $tokens->consumeTokenType(Lexer::TOKEN_COLON); $value = $this->parse($tokens); - return $this->enrichWithAttributes($tokens, new Ast\Type\ObjectShapeItemNode($key, $optional, $value), $startLine, $startIndex); + return $this->enrichWithAttributes( + $tokens, + new Ast\Type\ObjectShapeItemNode($key, $optional, $value), + $startLine, + $startIndex, + ); } /** diff --git a/src/ParserConfig.php b/src/ParserConfig.php index b3a8889a..0c4377ad 100644 --- a/src/ParserConfig.php +++ b/src/ParserConfig.php @@ -9,13 +9,16 @@ class ParserConfig public bool $useIndexAttributes; + public bool $useCommentsAttributes; + /** - * @param array{lines?: bool, indexes?: bool} $usedAttributes + * @param array{lines?: bool, indexes?: bool, comments?: bool} $usedAttributes */ public function __construct(array $usedAttributes) { $this->useLinesAttributes = $usedAttributes['lines'] ?? false; $this->useIndexAttributes = $usedAttributes['indexes'] ?? false; + $this->useCommentsAttributes = $usedAttributes['comments'] ?? false; } } diff --git a/src/Printer/Printer.php b/src/Printer/Printer.php index 8febe826..af920f8d 100644 --- a/src/Printer/Printer.php +++ b/src/Printer/Printer.php @@ -4,6 +4,7 @@ use LogicException; use PHPStan\PhpDocParser\Ast\Attribute; +use PHPStan\PhpDocParser\Ast\Comment; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprNode; use PHPStan\PhpDocParser\Ast\Node; @@ -66,6 +67,7 @@ use PHPStan\PhpDocParser\Parser\TokenIterator; use function array_keys; use function array_map; +use function assert; use function count; use function get_class; use function get_object_vars; @@ -74,6 +76,7 @@ use function is_array; use function preg_match_all; use function sprintf; +use function str_replace; use function strlen; use function strpos; use function trim; @@ -547,23 +550,30 @@ private function printArrayFormatPreserving(array $nodes, array $originalNodes, foreach ($diff as $i => $diffElem) { $diffType = $diffElem->type; - $newNode = $diffElem->new; - $originalNode = $diffElem->old; + $arrItem = $diffElem->new; + $origArrayItem = $diffElem->old; if ($diffType === DiffElem::TYPE_KEEP || $diffType === DiffElem::TYPE_REPLACE) { $beforeFirstKeepOrReplace = false; - if (!$newNode instanceof Node || !$originalNode instanceof Node) { + if (!$arrItem instanceof Node || !$origArrayItem instanceof Node) { return null; } /** @var int $itemStartPos */ - $itemStartPos = $originalNode->getAttribute(Attribute::START_INDEX); + $itemStartPos = $origArrayItem->getAttribute(Attribute::START_INDEX); /** @var int $itemEndPos */ - $itemEndPos = $originalNode->getAttribute(Attribute::END_INDEX); + $itemEndPos = $origArrayItem->getAttribute(Attribute::END_INDEX); + if ($itemStartPos < 0 || $itemEndPos < 0 || $itemStartPos < $tokenIndex) { throw new LogicException(); } + $comments = $arrItem->getAttribute(Attribute::COMMENTS) ?? []; + $origComments = $origArrayItem->getAttribute(Attribute::COMMENTS) ?? []; + + $commentStartPos = count($origComments) > 0 ? $origComments[0]->startIndex : $itemStartPos; + assert($commentStartPos >= 0); + $result .= $originalTokens->getContentBetween($tokenIndex, $itemStartPos); if (count($delayedAdd) > 0) { @@ -573,6 +583,15 @@ private function printArrayFormatPreserving(array $nodes, array $originalNodes, if ($parenthesesNeeded) { $result .= '('; } + + if ($insertNewline) { + $delayedAddComments = $delayedAddNode->getAttribute(Attribute::COMMENTS) ?? []; + if (count($delayedAddComments) > 0) { + $result .= $this->printComments($delayedAddComments, $beforeAsteriskIndent, $afterAsteriskIndent); + $result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent); + } + } + $result .= $this->printNodeFormatPreserving($delayedAddNode, $originalTokens); if ($parenthesesNeeded) { $result .= ')'; @@ -589,14 +608,21 @@ private function printArrayFormatPreserving(array $nodes, array $originalNodes, } $parenthesesNeeded = isset($this->parenthesesListMap[$mapKey]) - && in_array(get_class($newNode), $this->parenthesesListMap[$mapKey], true) - && !in_array(get_class($originalNode), $this->parenthesesListMap[$mapKey], true); + && in_array(get_class($arrItem), $this->parenthesesListMap[$mapKey], true) + && !in_array(get_class($origArrayItem), $this->parenthesesListMap[$mapKey], true); $addParentheses = $parenthesesNeeded && !$originalTokens->hasParentheses($itemStartPos, $itemEndPos); if ($addParentheses) { $result .= '('; } - $result .= $this->printNodeFormatPreserving($newNode, $originalTokens); + if ($comments !== $origComments) { + if (count($comments) > 0) { + $result .= $this->printComments($comments, $beforeAsteriskIndent, $afterAsteriskIndent); + $result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent); + } + } + + $result .= $this->printNodeFormatPreserving($arrItem, $originalTokens); if ($addParentheses) { $result .= ')'; } @@ -606,36 +632,42 @@ private function printArrayFormatPreserving(array $nodes, array $originalNodes, if ($insertStr === null) { return null; } - if (!$newNode instanceof Node) { + if (!$arrItem instanceof Node) { return null; } - if ($insertStr === ', ' && $isMultiline) { + if ($insertStr === ', ' && $isMultiline || count($arrItem->getAttribute(Attribute::COMMENTS) ?? []) > 0) { $insertStr = ','; $insertNewline = true; } if ($beforeFirstKeepOrReplace) { // Will be inserted at the next "replace" or "keep" element - $delayedAdd[] = $newNode; + $delayedAdd[] = $arrItem; continue; } /** @var int $itemEndPos */ $itemEndPos = $tokenIndex - 1; if ($insertNewline) { - $result .= $insertStr . sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent); + $comments = $arrItem->getAttribute(Attribute::COMMENTS) ?? []; + $result .= $insertStr; + if (count($comments) > 0) { + $result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent); + $result .= $this->printComments($comments, $beforeAsteriskIndent, $afterAsteriskIndent); + } + $result .= sprintf('%s%s*%s', $originalTokens->getDetectedNewline() ?? "\n", $beforeAsteriskIndent, $afterAsteriskIndent); } else { $result .= $insertStr; } $parenthesesNeeded = isset($this->parenthesesListMap[$mapKey]) - && in_array(get_class($newNode), $this->parenthesesListMap[$mapKey], true); + && in_array(get_class($arrItem), $this->parenthesesListMap[$mapKey], true); if ($parenthesesNeeded) { $result .= '('; } - $result .= $this->printNodeFormatPreserving($newNode, $originalTokens); + $result .= $this->printNodeFormatPreserving($arrItem, $originalTokens); if ($parenthesesNeeded) { $result .= ')'; } @@ -643,15 +675,15 @@ private function printArrayFormatPreserving(array $nodes, array $originalNodes, $tokenIndex = $itemEndPos + 1; } elseif ($diffType === DiffElem::TYPE_REMOVE) { - if (!$originalNode instanceof Node) { + if (!$origArrayItem instanceof Node) { return null; } /** @var int $itemStartPos */ - $itemStartPos = $originalNode->getAttribute(Attribute::START_INDEX); + $itemStartPos = $origArrayItem->getAttribute(Attribute::START_INDEX); /** @var int $itemEndPos */ - $itemEndPos = $originalNode->getAttribute(Attribute::END_INDEX); + $itemEndPos = $origArrayItem->getAttribute(Attribute::END_INDEX); if ($itemStartPos < 0 || $itemEndPos < 0) { throw new LogicException(); } @@ -709,6 +741,20 @@ private function printArrayFormatPreserving(array $nodes, array $originalNodes, return $result; } + /** + * @param list $comments + */ + private function printComments(array $comments, string $beforeAsteriskIndent, string $afterAsteriskIndent): string + { + $formattedComments = []; + + foreach ($comments as $comment) { + $formattedComments[] = str_replace("\n", "\n" . $beforeAsteriskIndent . '*' . $afterAsteriskIndent, $comment->getReformattedText()); + } + + return implode("\n$beforeAsteriskIndent*$afterAsteriskIndent", $formattedComments); + } + /** * @param array $nodes * @return array{bool, string, string} @@ -738,7 +784,7 @@ private function isMultiline(int $initialIndex, array $nodes, TokenIterator $ori $c = preg_match_all('~\n(?[\\x09\\x20]*)\*(?\\x20*)~', $allText, $matches, PREG_SET_ORDER); if ($c === 0) { - return [$isMultiline, '', '']; + return [$isMultiline, ' ', ' ']; } $before = ''; @@ -754,6 +800,9 @@ private function isMultiline(int $initialIndex, array $nodes, TokenIterator $ori $after = $match['after']; } + $before = strlen($before) === 0 ? ' ' : $before; + $after = strlen($after) === 0 ? ' ' : $after; + return [$isMultiline, $before, $after]; } diff --git a/tests/PHPStan/Parser/PhpDocParserTest.php b/tests/PHPStan/Parser/PhpDocParserTest.php index 49ae1873..abe69b8e 100644 --- a/tests/PHPStan/Parser/PhpDocParserTest.php +++ b/tests/PHPStan/Parser/PhpDocParserTest.php @@ -6345,6 +6345,49 @@ public function provideCommentLikeDescriptions(): Iterator ]), ]; + yield [ + 'Comment after @param with https://', + '/** @param int $a https://phpstan.org/ */', + new PhpDocNode([ + new PhpDocTagNode('@param', new ParamTagValueNode( + new IdentifierTypeNode('int'), + false, + '$a', + 'https://phpstan.org/', + false, + )), + ]), + ]; + + yield [ + 'Comment after @param with https:// in // comment', + '/** @param int $a // comment https://phpstan.org/ */', + new PhpDocNode([ + new PhpDocTagNode('@param', new ParamTagValueNode( + new IdentifierTypeNode('int'), + false, + '$a', + '// comment https://phpstan.org/', + false, + )), + ]), + ]; + + yield [ + 'Comment in PHPDoc tag outside of type', + '/** @param // comment */', + new PhpDocNode([ + new PhpDocTagNode('@param', new InvalidTagValueNode('// comment', new ParserException( + '// comment ', + 37, + 11, + 24, + null, + 1, + ))), + ]), + ]; + yield [ 'Comment on a separate line', '/**' . PHP_EOL . diff --git a/tests/PHPStan/Parser/TypeParserTest.php b/tests/PHPStan/Parser/TypeParserTest.php index 6306cfb8..8fe96f5f 100644 --- a/tests/PHPStan/Parser/TypeParserTest.php +++ b/tests/PHPStan/Parser/TypeParserTest.php @@ -3,13 +3,17 @@ namespace PHPStan\PhpDocParser\Parser; use Exception; +use PHPStan\PhpDocParser\Ast\AbstractNodeVisitor; use PHPStan\PhpDocParser\Ast\Attribute; +use PHPStan\PhpDocParser\Ast\Comment; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprFloatNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprStringNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstFetchNode; use PHPStan\PhpDocParser\Ast\Node; use PHPStan\PhpDocParser\Ast\NodeTraverser; +use PHPStan\PhpDocParser\Ast\NodeVisitor\CloningVisitor; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeItemNode; use PHPStan\PhpDocParser\Ast\Type\ArrayShapeNode; @@ -67,10 +71,11 @@ public function testParse(string $input, $expectedResult, int $nextTokenType = L $tokens = new TokenIterator($this->lexer->tokenize($input)); $typeNode = $this->typeParser->parse($tokens); + $this->assertInstanceOf(TypeNode::class, $expectedResult); $this->assertSame((string) $expectedResult, (string) $typeNode); $this->assertInstanceOf(get_class($expectedResult), $typeNode); - $this->assertEquals($expectedResult, $typeNode); + $this->assertEquals($this->unsetAllAttributes($expectedResult), $this->unsetAllAttributes($typeNode)); $this->assertSame($nextTokenType, $tokens->currentTokenType(), Lexer::TOKEN_LABELS[$nextTokenType]); if (strpos((string) $expectedResult, '$ref') !== false) { @@ -117,13 +122,16 @@ public function testVerifyAttributes(string $input, $expectedResult): void $this->expectExceptionMessage($expectedResult->getMessage()); } - $config = new ParserConfig(['lines' => true, 'indexes' => true]); + $config = new ParserConfig(['lines' => true, 'indexes' => true, 'comments' => true]); $typeParser = new TypeParser($config, new ConstExprParser($config)); $tokens = new TokenIterator($this->lexer->tokenize($input)); + $typeNode = $typeParser->parse($tokens); + $this->assertInstanceOf(TypeNode::class, $expectedResult); + $visitor = new NodeCollectingVisitor(); $traverser = new NodeTraverser([$visitor]); - $traverser->traverse([$typeParser->parse($tokens)]); + $traverser->traverse([$typeNode]); foreach ($visitor->nodes as $node) { $this->assertNotNull($node->getAttribute(Attribute::START_LINE), (string) $node); @@ -131,6 +139,84 @@ public function testVerifyAttributes(string $input, $expectedResult): void $this->assertNotNull($node->getAttribute(Attribute::START_INDEX), (string) $node); $this->assertNotNull($node->getAttribute(Attribute::END_INDEX), (string) $node); } + + $this->assertEquals( + $this->unsetAllAttributesButComments($expectedResult), + $this->unsetAllAttributesButComments($typeNode), + ); + } + + + private function unsetAllAttributes(Node $node): Node + { + $visitor = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + $node->setAttribute(Attribute::START_LINE, null); + $node->setAttribute(Attribute::END_LINE, null); + $node->setAttribute(Attribute::START_INDEX, null); + $node->setAttribute(Attribute::END_INDEX, null); + $node->setAttribute(Attribute::ORIGINAL_NODE, null); + $node->setAttribute(Attribute::COMMENTS, null); + + return $node; + } + + }; + + $cloningTraverser = new NodeTraverser([new CloningVisitor()]); + $newNodes = $cloningTraverser->traverse([$node]); + + $traverser = new NodeTraverser([$visitor]); + + /** @var PhpDocNode */ + return $traverser->traverse($newNodes)[0]; + } + + + private function unsetAllAttributesButComments(Node $node): Node + { + $visitor = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + $node->setAttribute(Attribute::START_LINE, null); + $node->setAttribute(Attribute::END_LINE, null); + $node->setAttribute(Attribute::START_INDEX, null); + $node->setAttribute(Attribute::END_INDEX, null); + $node->setAttribute(Attribute::ORIGINAL_NODE, null); + + if ($node->getAttribute(Attribute::COMMENTS) === []) { + $node->setAttribute(Attribute::COMMENTS, null); + } + + return $node; + } + + }; + + $cloningTraverser = new NodeTraverser([new CloningVisitor()]); + $newNodes = $cloningTraverser->traverse([$node]); + + $traverser = new NodeTraverser([$visitor]); + + /** @var PhpDocNode */ + return $traverser->traverse($newNodes)[0]; + } + + + /** + * @template TNode of Node + * @param TNode $node + * @return TNode + */ + public static function withComment(Node $node, string $comment, int $startLine, int $startIndex): Node + { + $comments = $node->getAttribute(Attribute::COMMENTS) ?? []; + $comments[] = new Comment($comment, $startLine, $startIndex); + $node->setAttribute(Attribute::COMMENTS, $comments); + return $node; } @@ -140,6 +226,100 @@ public function testVerifyAttributes(string $input, $expectedResult): void public function provideParseData(): array { return [ + [ + 'array{ + // a is for apple + a: int, + }', + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode( + self::withComment(new IdentifierTypeNode('a'), '// a is for apple', 2, 3), + false, + new IdentifierTypeNode('int'), + ), + ]), + ], + [ + 'array{ + // a is for // apple + a: int, + }', + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode( + self::withComment(new IdentifierTypeNode('a'), '// a is for // apple', 2, 3), + false, + new IdentifierTypeNode('int'), + ), + ]), + ], + [ + 'array{ + // a is for * apple + a: int, + }', + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode( + self::withComment(new IdentifierTypeNode('a'), '// a is for * apple', 2, 3), + false, + new IdentifierTypeNode('int'), + ), + ]), + ], + [ + 'array{ + // a is for http://www.apple.com/ + a: int, + }', + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode( + self::withComment(new IdentifierTypeNode('a'), '// a is for http://www.apple.com/', 2, 3), + false, + new IdentifierTypeNode('int'), + ), + ]), + ], + [ + 'array{ + // a is for apple + // a is also for awesome + a: int, + }', + ArrayShapeNode::createSealed([ + new ArrayShapeItemNode( + self::withComment(self::withComment(new IdentifierTypeNode('a'), '// a is for apple', 2, 3), '// a is also for awesome', 3, 5), + false, + new IdentifierTypeNode('int'), + ), + ]), + ], + [ + 'string', + new IdentifierTypeNode('string'), + ], + [ + ' string ', + new IdentifierTypeNode('string'), + ], + [ + ' ( string ) ', + new IdentifierTypeNode('string'), + ], + [ + '( ( string ) )', + new IdentifierTypeNode('string'), + ], + [ + '\\Foo\Bar\\Baz', + new IdentifierTypeNode('\\Foo\Bar\\Baz'), + ], + [ + ' \\Foo\Bar\\Baz ', + new IdentifierTypeNode('\\Foo\Bar\\Baz'), + ], + [ + ' ( \\Foo\Bar\\Baz ) ', + new IdentifierTypeNode('\\Foo\Bar\\Baz'), + ], [ 'string', new IdentifierTypeNode('string'), @@ -371,6 +551,24 @@ public function provideParseData(): array ], ), ], + [ + 'array< + // index with an int + int, + Foo\\Bar + >', + new GenericTypeNode( + new IdentifierTypeNode('array'), + [ + new IdentifierTypeNode('int'), + new IdentifierTypeNode('Foo\\Bar'), + ], + [ + GenericTypeNode::VARIANCE_INVARIANT, + GenericTypeNode::VARIANCE_INVARIANT, + ], + ), + ], [ 'array {\'a\': int}', new IdentifierTypeNode('array'), @@ -2014,6 +2212,22 @@ public function provideParseData(): array false, ), ], + [ + '( + Foo is Bar + ? + // never, I say + never + : + int)', + new ConditionalTypeNode( + new IdentifierTypeNode('Foo'), + new IdentifierTypeNode('Bar'), + new IdentifierTypeNode('never'), + new IdentifierTypeNode('int'), + false, + ), + ], [ '(Foo is not Bar ? never : int)', new ConditionalTypeNode( @@ -2516,6 +2730,19 @@ public function provideParseData(): array ), ]), ], + [ + 'object{ + // a is for apple + a: int, + }', + new ObjectShapeNode([ + new ObjectShapeItemNode( + self::withComment(new IdentifierTypeNode('a'), '// a is for apple', 2, 3), + false, + new IdentifierTypeNode('int'), + ), + ]), + ], [ 'object{ a: int, diff --git a/tests/PHPStan/Printer/PrinterTest.php b/tests/PHPStan/Printer/PrinterTest.php index 93e78bcb..464b7234 100644 --- a/tests/PHPStan/Printer/PrinterTest.php +++ b/tests/PHPStan/Printer/PrinterTest.php @@ -4,6 +4,7 @@ use PHPStan\PhpDocParser\Ast\AbstractNodeVisitor; use PHPStan\PhpDocParser\Ast\Attribute; +use PHPStan\PhpDocParser\Ast\Comment; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayItemNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprArrayNode; use PHPStan\PhpDocParser\Ast\ConstExpr\ConstExprIntegerNode; @@ -76,7 +77,7 @@ class PrinterTest extends TestCase protected function setUp(): void { - $config = new ParserConfig(['lines' => true, 'indexes' => true]); + $config = new ParserConfig(['lines' => true, 'indexes' => true, 'comments' => true]); $constExprParser = new ConstExprParser($config); $this->typeParser = new TypeParser($config, $constExprParser); $this->phpDocParser = new PhpDocParser( @@ -951,6 +952,219 @@ public function enterNode(Node $node) $addItemsToObjectShape, ]; + $addItemsWithCommentsToMultilineArrayShape = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $commentedNode = new ArrayShapeItemNode(new IdentifierTypeNode('b'), false, new IdentifierTypeNode('int')); + $commentedNode->setAttribute(Attribute::COMMENTS, [new Comment('// bar')]); + array_splice($node->items, 1, 0, [ + $commentedNode, + ]); + $commentedNode = new ArrayShapeItemNode(new IdentifierTypeNode('d'), false, new IdentifierTypeNode('string')); + $commentedNode->setAttribute(Attribute::COMMENTS, [new Comment( + PrinterTest::nowdoc(' + // first comment'), + )]); + $node->items[] = $commentedNode; + + $commentedNode = new ArrayShapeItemNode(new IdentifierTypeNode('e'), false, new IdentifierTypeNode('string')); + $commentedNode->setAttribute(Attribute::COMMENTS, [new Comment( + PrinterTest::nowdoc(' + // second comment'), + )]); + $node->items[] = $commentedNode; + + $commentedNode = new ArrayShapeItemNode(new IdentifierTypeNode('f'), false, new IdentifierTypeNode('string')); + $commentedNode->setAttribute(Attribute::COMMENTS, [ + new Comment('// third comment'), + new Comment('// fourth comment'), + ]); + $node->items[] = $commentedNode; + } + + return $node; + } + + }; + + yield [ + self::nowdoc(' + /** + * @return array{ + * // foo + * a: int, + * c: string + * } + */'), + self::nowdoc(' + /** + * @return array{ + * // foo + * a: int, + * // bar + * b: int, + * c: string, + * // first comment + * d: string, + * // second comment + * e: string, + * // third comment + * // fourth comment + * f: string + * } + */'), + $addItemsWithCommentsToMultilineArrayShape, + ]; + + $prependItemsWithCommentsToMultilineArrayShape = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + $commentedNode = new ArrayShapeItemNode(new IdentifierTypeNode('a'), false, new IdentifierTypeNode('int')); + $commentedNode->setAttribute(Attribute::COMMENTS, [new Comment('// first item')]); + array_splice($node->items, 0, 0, [ + $commentedNode, + ]); + } + + return $node; + } + + }; + + yield [ + self::nowdoc(' + /** + * @return array{ + * b: int, + * } + */'), + self::nowdoc(' + /** + * @return array{ + * // first item + * a: int, + * b: int, + * } + */'), + $prependItemsWithCommentsToMultilineArrayShape, + ]; + + $changeCommentOnArrayShapeItem = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeItemNode) { + $node->setAttribute(Attribute::COMMENTS, [new Comment('// puppies')]); + } + + return $node; + } + + }; + + yield [ + self::nowdoc(' + /** + * @return array{ + * a: int, + * } + */'), + self::nowdoc(' + /** + * @return array{ + * // puppies + * a: int, + * } + */'), + $changeCommentOnArrayShapeItem, + ]; + + $addItemsWithCommentsToObjectShape = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ObjectShapeNode) { + $item = new ObjectShapeItemNode(new IdentifierTypeNode('foo'), false, new IdentifierTypeNode('int')); + $item->setAttribute(Attribute::COMMENTS, [new Comment('// favorite foo')]); + $node->items[] = $item; + } + + return $node; + } + + }; + + yield [ + self::nowdoc(' + /** + * @return object{ + * // your favorite bar + * bar: string + * } + */'), + self::nowdoc(' + /** + * @return object{ + * // your favorite bar + * bar: string, + * // favorite foo + * foo: int + * } + */'), + $addItemsWithCommentsToObjectShape, + ]; + + $removeItemWithComment = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if (!$node instanceof ArrayShapeNode) { + return null; + } + + foreach ($node->items as $i => $item) { + if ($item->keyName === null) { + continue; + } + + $comments = $item->keyName->getAttribute(Attribute::COMMENTS); + if ($comments === null) { + continue; + } + if ($comments === []) { + continue; + } + + unset($node->items[$i]); + } + + return $node; + } + + }; + + yield [ + self::nowdoc(' + /** + * @return array{ + * a: string, + * // b comment + * b: int, + * } + */'), + self::nowdoc(' + /** + * @return array{ + * a: string, + * } + */'), + $removeItemWithComment, + ]; + $addItemsToConstExprArray = new class extends AbstractNodeVisitor { public function enterNode(Node $node) @@ -2039,6 +2253,377 @@ public function enterNode(Node $node) }, ]; + + $singleCommentLineAddFront = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ArrayShapeNode) { + array_unshift($node->items, PrinterTest::withComment( + new ArrayShapeItemNode(null, false, new IdentifierTypeNode('float')), + '// A fractional number', + )); + } + + return $node; + } + + }; + + yield [ + self::nowdoc(' + /** + * @param array{} $foo + */'), + self::nowdoc(' + /** + * @param array{float} $foo + */'), + $singleCommentLineAddFront, + ]; + + yield [ + self::nowdoc(' + /** + * @param array{string} $foo + */'), + self::nowdoc(' + /** + * @param array{// A fractional number + * float, + * string} $foo + */'), + $singleCommentLineAddFront, + ]; + + yield [ + self::nowdoc(' + /** + * @param array{ + * string,int} $foo + */'), + self::nowdoc(' + /** + * @param array{ + * // A fractional number + * float, + * string,int} $foo + */'), + $singleCommentLineAddFront, + ]; + + yield [ + self::nowdoc(' + /** + * @param array{ + * string, + * int + * } $foo + */'), + self::nowdoc(' + /** + * @param array{ + * // A fractional number + * float, + * string, + * int + * } $foo + */'), + $singleCommentLineAddFront, + ]; + + $singleCommentLineAddMiddle = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + $newItem = PrinterTest::withComment( + new ArrayShapeItemNode(null, false, new IdentifierTypeNode('float')), + '// A fractional number', + ); + + if ($node instanceof ArrayShapeNode) { + if (count($node->items) === 0) { + $node->items[] = $newItem; + } else { + array_splice($node->items, 1, 0, [$newItem]); + } + } + + return $node; + } + + }; + + yield [ + self::nowdoc(' + /** + * @param array{} $foo + */'), + self::nowdoc(' + /** + * @param array{float} $foo + */'), + $singleCommentLineAddMiddle, + ]; + + yield [ + self::nowdoc(' + /** + * @param array{string} $foo + */'), + self::nowdoc(' + /** + * @param array{string, + * // A fractional number + * float} $foo + */'), + $singleCommentLineAddMiddle, + ]; + + yield [ + self::nowdoc(' + /** + * @param array{ + * string,int} $foo + */'), + self::nowdoc(' + /** + * @param array{ + * string, + * // A fractional number + * float,int} $foo + */'), + $singleCommentLineAddMiddle, + ]; + + yield [ + self::nowdoc(' + /** + * @param array{ + * string, + * int + * } $foo + */'), + self::nowdoc(' + /** + * @param array{ + * string, + * // A fractional number + * float, + * int + * } $foo + */'), + $singleCommentLineAddMiddle, + ]; + + $addCommentToCallableParamsFront = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof CallableTypeNode) { + array_unshift($node->parameters, PrinterTest::withComment( + new CallableTypeParameterNode(new IdentifierTypeNode('Foo'), false, false, '$foo', false), + '// never pet a burning dog', + )); + } + + return $node; + } + + }; + + yield [ + self::nowdoc(' + /** + * @param callable(Bar $bar): int $a + */'), + self::nowdoc(' + /** + * @param callable(// never pet a burning dog + * Foo $foo, + * Bar $bar): int $a + */'), + $addCommentToCallableParamsFront, + ]; + + $addCommentToCallableParamsMiddle = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof CallableTypeNode) { + $node->parameters[] = PrinterTest::withComment( + new CallableTypeParameterNode(new IdentifierTypeNode('Bar'), false, false, '$bar', false), + '// never pet a burning dog', + ); + } + + return $node; + } + + }; + + yield [ + self::nowdoc(' + /** + * @param callable(Foo $foo): int $a + */'), + self::nowdoc(' + /** + * @param callable(Foo $foo, + * // never pet a burning dog + * Bar $bar): int $a + */'), + $addCommentToCallableParamsMiddle, + ]; + + $addCommentToObjectShapeItemFront = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ObjectShapeNode) { + array_unshift($node->items, PrinterTest::withComment( + new ObjectShapeItemNode(new IdentifierTypeNode('foo'), false, new IdentifierTypeNode('float')), + '// A fractional number', + )); + } + + return $node; + } + + }; + + yield [ + self::nowdoc(' + /** + * @param object{bar: string} $foo + */'), + self::nowdoc(' + /** + * @param object{// A fractional number + * foo: float, + * bar: string} $foo + */'), + $addCommentToObjectShapeItemFront, + ]; + + yield [ + self::nowdoc(' + /** + * @param object{ + * bar:string,naz:int} $foo + */'), + self::nowdoc(' + /** + * @param object{ + * // A fractional number + * foo: float, + * bar:string,naz:int} $foo + */'), + $addCommentToObjectShapeItemFront, + ]; + + yield [ + self::nowdoc(' + /** + * @param object{ + * bar:string, + * naz:int + * } $foo + */'), + self::nowdoc(' + /** + * @param object{ + * // A fractional number + * foo: float, + * bar:string, + * naz:int + * } $foo + */'), + $addCommentToObjectShapeItemFront, + ]; + + $addCommentToObjectShapeItemMiddle = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ObjectShapeNode) { + $newItem = PrinterTest::withComment( + new ObjectShapeItemNode(new IdentifierTypeNode('bar'), false, new IdentifierTypeNode('float')), + '// A fractional number', + ); + if (count($node->items) === 0) { + $node->items[] = $newItem; + } else { + array_splice($node->items, 1, 0, [$newItem]); + } + } + + return $node; + } + + }; + + yield [ + self::nowdoc(' + /** + * @param object{} $foo + */'), + self::nowdoc(' + /** + * @param object{bar: float} $foo + */'), + $addCommentToObjectShapeItemMiddle, + ]; + + yield [ + self::nowdoc(' + /** + * @param object{foo:string} $foo + */'), + self::nowdoc(' + /** + * @param object{foo:string, + * // A fractional number + * bar: float} $foo + */'), + $addCommentToObjectShapeItemMiddle, + ]; + + yield [ + self::nowdoc(' + /** + * @param object{ + * foo:string,naz:int} $foo + */'), + self::nowdoc(' + /** + * @param object{ + * foo:string, + * // A fractional number + * bar: float,naz:int} $foo + */'), + $addCommentToObjectShapeItemMiddle, + ]; + + yield [ + self::nowdoc(' + /** + * @param object{ + * foo:string, + * naz:int + * } $foo + */'), + self::nowdoc(' + /** + * @param object{ + * foo:string, + * // A fractional number + * bar: float, + * naz:int + * } $foo + */'), + $addCommentToObjectShapeItemMiddle, + ]; } /** @@ -2046,7 +2631,7 @@ public function enterNode(Node $node) */ public function testPrintFormatPreserving(string $phpDoc, string $expectedResult, NodeVisitor $visitor): void { - $config = new ParserConfig([]); + $config = new ParserConfig(['lines' => true, 'indexes' => true, 'comments' => true]); $lexer = new Lexer($config); $tokens = new TokenIterator($lexer->tokenize($phpDoc)); $phpDocNode = $this->phpDocParser->parse($tokens); @@ -2079,6 +2664,7 @@ public function enterNode(Node $node) $node->setAttribute(Attribute::START_INDEX, null); $node->setAttribute(Attribute::END_INDEX, null); $node->setAttribute(Attribute::ORIGINAL_NODE, null); + $node->setAttribute(Attribute::COMMENTS, null); return $node; } @@ -2273,6 +2859,17 @@ public function testPrintPhpDocNode(PhpDocNode $node, string $expectedResult): v ); } + /** + * @template TNode of Node + * @param TNode $node + * @return TNode + */ + public static function withComment(Node $node, string $comment): Node + { + $node->setAttribute(Attribute::COMMENTS, [new Comment($comment)]); + return $node; + } + public static function nowdoc(string $str): string { $lines = preg_split('/\\n/', $str);