From d084f5787505d8f192a90f3bbc6fcfc38ed65767 Mon Sep 17 00:00:00 2001 From: tmdk Date: Wed, 25 Feb 2026 23:08:12 +0000 Subject: [PATCH] Fix duplicate star on continuation lines starting with * in tag descriptions When a tag description contains continuation lines beginning with *, the * was duplicated in the parsed output. This affected block-style @param descriptions (e.g. WordPress-style docblocks with @type lines and star- prefixed list items). Root cause: tokenizeLine() split TOKEN_PHPDOC_EOL tokens with trailing whitespace into a bare EOL + TOKEN_HORIZONTAL_WS pair, placing * in both token values. When PHPStan rolled back past such a token on encountering a tag-like token (@type), joinUntil() concatenated both raw values, doubling the star. Fix: prefix every continuation line with "* " before tokenizing. The lexer always consumes the inserted "* " as part of TOKEN_PHPDOC_EOL, leaving original indentation as a separate subsequent token. An unconditional trim(..., "* \t") on every EOL token value strips the inserted "* " back out. The manual split into TOKEN_HORIZONTAL_WS is no longer needed. Co-Authored-By: Claude Sonnet 4.6 --- .../Tags/Factory/AbstractPHPStanFactory.php | 22 ++++------- .../integration/InterpretingDocBlocksTest.php | 38 +++++++++++++++++++ 2 files changed, 46 insertions(+), 14 deletions(-) diff --git a/src/DocBlock/Tags/Factory/AbstractPHPStanFactory.php b/src/DocBlock/Tags/Factory/AbstractPHPStanFactory.php index 35a981e5..456a2645 100644 --- a/src/DocBlock/Tags/Factory/AbstractPHPStanFactory.php +++ b/src/DocBlock/Tags/Factory/AbstractPHPStanFactory.php @@ -61,7 +61,7 @@ public function __construct(PHPStanFactory ...$factories) public function create(string $tagLine, ?TypeContext $context = null): Tag { try { - $tokens = $this->tokenizeLine($tagLine . "\n"); + $tokens = $this->tokenizeLine($tagLine); $ast = $this->parser->parseTag($tokens); if (property_exists($ast->value, 'description') === true) { $ast->value->setAttribute( @@ -104,21 +104,15 @@ public function create(string $tagLine, ?TypeContext $context = null): Tag */ private function tokenizeLine(string $tagLine): TokenIterator { - $tokens = $this->lexer->tokenize($tagLine); + // Prefix continuation lines with "* ", which is consumed by the phpstan parser as TOKEN_PHPDOC_EOL. + $tagLine = str_replace("\n", "\n* ", $tagLine); + $tokens = $this->lexer->tokenize($tagLine . "\n"); $fixed = []; foreach ($tokens as $token) { - if (($token[1] === Lexer::TOKEN_PHPDOC_EOL) && rtrim($token[0], " \t") !== $token[0]) { - $fixed[] = [ - rtrim($token[Lexer::VALUE_OFFSET], " \t"), - Lexer::TOKEN_PHPDOC_EOL, - $token[2] ?? 0, - ]; - $fixed[] = [ - ltrim($token[Lexer::VALUE_OFFSET], "\n\r"), - Lexer::TOKEN_HORIZONTAL_WS, - ($token[2] ?? 0) + 1, - ]; - continue; + if ($token[Lexer::TYPE_OFFSET] === Lexer::TOKEN_PHPDOC_EOL) { + // Strip "* " prefix (and other horizontal whitespace) again so it doesn't and up in the + // description when we joinUntil() in create(). + $token[Lexer::VALUE_OFFSET] = trim($token[Lexer::VALUE_OFFSET], "* \t"); } $fixed[] = $token; diff --git a/tests/integration/InterpretingDocBlocksTest.php b/tests/integration/InterpretingDocBlocksTest.php index 4ea86680..64644f42 100644 --- a/tests/integration/InterpretingDocBlocksTest.php +++ b/tests/integration/InterpretingDocBlocksTest.php @@ -489,4 +489,42 @@ public function testParamTagDescriptionIsCorrectly(): void self::assertSame('Lorem ipsum dolor sit amet, consectetur adipiscing elit. Maecenas varius, tellus in cursus dictum, justo odio sagittis velit, id iaculis mi dui id nisi.', (string) $paramTags->getDescription()); } + + public function testParamBlockDescriptionPreservesStarContinuationLines(): void + { + $docComment = <<create($docComment); + + self::assertEquals( + [ + new Param( + 'foo', + new Array_(), + false, + new Description( + '{' . "\n" . + ' Description of foo.' . "\n" . + "\n" . + ' @type string $bar Description of bar with' . "\n" . + ' * a list' . "\n" . + ' * spanning *multiple* lines' . "\n" . + '}' + ), + ), + ], + $docblock->getTags() + ); + } }