From ec9542a636de91df236009f241083b54c233a3d5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ond=C5=99ej=20Mirtes?= Date: Mon, 26 Oct 2026 18:05:38 +0000 Subject: [PATCH] Printer: put back asterisks in multi-line texts and descriptions The parser removes the asterisk at the start of each line of a text or description. When the printer printed such a node, the lines after the first had no asterisk. The format-preserving printer now uses the line start of the original PHPDoc, so the indentation stays the same. Amp-Thread-ID: https://ampcode.com/threads/T-01a17930-0285-73c5-a957-4325c77aa1b2 --- src/Printer/Printer.php | 58 ++++++++++++++++++- tests/PHPStan/Printer/PrinterTest.php | 83 +++++++++++++++++++++++++++ 2 files changed, 138 insertions(+), 3 deletions(-) diff --git a/src/Printer/Printer.php b/src/Printer/Printer.php index 0e218b50..fe049aee 100644 --- a/src/Printer/Printer.php +++ b/src/Printer/Printer.php @@ -77,10 +77,12 @@ use function in_array; use function is_array; use function preg_match_all; +use function preg_replace_callback; use function sprintf; use function str_replace; use function strlen; use function strpos; +use function substr; use function trim; use const PREG_SET_ORDER; @@ -96,6 +98,12 @@ final class Printer /** @var Differ */ private Differ $differ; + /** + * Newline, indentation and asterisk at the start of a PHPDoc line. + * Written after each newline in a printed text or description. + */ + private string $lineStart = "\n *"; + /** * Map From "{$class}->{$subNode}" to string that should be inserted * between elements of this list subnode @@ -177,6 +185,17 @@ final class Printer ]; public function printFormatPreserving(PhpDocNode $node, PhpDocNode $originalNode, TokenIterator $originalTokens): string + { + $lineStart = $this->lineStart; + $this->lineStart = $this->detectLineStart($originalTokens); + try { + return $this->printFormatPreservingWithLineStart($node, $originalNode, $originalTokens); + } finally { + $this->lineStart = $lineStart; + } + } + + private function printFormatPreservingWithLineStart(PhpDocNode $node, PhpDocNode $originalNode, TokenIterator $originalTokens): string { $this->differ = new Differ(static function ($a, $b) { if ($a instanceof Node && $b instanceof Node) { @@ -207,7 +226,7 @@ public function print(Node $node): string if ($node instanceof PhpDocNode) { return "/**\n *" . implode("\n *", array_map( function (PhpDocChildNode $child): string { - $s = $this->print($child); + $s = $this->addLineStarts($this->print($child)); return $s === '' ? '' : ' ' . $s; }, $node->children, @@ -970,7 +989,7 @@ private function printNodeFormatPreserving(Node $node, TokenIterator $originalTo private function printReplacement(Node $node): string { if (!$node instanceof DoctrineTagValueNode) { - return $this->print($node); + return $this->addLineStarts($this->print($node)); } $arguments = '(' . implode(', ', $node->annotation->arguments) . ')'; @@ -984,7 +1003,40 @@ private function printReplacement(Node $node): string } } - return trim($arguments . ' ' . $node->description); + return $this->addLineStarts(trim($arguments . ' ' . $node->description)); + } + + /** + * The parser removes the asterisk at the start of each line of a text or description. + * This puts it back. + */ + private function addLineStarts(string $s): string + { + return preg_replace_callback( + '~\r?\n(?=(.?))~', + fn (array $matches): string => $matches[1] === '' || $matches[1] === "\r" || $matches[1] === "\n" + ? $this->lineStart + : $this->lineStart . ' ', + $s, + ) ?? $s; + } + + private function detectLineStart(TokenIterator $originalTokens): string + { + foreach ($originalTokens->getTokens() as $token) { + if ($token[Lexer::TYPE_OFFSET] !== Lexer::TOKEN_PHPDOC_EOL) { + continue; + } + + $asteriskPosition = strpos($token[Lexer::VALUE_OFFSET], '*'); + if ($asteriskPosition === false) { + continue; + } + + return substr($token[Lexer::VALUE_OFFSET], 0, $asteriskPosition + 1); + } + + return ($originalTokens->getDetectedNewline() ?? "\n") . ' *'; } } diff --git a/tests/PHPStan/Printer/PrinterTest.php b/tests/PHPStan/Printer/PrinterTest.php index a78358ba..4f6c2c9c 100644 --- a/tests/PHPStan/Printer/PrinterTest.php +++ b/tests/PHPStan/Printer/PrinterTest.php @@ -25,6 +25,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PureUnlessCallableIsImpureTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PureUnlessParameterIsPassedTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ReturnTagValueNode; @@ -1988,6 +1989,67 @@ public function enterNode(Node $node) }, ]; + yield [ + self::nowdoc(' + /** + * @param int $foo multi + * line description + * @return void + */'), + self::nowdoc(' + /** + * @param int $bar multi + * line description + * @return void + */'), + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof ParamTagValueNode) { + $node->parameterName = '$bar'; + } + + return $node; + } + + }, + ]; + + yield [ + "/**\n * First line\n * second line\n *\n * @param int \$a\n */", + "/**\n * Changed line\n * second line\n *\n * @param int \$a\n */", + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof PhpDocTextNode && $node->text !== '') { + $node->text = "Changed line\nsecond line"; + } + + return $node; + } + + }, + ]; + + yield [ + "/**\n * @param int \$a\n */", + "/**\n * @param int \$a\n * @return int first\n *\n * second\n */", + new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof PhpDocNode) { + $node->children[] = new PhpDocTagNode('@return', new ReturnTagValueNode(new IdentifierTypeNode('int'), "first\n\n second")); + } + + return $node; + } + + }, + ]; + yield [ '/** @\Foo\Id */', '/** @\Foo\Id */', @@ -3003,6 +3065,27 @@ public function dataPrintPhpDocNode(): iterable ]), '/** * @param int $a + */', + ]; + + yield [ + new PhpDocNode([ + new PhpDocTextNode("First line\nsecond line"), + new PhpDocTagNode('@param', new ParamTagValueNode( + new IdentifierTypeNode('int'), + false, + '$a', + "multi\n line\n\nwith empty line", + false, + )), + ]), + '/** + * First line + * second line + * @param int $a multi + * line + * + * with empty line */', ]; }