Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/Ast/PhpDoc/PhpDocInlineTagNode.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
<?php declare(strict_types = 1);

namespace PHPStan\PhpDocParser\Ast\PhpDoc;

use PHPStan\PhpDocParser\Ast\Node;
use PHPStan\PhpDocParser\Ast\NodeAttributes;

class PhpDocInlineTagNode implements Node
{

use NodeAttributes;

public string $name;

public string $value;

public function __construct(string $name, string $value)
{
$this->name = $name;
$this->value = $value;
}

public function __toString(): string
{
if ($this->value === '') {
return '{' . $this->name . '}';
}

return '{' . $this->name . ' ' . $this->value . '}';
}

/**
* @param array<string, mixed> $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;
}

}
11 changes: 9 additions & 2 deletions src/Ast/PhpDoc/PhpDocTextNode.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,16 @@ class PhpDocTextNode implements PhpDocChildNode

public string $text;

public function __construct(string $text)
/** @var list<PhpDocInlineTagNode> */
public array $inlineTags;

/**
* @param list<PhpDocInlineTagNode> $inlineTags
*/
public function __construct(string $text, array $inlineTags = [])
{
$this->text = $text;
$this->inlineTags = $inlineTags;
}

public function __toString(): string
Expand All @@ -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);
Expand Down
3 changes: 3 additions & 0 deletions src/Lexer/Lexer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 => '\'&\'',
Expand All @@ -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',
Expand Down Expand Up @@ -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 => '\\}',

Expand Down
35 changes: 34 additions & 1 deletion src/Parser/PhpDocParser.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -190,14 +191,17 @@ 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];

$savepoint = false;

// 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
Expand Down Expand Up @@ -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<Ast\PhpDoc\PhpDocInlineTagNode> $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
Expand Down
58 changes: 58 additions & 0 deletions src/Printer/Printer.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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));
Expand Down Expand Up @@ -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);
}

}
68 changes: 68 additions & 0 deletions tests/PHPStan/Parser/PhpDocParserTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading