From 9c832b36fe896781e8308a98d62278bb92e11c9e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20Wer=C5=82os?= Date: Thu, 7 May 2026 21:01:18 +0200 Subject: [PATCH 1/2] Add `TOKEN_PHPDOC_INLINE_TAG` --- src/Ast/PhpDoc/PhpDocInlineTagNode.php | 46 +++++++++++++++ src/Ast/PhpDoc/PhpDocTextNode.php | 11 +++- src/Lexer/Lexer.php | 3 + src/Parser/PhpDocParser.php | 35 +++++++++++- tests/PHPStan/Parser/PhpDocParserTest.php | 68 +++++++++++++++++++++++ 5 files changed, 160 insertions(+), 3 deletions(-) create mode 100644 src/Ast/PhpDoc/PhpDocInlineTagNode.php diff --git a/src/Ast/PhpDoc/PhpDocInlineTagNode.php b/src/Ast/PhpDoc/PhpDocInlineTagNode.php new file mode 100644 index 00000000..b9bd8ed3 --- /dev/null +++ b/src/Ast/PhpDoc/PhpDocInlineTagNode.php @@ -0,0 +1,46 @@ +name = $name; + $this->value = $value; + } + + public function __toString(): string + { + if ($this->value === '') { + return '{' . $this->name . '}'; + } + + return '{' . $this->name . ' ' . $this->value . '}'; + } + + /** + * @param array $properties + */ + public static function __set_state(array $properties): self + { + $instance = new self($properties['name'], $properties['value']); + if (isset($properties['attributes'])) { + foreach ($properties['attributes'] as $key => $value) { + $instance->setAttribute($key, $value); + } + } + return $instance; + } + +} diff --git a/src/Ast/PhpDoc/PhpDocTextNode.php b/src/Ast/PhpDoc/PhpDocTextNode.php index 4a485222..89d575ba 100644 --- a/src/Ast/PhpDoc/PhpDocTextNode.php +++ b/src/Ast/PhpDoc/PhpDocTextNode.php @@ -11,9 +11,16 @@ class PhpDocTextNode implements PhpDocChildNode public string $text; - public function __construct(string $text) + /** @var list */ + public array $inlineTags; + + /** + * @param list $inlineTags + */ + public function __construct(string $text, array $inlineTags = []) { $this->text = $text; + $this->inlineTags = $inlineTags; } public function __toString(): string @@ -26,7 +33,7 @@ public function __toString(): string */ public static function __set_state(array $properties): self { - $instance = new self($properties['text']); + $instance = new self($properties['text'], $properties['inlineTags'] ?? []); if (isset($properties['attributes'])) { foreach ($properties['attributes'] as $key => $value) { $instance->setAttribute($key, $value); diff --git a/src/Lexer/Lexer.php b/src/Lexer/Lexer.php index e2e0e576..89aec246 100644 --- a/src/Lexer/Lexer.php +++ b/src/Lexer/Lexer.php @@ -52,6 +52,7 @@ class Lexer public const TOKEN_ARROW = 36; public const TOKEN_COMMENT = 37; + public const TOKEN_PHPDOC_INLINE_TAG = 38; public const TOKEN_LABELS = [ self::TOKEN_REFERENCE => '\'&\'', @@ -78,6 +79,7 @@ class Lexer self::TOKEN_OPEN_PHPDOC => '\'/**\'', self::TOKEN_CLOSE_PHPDOC => '\'*/\'', self::TOKEN_PHPDOC_TAG => 'TOKEN_PHPDOC_TAG', + self::TOKEN_PHPDOC_INLINE_TAG => 'TOKEN_PHPDOC_INLINE_TAG', self::TOKEN_DOCTRINE_TAG => 'TOKEN_DOCTRINE_TAG', self::TOKEN_PHPDOC_EOL => 'TOKEN_PHPDOC_EOL', self::TOKEN_FLOAT => 'TOKEN_FLOAT', @@ -157,6 +159,7 @@ private function generateRegexp(): string self::TOKEN_CLOSE_ANGLE_BRACKET => '>', self::TOKEN_OPEN_SQUARE_BRACKET => '\\[', self::TOKEN_CLOSE_SQUARE_BRACKET => '\\]', + self::TOKEN_PHPDOC_INLINE_TAG => '\\{@[a-z][a-z0-9-\\\\]*+(?:[\\x09\\x20]++[^}\\r\\n]*+)?+\\}', self::TOKEN_OPEN_CURLY_BRACKET => '\\{', self::TOKEN_CLOSE_CURLY_BRACKET => '\\}', diff --git a/src/Parser/PhpDocParser.php b/src/Parser/PhpDocParser.php index cbb0e1a3..c16d208f 100644 --- a/src/Parser/PhpDocParser.php +++ b/src/Parser/PhpDocParser.php @@ -14,6 +14,7 @@ use PHPStan\ShouldNotHappenException; use function array_key_exists; use function count; +use function preg_match; use function rtrim; use function str_replace; use function trim; @@ -190,6 +191,7 @@ private function enrichWithAttributes(TokenIterator $tokens, Ast\Node $tag, int private function parseText(TokenIterator $tokens): Ast\PhpDoc\PhpDocTextNode { $text = ''; + $inlineTags = []; $endTokens = [Lexer::TOKEN_CLOSE_PHPDOC, Lexer::TOKEN_END]; @@ -197,7 +199,9 @@ private function parseText(TokenIterator $tokens): Ast\PhpDoc\PhpDocTextNode // if the next token is EOL, everything below is skipped and empty string is returned while (true) { + $startIndex = $tokens->currentTokenIndex(); $tmpText = $tokens->getSkippedHorizontalWhiteSpaceIfAny() . $tokens->joinUntil(Lexer::TOKEN_PHPDOC_EOL, ...$endTokens); + $this->collectInlineTags($tokens, $startIndex, $tokens->currentTokenIndex(), $inlineTags); $text .= $tmpText; // stop if we're not at EOL - meaning it's the end of PHPDoc @@ -234,7 +238,36 @@ private function parseText(TokenIterator $tokens): Ast\PhpDoc\PhpDocTextNode $text = rtrim($text, $tokens->getDetectedNewline() ?? "\n"); } - return new Ast\PhpDoc\PhpDocTextNode(trim($text, " \t")); + return new Ast\PhpDoc\PhpDocTextNode(trim($text, " \t"), $inlineTags); + } + + /** + * @param list $inlineTags + */ + private function collectInlineTags(TokenIterator $tokens, int $startIndex, int $endIndex, array &$inlineTags): void + { + $allTokens = $tokens->getTokens(); + for ($i = $startIndex; $i < $endIndex; $i++) { + if ($allTokens[$i][Lexer::TYPE_OFFSET] !== Lexer::TOKEN_PHPDOC_INLINE_TAG) { + continue; + } + + $value = $allTokens[$i][Lexer::VALUE_OFFSET]; + if (preg_match('~^\\{(@[a-z][a-z0-9-\\\\]*+)(?:[\\x09\\x20]++([^}\\r\\n]*+))?+\\}$~i', $value, $matches) !== 1) { + continue; + } + + $node = new Ast\PhpDoc\PhpDocInlineTagNode($matches[1], $matches[2] ?? ''); + if ($this->config->useLinesAttributes) { + $node->setAttribute(Ast\Attribute::START_LINE, $allTokens[$i][Lexer::LINE_OFFSET]); + $node->setAttribute(Ast\Attribute::END_LINE, $allTokens[$i][Lexer::LINE_OFFSET]); + } + if ($this->config->useIndexAttributes) { + $node->setAttribute(Ast\Attribute::START_INDEX, $i); + $node->setAttribute(Ast\Attribute::END_INDEX, $i); + } + $inlineTags[] = $node; + } } private function parseOptionalDescriptionAfterDoctrineTag(TokenIterator $tokens): string diff --git a/tests/PHPStan/Parser/PhpDocParserTest.php b/tests/PHPStan/Parser/PhpDocParserTest.php index 12a2c40f..cb7e45df 100644 --- a/tests/PHPStan/Parser/PhpDocParserTest.php +++ b/tests/PHPStan/Parser/PhpDocParserTest.php @@ -34,6 +34,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\ParamLaterInvokedCallableTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamOutTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocInlineTagNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTextNode; @@ -6558,6 +6559,73 @@ public function provideInlineTags(): Iterator new PhpDocTagNode('@\ORM\Entity', new DoctrineTagValueNode(new DoctrineAnnotation('@\ORM\Entity', []), '2024 onwards Catalyst IT EU {@link https://catalyst-eu.net}')), ]), ]; + + yield [ + 'Inline {@inheritDoc} alone', + '/** {@inheritDoc} */', + new PhpDocNode([ + new PhpDocTextNode('{@inheritDoc}', [ + new PhpDocInlineTagNode('@inheritDoc', ''), + ]), + ]), + ]; + + yield [ + 'Inline {@inheritdoc} lowercase', + '/** {@inheritdoc} */', + new PhpDocNode([ + new PhpDocTextNode('{@inheritdoc}', [ + new PhpDocInlineTagNode('@inheritdoc', ''), + ]), + ]), + ]; + + yield [ + 'Inline {@link} with description', + '/** see {@link https://example.com Example} for details */', + new PhpDocNode([ + new PhpDocTextNode('see {@link https://example.com Example} for details', [ + new PhpDocInlineTagNode('@link', 'https://example.com Example'), + ]), + ]), + ]; + + yield [ + 'Multiple inline tags in text', + '/** see {@see Foo} or {@link https://example.com} */', + new PhpDocNode([ + new PhpDocTextNode('see {@see Foo} or {@link https://example.com}', [ + new PhpDocInlineTagNode('@see', 'Foo'), + new PhpDocInlineTagNode('@link', 'https://example.com'), + ]), + ]), + ]; + + yield [ + 'Inline tag mixed with prose', + '/** Please do not add {@inheritDoc} to this method */', + new PhpDocNode([ + new PhpDocTextNode('Please do not add {@inheritDoc} to this method', [ + new PhpDocInlineTagNode('@inheritDoc', ''), + ]), + ]), + ]; + + yield [ + 'Unclosed brace is not an inline tag', + '/** {@inheritDoc no closing brace */', + new PhpDocNode([ + new PhpDocTextNode('{@inheritDoc no closing brace'), + ]), + ]; + + yield [ + 'Curly braces without @ are not inline tags', + '/** {key: value} */', + new PhpDocNode([ + new PhpDocTextNode('{key: value}'), + ]), + ]; } public function provideParamOutTagsData(): Iterator From 285f2a087ac6b854e9d880cc1c44e9f8bfc4b389 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Kuba=20Wer=C5=82os?= Date: Thu, 7 May 2026 21:50:46 +0200 Subject: [PATCH 2/2] Improve printer --- src/Printer/Printer.php | 58 ++++++++ tests/PHPStan/Printer/PrinterTest.php | 183 ++++++++++++++++++++++++++ 2 files changed, 241 insertions(+) diff --git a/src/Printer/Printer.php b/src/Printer/Printer.php index 36f6ebe1..187abf99 100644 --- a/src/Printer/Printer.php +++ b/src/Printer/Printer.php @@ -27,6 +27,7 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\ParamOutTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocChildNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocInlineTagNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode; @@ -205,6 +206,9 @@ function (PhpDocChildNode $child): string { if ($node instanceof PhpDocTextNode) { return $node->text; } + if ($node instanceof PhpDocInlineTagNode) { + return (string) $node; + } if ($node instanceof PhpDocTagNode) { if ($node->value instanceof DoctrineTagValueNode) { return $this->print($node->value); @@ -830,6 +834,11 @@ private function printNodeFormatPreserving(Node $node, TokenIterator $originalTo throw new LogicException(); } + if ($node instanceof PhpDocTextNode) { + assert($originalNode instanceof PhpDocTextNode); + return $this->printPhpDocTextNodeFormatPreserving($node, $originalNode, $originalTokens, $startPos, $endPos); + } + $result = ''; $pos = $startPos; $subNodeNames = array_keys(get_object_vars($node)); @@ -917,4 +926,53 @@ private function printNodeFormatPreserving(Node $node, TokenIterator $originalTo return $result . $originalTokens->getContentBetween($pos, $endPos + 1); } + private function printPhpDocTextNodeFormatPreserving( + PhpDocTextNode $node, + PhpDocTextNode $originalNode, + TokenIterator $originalTokens, + int $startPos, + int $endPos + ): string + { + if (count($node->inlineTags) !== count($originalNode->inlineTags)) { + return $this->print($node); + } + + $anyInlineTagModified = false; + foreach ($node->inlineTags as $i => $inlineTag) { + $original = $inlineTag->getAttribute(Attribute::ORIGINAL_NODE); + if (!$original instanceof PhpDocInlineTagNode || $original !== $originalNode->inlineTags[$i]) { + $anyInlineTagModified = true; + break; + } + if ($inlineTag->name !== $original->name || $inlineTag->value !== $original->value) { + $anyInlineTagModified = true; + break; + } + } + + if (!$anyInlineTagModified) { + if ($node->text === $originalNode->text) { + return $originalTokens->getContentBetween($startPos, $endPos + 1); + } + return $this->print($node); + } + + $pos = $startPos; + $listResult = $this->printArrayFormatPreserving( + $node->inlineTags, + $originalNode->inlineTags, + $originalTokens, + $pos, + PhpDocTextNode::class, + 'inlineTags', + ); + + if ($listResult === null) { + return $this->print($node); + } + + return $listResult . $originalTokens->getContentBetween($pos, $endPos + 1); + } + } diff --git a/tests/PHPStan/Printer/PrinterTest.php b/tests/PHPStan/Printer/PrinterTest.php index 464b7234..b78f8b10 100644 --- a/tests/PHPStan/Printer/PrinterTest.php +++ b/tests/PHPStan/Printer/PrinterTest.php @@ -22,8 +22,10 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\ParamImmediatelyInvokedCallableTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamLaterInvokedCallableTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\ParamTagValueNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocInlineTagNode; 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\ReturnTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\TemplateTagValueNode; @@ -2624,6 +2626,187 @@ public function enterNode(Node $node) */'), $addCommentToObjectShapeItemMiddle, ]; + + yield [ + '/** {@inheritDoc} */', + '/** {@inheritDoc} */', + $noopVisitor, + ]; + + yield [ + '/** see {@link https://example.com Example} for details */', + '/** see {@link https://example.com Example} for details */', + $noopVisitor, + ]; + + yield [ + self::nowdoc(' + /** + * Description {@link https://example.com} + * second line {@see Foo} + */'), + self::nowdoc(' + /** + * Description {@link https://example.com} + * second line {@see Foo} + */'), + $noopVisitor, + ]; + + $normalizeInheritdoc = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof PhpDocTextNode) { + $node->text = str_replace('{@inheritdoc}', '{@inheritDoc}', $node->text); + } + if ($node instanceof PhpDocInlineTagNode && $node->name === '@inheritdoc') { + $node->name = '@inheritDoc'; + } + + return null; + } + + }; + + yield [ + '/** {@inheritdoc} */', + '/** {@inheritDoc} */', + $normalizeInheritdoc, + ]; + + yield [ + '/** see {@inheritdoc} for details */', + '/** see {@inheritDoc} for details */', + $normalizeInheritdoc, + ]; + + yield [ + self::nowdoc(' + /** + * Description {@inheritdoc} + */'), + self::nowdoc(' + /** + * Description {@inheritDoc} + */'), + $normalizeInheritdoc, + ]; + + yield [ + self::nowdoc(' + /** + * line 1 + * line 2 with {@inheritdoc} + * line 3 + */'), + self::nowdoc(' + /** + * line 1 + * line 2 with {@inheritDoc} + * line 3 + */'), + $normalizeInheritdoc, + ]; + + yield [ + '/** see {@link https://example.com Example} or {@inheritdoc} */', + '/** see {@link https://example.com Example} or {@inheritDoc} */', + $normalizeInheritdoc, + ]; + + $replaceInheritdocWithSee = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof PhpDocTextNode) { + $node->text = str_replace('{@inheritdoc}', '{@see Foo}', $node->text); + foreach ($node->inlineTags as $i => $inlineTag) { + if ($inlineTag->name !== '@inheritdoc') { + continue; + } + $node->inlineTags[$i] = new PhpDocInlineTagNode('@see', 'Foo'); + } + } + + return null; + } + + }; + + yield [ + '/** see {@inheritdoc} for details */', + '/** see {@see Foo} for details */', + $replaceInheritdocWithSee, + ]; + + $updateLink = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof PhpDocTextNode) { + $node->text = str_replace('https://old.example.com', 'https://new.example.com', $node->text); + } + if ($node instanceof PhpDocInlineTagNode && $node->name === '@link') { + $node->value = str_replace('https://old.example.com', 'https://new.example.com', $node->value); + } + + return null; + } + + }; + + yield [ + '/** see {@link https://old.example.com Example} */', + '/** see {@link https://new.example.com Example} */', + $updateLink, + ]; + + $replaceTextWithoutInlineTag = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof PhpDocTextNode) { + $node->text = 'no tag here'; + $node->inlineTags = []; + } + + return null; + } + + }; + + yield [ + '/** {@inheritDoc} */', + '/** no tag here */', + $replaceTextWithoutInlineTag, + ]; + + yield [ + '/** see {@inheritDoc} for details */', + '/** no tag here */', + $replaceTextWithoutInlineTag, + ]; + + $addInlineTag = new class extends AbstractNodeVisitor { + + public function enterNode(Node $node) + { + if ($node instanceof PhpDocTextNode && $node->text === 'see for details') { + $node->text = 'see {@inheritDoc} for details'; + $node->inlineTags[] = new PhpDocInlineTagNode('@inheritDoc', ''); + } + + return null; + } + + }; + + yield [ + '/** see for details */', + '/** see {@inheritDoc} for details */', + $addInlineTag, + ]; } /**