From 129e94c6a657b5fc8f542df75cb0502f0c705fe6 Mon Sep 17 00:00:00 2001 From: "ondrej@mirtes.cz" Date: Sun, 1 Nov 2026 15:51:41 +0000 Subject: [PATCH] Convert Doctrine annotations with arguments on the next line Doctrine skips the whitespace and "*" between the tag name and "(", so @Action with (id="value") on the next line has the same arguments as @Action(id="value"). phpdoc-parser reads the arguments only when "(" follows the tag name directly, and AnnotationToAttributeRector created an attribute without arguments and dropped them. For a tag of an annotation class, the decorator now parses the arguments on the next line. When they do not parse, e.g. a nested annotation on its own line, the tag stays as it is. Amp-Thread-ID: https://ampcode.com/threads/T-01a197b0-0078-7305-a757-b15baab1dfdf --- .../Fixture/arguments_on_next_line.php.inc | 44 ++++++++ ...kip_invalid_arguments_on_next_line.php.inc | 15 +++ ...notation_in_arguments_on_next_line.php.inc | 19 ++++ .../DoctrineAnnotationDecorator.php | 101 +++++++++++++++--- 4 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/arguments_on_next_line.php.inc create mode 100644 rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/skip_invalid_arguments_on_next_line.php.inc create mode 100644 rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/FixturePhp81/skip_nested_annotation_in_arguments_on_next_line.php.inc diff --git a/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/arguments_on_next_line.php.inc b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/arguments_on_next_line.php.inc new file mode 100644 index 00000000000..757fdde4ee2 --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/arguments_on_next_line.php.inc @@ -0,0 +1,44 @@ + +----- + diff --git a/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/skip_invalid_arguments_on_next_line.php.inc b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/skip_invalid_arguments_on_next_line.php.inc new file mode 100644 index 00000000000..19893931bd6 --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/skip_invalid_arguments_on_next_line.php.inc @@ -0,0 +1,15 @@ + diff --git a/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/FixturePhp81/skip_nested_annotation_in_arguments_on_next_line.php.inc b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/FixturePhp81/skip_nested_annotation_in_arguments_on_next_line.php.inc new file mode 100644 index 00000000000..275a84633b6 --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/FixturePhp81/skip_nested_annotation_in_arguments_on_next_line.php.inc @@ -0,0 +1,19 @@ + diff --git a/src/BetterPhpDocParser/NodeDecorator/DoctrineAnnotationDecorator.php b/src/BetterPhpDocParser/NodeDecorator/DoctrineAnnotationDecorator.php index 995ee4d9be4..78c6ddc527c 100644 --- a/src/BetterPhpDocParser/NodeDecorator/DoctrineAnnotationDecorator.php +++ b/src/BetterPhpDocParser/NodeDecorator/DoctrineAnnotationDecorator.php @@ -19,8 +19,10 @@ use PHPStan\PhpDocParser\Ast\PhpDoc\InvalidTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; +use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagValueNode; use PHPStan\PhpDocParser\Ast\Type\IdentifierTypeNode; use PHPStan\PhpDocParser\Lexer\Lexer; +use PHPStan\PhpDocParser\Parser\PhpDocParser; use PHPStan\Type\ObjectType; use Rector\BetterPhpDocParser\Attributes\AttributeMirrorer; use Rector\BetterPhpDocParser\Contract\PhpDocParser\PhpDocNodeDecoratorInterface; @@ -52,6 +54,7 @@ public function __construct( private ClassAnnotationMatcher $classAnnotationMatcher, private TokenIteratorFactory $tokenIteratorFactory, + private PhpDocParser $phpDocParser, private AttributeMirrorer $attributeMirrorer, private ObjectTypeSpecifier $objectTypeSpecifier ) { @@ -85,13 +88,33 @@ public function decorate(PhpDocNode $phpDocNode, Node $phpNode): void continue; } - if ($phpDocChildNode->value instanceof DoctrineTagValueNode) { + $doctrineTagValueNode = $phpDocChildNode->value; + + // phpdoc-parser reads the arguments only when "(" follows the tag name directly + $argumentsOnNextLine = $this->resolveArgumentsOnNextLine($doctrineTagValueNode); + if ($argumentsOnNextLine !== null && $this->resolveAnnotationClass( + $phpDocChildNode->name, + $phpNode + ) !== null) { + $doctrineTagValueNode = $this->parseDoctrineTagValue($phpDocChildNode->name, $argumentsOnNextLine); + + // keep the tag as it is, so its content is not lost + if (! $doctrineTagValueNode instanceof DoctrineTagValueNode) { + // e.g. the arguments end after a nested annotation on the next line; keep these too + if (! $this->isClosedContent($argumentsOnNextLine)) { + $incompleteAnnotationContent = $argumentsOnNextLine; + } + + continue; + } + } + + if ($doctrineTagValueNode instanceof DoctrineTagValueNode) { $fullyQualifiedAnnotationClass = $this->resolveDoctrineTagClass($phpDocChildNode->name, $phpNode); if ($fullyQualifiedAnnotationClass === null) { continue; } - $doctrineTagValueNode = $phpDocChildNode->value; $originalContent = '(' . implode(', ', $doctrineTagValueNode->annotation->arguments) . ')' . $doctrineTagValueNode->description; @@ -110,18 +133,8 @@ public function decorate(PhpDocNode $phpDocNode, Node $phpNode): void continue; } - // known doc tag to annotation class - $fullyQualifiedAnnotationClass = $this->classAnnotationMatcher->resolveTagFullyQualifiedName( - $phpDocChildNode->name, - $phpNode - ); - - // not an annotations class - if (! \str_contains($fullyQualifiedAnnotationClass, '\\') && ! in_array( - $fullyQualifiedAnnotationClass, - self::ALLOWED_SHORT_ANNOTATIONS, - true - )) { + $fullyQualifiedAnnotationClass = $this->resolveAnnotationClass($phpDocChildNode->name, $phpNode); + if ($fullyQualifiedAnnotationClass === null) { continue; } @@ -134,6 +147,66 @@ public function decorate(PhpDocNode $phpDocNode, Node $phpNode): void } } + /** + * Doctrine skips the whitespace and "*" between the tag name and "(", + * e.g. "@Action" with "(id="value")" on the next line + */ + private function resolveArgumentsOnNextLine(PhpDocTagValueNode $phpDocTagValueNode): ?string + { + if ($phpDocTagValueNode instanceof GenericTagValueNode) { + $content = $phpDocTagValueNode->value; + } elseif ($phpDocTagValueNode instanceof DoctrineTagValueNode && $phpDocTagValueNode->annotation->arguments === []) { + $content = $phpDocTagValueNode->description; + } else { + return null; + } + + $content = ltrim($content); + if (! str_starts_with($content, '(')) { + return null; + } + + return $content; + } + + private function parseDoctrineTagValue(string $tagName, string $arguments): ?DoctrineTagValueNode + { + $tokenIterator = $this->tokenIteratorFactory->create('/** ' . $tagName . $arguments . ' */'); + $phpDocNode = $this->phpDocParser->parse($tokenIterator); + + $phpDocChildNode = $phpDocNode->children[0] ?? null; + if (! $phpDocChildNode instanceof PhpDocTagNode) { + return null; + } + + if (! $phpDocChildNode->value instanceof DoctrineTagValueNode) { + return null; + } + + return $phpDocChildNode->value; + } + + /** + * Known doc tag to annotation class + */ + private function resolveAnnotationClass(string $tagName, Node $phpNode): ?string + { + $fullyQualifiedAnnotationClass = $this->classAnnotationMatcher->resolveTagFullyQualifiedName( + $tagName, + $phpNode + ); + + if (\str_contains($fullyQualifiedAnnotationClass, '\\')) { + return $fullyQualifiedAnnotationClass; + } + + if (in_array($fullyQualifiedAnnotationClass, self::ALLOWED_SHORT_ANNOTATIONS, true)) { + return $fullyQualifiedAnnotationClass; + } + + return null; + } + /** * @param ArrayItemNode[] $values */