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 @@ + +----- + 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 {