Skip to content

Commit e6fc207

Browse files
authored
[Php80] Keep trailing doc comment after annotation in AnnotationToAttributeRector (#8160)
* [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 * Add fixture for doc comment above the annotation
1 parent 52b57a6 commit e6fc207

4 files changed

Lines changed: 198 additions & 1 deletion

File tree

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Symfony;
4+
5+
use Symfony\Component\Routing\Annotation\Route;
6+
7+
final class RouteWithLeadingComment
8+
{
9+
/**
10+
* this comment must be kept
11+
* @Route("/path", name="action")
12+
*/
13+
public function action()
14+
{
15+
}
16+
}
17+
18+
?>
19+
-----
20+
<?php
21+
22+
namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Symfony;
23+
24+
use Symfony\Component\Routing\Annotation\Route;
25+
26+
final class RouteWithLeadingComment
27+
{
28+
/**
29+
* this comment must be kept
30+
*/
31+
#[Route(path: '/path', name: 'action')]
32+
public function action()
33+
{
34+
}
35+
}
36+
37+
?>
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
<?php
2+
3+
namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Symfony;
4+
5+
use Symfony\Component\Routing\Annotation\Route;
6+
7+
final class RouteWithTrailingComment
8+
{
9+
/**
10+
* @Route("/path", name="action")
11+
* this comment must be kept
12+
*/
13+
public function action()
14+
{
15+
}
16+
}
17+
18+
?>
19+
-----
20+
<?php
21+
22+
namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Symfony;
23+
24+
use Symfony\Component\Routing\Annotation\Route;
25+
26+
final class RouteWithTrailingComment
27+
{
28+
/**
29+
* this comment must be kept
30+
*/
31+
#[Route(path: '/path', name: 'action')]
32+
public function action()
33+
{
34+
}
35+
}
36+
37+
?>
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
<?php
2+
3+
namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Symfony;
4+
5+
use Symfony\Component\Routing\Annotation\Route;
6+
7+
final class RouteWithTrailingCommentMultiline
8+
{
9+
/**
10+
* @Route(
11+
* "/path",
12+
* name="action"
13+
* )
14+
* this comment must be kept
15+
*/
16+
public function action()
17+
{
18+
}
19+
}
20+
21+
?>
22+
-----
23+
<?php
24+
25+
namespace Rector\Tests\Php80\Rector\Class_\AnnotationToAttributeRector\Fixture\Symfony;
26+
27+
use Symfony\Component\Routing\Annotation\Route;
28+
29+
final class RouteWithTrailingCommentMultiline
30+
{
31+
/**
32+
* this comment must be kept
33+
*/
34+
#[Route(path: '/path', name: 'action')]
35+
public function action()
36+
{
37+
}
38+
}
39+
40+
?>

‎rules/Php80/Rector/Class_/AnnotationToAttributeRector.php‎

Lines changed: 84 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@
3838
use Rector\PhpAttribute\NodeFactory\PhpAttributeGroupFactory;
3939
use Rector\PhpDocParser\PhpDocParser\PhpDocNodeTraverser;
4040
use Rector\Rector\AbstractRector;
41+
use Rector\Util\StringUtils;
4142
use Rector\ValueObject\PhpVersionFeature;
4243
use Rector\VersionBonding\Contract\MinPhpVersionInterface;
4344
use Symplify\RuleDocGenerator\ValueObject\CodeSample\ConfiguredCodeSample;
@@ -51,6 +52,11 @@
5152
*/
5253
final class AnnotationToAttributeRector extends AbstractRector implements ConfigurableRectorInterface, MinPhpVersionInterface
5354
{
55+
/**
56+
* Any unicode letter, to tell a real doc comment apart from leftover annotation syntax
57+
*/
58+
private const string LETTER_REGEX = '#\p{L}#u';
59+
5460
/**
5561
* @var AnnotationToAttribute[]
5662
*/
@@ -307,13 +313,90 @@ private function processDoctrineAnnotationClasses(PhpDocInfo $phpDocInfo, array
307313
return [];
308314
}
309315

316+
$phpDocNode = $phpDocInfo->getPhpDocNode();
317+
310318
foreach ($doctrineTagValueNodes as $doctrineTagValueNode) {
311-
$this->phpDocTagRemover->removeTagValueFromNode($phpDocInfo, $doctrineTagValueNode);
319+
// keep a doc comment that followed the annotation on the next line(s)
320+
$trailingComment = $this->resolveTrailingComment((string) $doctrineTagValueNode->getOriginalContent());
321+
if ($trailingComment === null) {
322+
$this->phpDocTagRemover->removeTagValueFromNode($phpDocInfo, $doctrineTagValueNode);
323+
continue;
324+
}
325+
326+
foreach ($phpDocNode->children as $key => $phpDocChildNode) {
327+
if (! $phpDocChildNode instanceof PhpDocTagNode) {
328+
continue;
329+
}
330+
331+
if ($phpDocChildNode->value !== $doctrineTagValueNode) {
332+
continue;
333+
}
334+
335+
$phpDocNode->children[$key] = new PhpDocTextNode($trailingComment);
336+
}
312337
}
313338

314339
return $attributeGroups;
315340
}
316341

342+
/**
343+
* A doctrine annotation swallows the doc comment placed on the line(s) below it into its original content.
344+
* Split it back out, so it is not removed together with the converted annotation.
345+
*/
346+
private function resolveTrailingComment(string $originalContent): ?string
347+
{
348+
if (! str_starts_with($originalContent, '(')) {
349+
return null;
350+
}
351+
352+
$depth = 0;
353+
$inString = false;
354+
$stringChar = '';
355+
$length = strlen($originalContent);
356+
357+
for ($i = 0; $i < $length; ++$i) {
358+
$char = $originalContent[$i];
359+
360+
if ($inString) {
361+
if ($char === '\\') {
362+
// skip escaped char
363+
++$i;
364+
} elseif ($char === $stringChar) {
365+
$inString = false;
366+
}
367+
368+
continue;
369+
}
370+
371+
if ($char === '"' || $char === "'") {
372+
$inString = true;
373+
$stringChar = $char;
374+
continue;
375+
}
376+
377+
if ($char === '(') {
378+
++$depth;
379+
continue;
380+
}
381+
382+
if ($char === ')') {
383+
--$depth;
384+
if ($depth === 0) {
385+
$trailingComment = trim(substr($originalContent, $i + 1));
386+
387+
// keep only a real doc comment, not leftover annotation syntax (e.g. a stray ")")
388+
if (! StringUtils::isMatch($trailingComment, self::LETTER_REGEX)) {
389+
return null;
390+
}
391+
392+
return $trailingComment;
393+
}
394+
}
395+
}
396+
397+
return null;
398+
}
399+
317400
private function matchAnnotationToAttribute(
318401
DoctrineAnnotationTagValueNode $doctrineAnnotationTagValueNode
319402
): AnnotationToAttribute|null {

0 commit comments

Comments
 (0)