From c85944da0c9915b39947b18a16cb4c648176fa31 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 8 Jul 2026 18:44:24 +0200 Subject: [PATCH 1/2] [Php80] Keep trailing doc comment after annotation in AnnotationToAttributeRector A doctrine annotation swallows the doc comment placed on the line(s) below it into its original content, so removing the converted tag also removed the comment. Split the trailing comment back out into a PhpDocTextNode instead. Fixes rectorphp/rector#9786 --- .../route_with_trailing_comment.php.inc | 37 ++++++++ ...te_with_trailing_comment_multiline.php.inc | 40 +++++++++ .../Class_/AnnotationToAttributeRector.php | 85 ++++++++++++++++++- 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_trailing_comment.php.inc create mode 100644 rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_trailing_comment_multiline.php.inc diff --git a/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_trailing_comment.php.inc b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_trailing_comment.php.inc new file mode 100644 index 00000000000..62aabec3230 --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_trailing_comment.php.inc @@ -0,0 +1,37 @@ + +----- + diff --git a/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_trailing_comment_multiline.php.inc b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_trailing_comment_multiline.php.inc new file mode 100644 index 00000000000..4204bb1dc3e --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_trailing_comment_multiline.php.inc @@ -0,0 +1,40 @@ + +----- + diff --git a/rules/Php80/Rector/Class_/AnnotationToAttributeRector.php b/rules/Php80/Rector/Class_/AnnotationToAttributeRector.php index 823b8d1c514..9c717570b22 100644 --- a/rules/Php80/Rector/Class_/AnnotationToAttributeRector.php +++ b/rules/Php80/Rector/Class_/AnnotationToAttributeRector.php @@ -38,6 +38,7 @@ use Rector\PhpAttribute\NodeFactory\PhpAttributeGroupFactory; use Rector\PhpDocParser\PhpDocParser\PhpDocNodeTraverser; use Rector\Rector\AbstractRector; +use Rector\Util\StringUtils; use Rector\ValueObject\PhpVersionFeature; use Rector\VersionBonding\Contract\MinPhpVersionInterface; use Symplify\RuleDocGenerator\ValueObject\CodeSample\ConfiguredCodeSample; @@ -51,6 +52,11 @@ */ final class AnnotationToAttributeRector extends AbstractRector implements ConfigurableRectorInterface, MinPhpVersionInterface { + /** + * Any unicode letter, to tell a real doc comment apart from leftover annotation syntax + */ + private const string LETTER_REGEX = '#\p{L}#u'; + /** * @var AnnotationToAttribute[] */ @@ -307,13 +313,90 @@ private function processDoctrineAnnotationClasses(PhpDocInfo $phpDocInfo, array return []; } + $phpDocNode = $phpDocInfo->getPhpDocNode(); + foreach ($doctrineTagValueNodes as $doctrineTagValueNode) { - $this->phpDocTagRemover->removeTagValueFromNode($phpDocInfo, $doctrineTagValueNode); + // keep a doc comment that followed the annotation on the next line(s) + $trailingComment = $this->resolveTrailingComment((string) $doctrineTagValueNode->getOriginalContent()); + if ($trailingComment === null) { + $this->phpDocTagRemover->removeTagValueFromNode($phpDocInfo, $doctrineTagValueNode); + continue; + } + + foreach ($phpDocNode->children as $key => $phpDocChildNode) { + if (! $phpDocChildNode instanceof PhpDocTagNode) { + continue; + } + + if ($phpDocChildNode->value !== $doctrineTagValueNode) { + continue; + } + + $phpDocNode->children[$key] = new PhpDocTextNode($trailingComment); + } } return $attributeGroups; } + /** + * A doctrine annotation swallows the doc comment placed on the line(s) below it into its original content. + * Split it back out, so it is not removed together with the converted annotation. + */ + private function resolveTrailingComment(string $originalContent): ?string + { + if (! str_starts_with($originalContent, '(')) { + return null; + } + + $depth = 0; + $inString = false; + $stringChar = ''; + $length = strlen($originalContent); + + for ($i = 0; $i < $length; ++$i) { + $char = $originalContent[$i]; + + if ($inString) { + if ($char === '\\') { + // skip escaped char + ++$i; + } elseif ($char === $stringChar) { + $inString = false; + } + + continue; + } + + if ($char === '"' || $char === "'") { + $inString = true; + $stringChar = $char; + continue; + } + + if ($char === '(') { + ++$depth; + continue; + } + + if ($char === ')') { + --$depth; + if ($depth === 0) { + $trailingComment = trim(substr($originalContent, $i + 1)); + + // keep only a real doc comment, not leftover annotation syntax (e.g. a stray ")") + if (! StringUtils::isMatch($trailingComment, self::LETTER_REGEX)) { + return null; + } + + return $trailingComment; + } + } + } + + return null; + } + private function matchAnnotationToAttribute( DoctrineAnnotationTagValueNode $doctrineAnnotationTagValueNode ): AnnotationToAttribute|null { From 9eea2dbd9a4a7e56bf41f7828c67d7b1c18f13c3 Mon Sep 17 00:00:00 2001 From: Tomas Votruba Date: Wed, 8 Jul 2026 18:48:21 +0200 Subject: [PATCH 2/2] Add fixture for doc comment above the annotation --- .../route_with_leading_comment.php.inc | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_leading_comment.php.inc diff --git a/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_leading_comment.php.inc b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_leading_comment.php.inc new file mode 100644 index 00000000000..79a9932f57d --- /dev/null +++ b/rules-tests/Php80/Rector/Class_/AnnotationToAttributeRector/Fixture/Symfony/route_with_leading_comment.php.inc @@ -0,0 +1,37 @@ + +----- +