From 6af2b823a9d188acf79322fffcb59183b9d31757 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mirtes?= Date: Mon, 26 Oct 2026 18:02:30 +0000 Subject: [PATCH] Printer: keep Doctrine annotation name and missing parentheses A Doctrine annotation without arguments, e.g. @\Foo\Id, has no tokens. The format-preserving printer reprinted the tag and added "()", also when nothing changed. The tokens of DoctrineTagValueNode do not contain the annotation name. When the printer reprinted only the value, the name was printed twice, e.g. @Route@Route("/path") other description. Amp-Thread-ID: https://ampcode.com/threads/T-01a17930-0285-73c5-a957-4325c77aa1b2 --- src/Printer/Printer.php | 43 ++++++++++++++++++---- tests/PHPStan/Printer/PrinterTest.php | 52 +++++++++++++++++++++++++++ 2 files changed, 89 insertions(+), 6 deletions(-) diff --git a/src/Printer/Printer.php b/src/Printer/Printer.php index db7d3db7..0e218b50 100644 --- a/src/Printer/Printer.php +++ b/src/Printer/Printer.php @@ -855,7 +855,7 @@ private function printNodeFormatPreserving(Node $node, TokenIterator $originalTo /** @var Node|null $originalNode */ $originalNode = $node->getAttribute(Attribute::ORIGINAL_NODE); if ($originalNode === null) { - return $this->print($node); + return $this->printReplacement($node); } $class = get_class($node); @@ -897,14 +897,14 @@ private function printNodeFormatPreserving(Node $node, TokenIterator $originalTo ); if ($listResult === null) { - return $this->print($node); + return $this->printReplacement($node); } $result .= $listResult; continue; } - return $this->print($node); + return $this->printReplacement($node); } if ($origSubNode === null) { @@ -913,7 +913,7 @@ private function printNodeFormatPreserving(Node $node, TokenIterator $originalTo continue; } - return $this->print($node); + return $this->printReplacement($node); } $subStartPos = $origSubNode->getAttribute(Attribute::START_INDEX); @@ -923,11 +923,16 @@ private function printNodeFormatPreserving(Node $node, TokenIterator $originalTo } if ($subEndPos < $subStartPos) { - return $this->print($node); + // the original sub-node has no tokens, e.g. a Doctrine annotation without arguments + if ($subNode !== null && $this->print($subNode) === $this->print($origSubNode)) { + continue; + } + + return $this->printReplacement($node); } if ($subNode === null) { - return $this->print($node); + return $this->printReplacement($node); } $result .= $originalTokens->getContentBetween($pos, $subStartPos); @@ -956,4 +961,30 @@ private function printNodeFormatPreserving(Node $node, TokenIterator $originalTo return $result . $originalTokens->getContentBetween($pos, $endPos + 1); } + /** + * Prints a node that replaces the tokens of the original node. + * + * The tokens of a Doctrine tag value do not contain the annotation name. + * The parent tag prints the name from its own tokens. + */ + private function printReplacement(Node $node): string + { + if (!$node instanceof DoctrineTagValueNode) { + return $this->print($node); + } + + $arguments = '(' . implode(', ', $node->annotation->arguments) . ')'; + if ($node->annotation->arguments === []) { + $originalNode = $node->getAttribute(Attribute::ORIGINAL_NODE); + if ( + $originalNode instanceof DoctrineTagValueNode + && $originalNode->annotation->getAttribute(Attribute::END_INDEX) < $originalNode->annotation->getAttribute(Attribute::START_INDEX) + ) { + $arguments = ''; + } + } + + return trim($arguments . ' ' . $node->description); + } + } diff --git a/tests/PHPStan/Printer/PrinterTest.php b/tests/PHPStan/Printer/PrinterTest.php index 3f791927..a78358ba 100644 --- a/tests/PHPStan/Printer/PrinterTest.php +++ b/tests/PHPStan/Printer/PrinterTest.php @@ -17,6 +17,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArgument; use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArray; use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineArrayItem; +use PHPStan\PhpDocParser\Ast\PhpDoc\Doctrine\DoctrineTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamClosureThisTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamImmediatelyInvokedCallableTagValueNode; @@ -1987,6 +1988,57 @@ public function enterNode(Node $node) }, ]; + yield [ + '/** @\Foo\Id */', + '/** @\Foo\Id */', + $noopVisitor, + ]; + + yield [ + self::nowdoc(' + /** + * @ORM\Column(type="string") + * @\Foo\Id + */'), + self::nowdoc(' + /** + * @ORM\Column(type="string") + * @\Foo\Id + */'), + $noopVisitor, + ]; + + $changeDoctrineDescriptionVisitor = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof DoctrineTagValueNode) { + $node->description = 'other description'; + } + + return $node; + } + + }; + + yield [ + '/** @Route("/path", name="x") some description */', + '/** @Route("/path", name="x") other description */', + $changeDoctrineDescriptionVisitor, + ]; + + yield [ + '/** @\Foo\Route("/path") some description */', + '/** @\Foo\Route("/path") other description */', + $changeDoctrineDescriptionVisitor, + ]; + + yield [ + '/** @\Foo\Id some description */', + '/** @\Foo\Id other description */', + $changeDoctrineDescriptionVisitor, + ]; + yield [ '/** @Foo() */', '/** @Foo(1, 2, 3) */',