Skip to content

Commit abd259a

Browse files
authored
[code-quality] Add attribute support (#702)
1 parent 2eb7139 commit abd259a

File tree

3 files changed

+152
-65
lines changed

3 files changed

+152
-65
lines changed
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
namespace Rector\Symfony\Tests\CodeQuality\Rector\Class_\InlineClassRoutePrefixRector\Fixture;
4+
5+
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
6+
use Symfony\Component\Routing\Annotation\Route;
7+
8+
#[\Symfony\Component\Routing\Attribute\Route("/city")]
9+
final class AttributeRoutingClass extends Controller
10+
{
11+
#[\Symfony\Component\Routing\Attribute\Route("/street")]
12+
public function some()
13+
{
14+
}
15+
}
16+
17+
?>
18+
-----
19+
<?php
20+
21+
namespace Rector\Symfony\Tests\CodeQuality\Rector\Class_\InlineClassRoutePrefixRector\Fixture;
22+
23+
use Symfony\Bundle\FrameworkBundle\Controller\Controller;
24+
use Symfony\Component\Routing\Annotation\Route;
25+
26+
final class AttributeRoutingClass extends Controller
27+
{
28+
#[\Symfony\Component\Routing\Attribute\Route('/city/street')]
29+
public function some()
30+
{
31+
}
32+
}
33+
34+
?>

rules/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector.php

Lines changed: 113 additions & 65 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,20 @@
55
namespace Rector\Symfony\CodeQuality\Rector\Class_;
66

77
use PhpParser\Node;
8-
use PhpParser\Node\Name;
8+
use PhpParser\Node\Attribute;
9+
use PhpParser\Node\Scalar\String_;
910
use PhpParser\Node\Stmt\Class_;
1011
use Rector\BetterPhpDocParser\PhpDoc\ArrayItemNode;
1112
use Rector\BetterPhpDocParser\PhpDoc\DoctrineAnnotationTagValueNode;
1213
use Rector\BetterPhpDocParser\PhpDoc\StringNode;
13-
use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfo;
1414
use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfoFactory;
1515
use Rector\BetterPhpDocParser\PhpDocManipulator\PhpDocTagRemover;
1616
use Rector\Comments\NodeDocBlock\DocBlockUpdater;
17+
use Rector\Doctrine\NodeAnalyzer\AttrinationFinder;
1718
use Rector\Rector\AbstractRector;
1819
use Rector\Symfony\Enum\FosAnnotation;
1920
use Rector\Symfony\Enum\SymfonyAnnotation;
21+
use Rector\Symfony\Enum\SymfonyAttribute;
2022
use Rector\Symfony\TypeAnalyzer\ControllerAnalyzer;
2123
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
2224
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
@@ -29,17 +31,23 @@ final class InlineClassRoutePrefixRector extends AbstractRector
2931
/**
3032
* @var string[]
3133
*/
32-
private const SKIPPED_ANNOTATIONS = [
34+
private const FOS_REST_ANNOTATIONS = [
3335
FosAnnotation::REST_POST,
3436
FosAnnotation::REST_GET,
3537
FosAnnotation::REST_ROUTE,
3638
];
3739

40+
/**
41+
* @var string
42+
*/
43+
private const PATH = 'path';
44+
3845
public function __construct(
3946
private readonly PhpDocInfoFactory $phpDocInfoFactory,
4047
private readonly PhpDocTagRemover $phpDocTagRemover,
4148
private readonly DocBlockUpdater $docBlockUpdater,
4249
private readonly ControllerAnalyzer $controllerAnalyzer,
50+
private readonly AttrinationFinder $attrinationFinder
4351
) {
4452
}
4553

@@ -80,7 +88,6 @@ public function action()
8088
}
8189
CODE_SAMPLE
8290
),
83-
8491
]
8592
);
8693
}
@@ -99,109 +106,150 @@ public function refactor(Node $node): ?Class_
99106
return null;
100107
}
101108

102-
// 1. detect and remove class-level Route annotation
103-
$classPhpDocInfo = $this->phpDocInfoFactory->createFromNode($node);
104-
if (! $classPhpDocInfo instanceof PhpDocInfo) {
105-
return null;
106-
}
109+
$classRoutePath = null;
107110

108-
$classRouteTagValueNode = $classPhpDocInfo->getByAnnotationClass(SymfonyAnnotation::ROUTE);
109-
if (! $classRouteTagValueNode instanceof DoctrineAnnotationTagValueNode) {
110-
return null;
111-
}
111+
// 1. detect attribute
112+
$routeAttributeOrAnnotation = $this->attrinationFinder->getByMany(
113+
$node,
114+
[SymfonyAttribute::ROUTE, SymfonyAnnotation::ROUTE]
115+
);
112116

113-
$classRoutePathNode = $classRouteTagValueNode->getSilentValue() ?: $classRouteTagValueNode->getValue('path');
114-
if (! $classRoutePathNode instanceof ArrayItemNode) {
115-
return null;
117+
if ($routeAttributeOrAnnotation instanceof DoctrineAnnotationTagValueNode) {
118+
$classRoutePath = $this->resolveRoutePath($routeAttributeOrAnnotation);
119+
} elseif ($routeAttributeOrAnnotation instanceof Attribute) {
120+
$classRoutePath = $this->resolveRoutePathFromAttribute($routeAttributeOrAnnotation);
116121
}
117122

118-
if (! $classRoutePathNode->value instanceof StringNode) {
123+
if ($classRoutePath === null) {
119124
return null;
120125
}
121126

122-
$classRoutePath = $classRoutePathNode->value->value;
123-
124127
// 2. inline prefix to all method routes
125128
$hasChanged = false;
126129

127130
foreach ($node->getMethods() as $classMethod) {
128-
if (! $classMethod->isPublic()) {
129-
continue;
130-
}
131-
132-
if ($classMethod->isMagic()) {
131+
if (! $classMethod->isPublic() || $classMethod->isMagic()) {
133132
continue;
134133
}
135134

136135
// can be route method
137-
$methodPhpDocInfo = $this->phpDocInfoFactory->createFromNode($classMethod);
138-
if (! $methodPhpDocInfo instanceof PhpDocInfo) {
139-
continue;
140-
}
141-
142-
$methodRouteTagValueNodes = $methodPhpDocInfo->findByAnnotationClass(SymfonyAnnotation::ROUTE);
143-
foreach ($methodRouteTagValueNodes as $methodRouteTagValueNode) {
144-
$routePathArrayItemNode = $methodRouteTagValueNode->getSilentValue() ?? $methodRouteTagValueNode->getValue(
145-
'path'
146-
);
147-
if (! $routePathArrayItemNode instanceof ArrayItemNode) {
148-
continue;
149-
}
150-
151-
if (! $routePathArrayItemNode->value instanceof StringNode) {
152-
continue;
136+
$methodRouteAnnotationOrAttributes = $this->attrinationFinder->findManyByMany(
137+
$classMethod,
138+
[SymfonyAttribute::ROUTE, SymfonyAnnotation::ROUTE]
139+
);
140+
141+
foreach ($methodRouteAnnotationOrAttributes as $methodRouteAnnotationOrAttribute) {
142+
if ($methodRouteAnnotationOrAttribute instanceof DoctrineAnnotationTagValueNode) {
143+
$routePathArrayItemNode = $methodRouteAnnotationOrAttribute->getSilentValue() ?? $methodRouteAnnotationOrAttribute->getValue(
144+
self::PATH
145+
);
146+
if (! $routePathArrayItemNode instanceof ArrayItemNode) {
147+
continue;
148+
}
149+
150+
if (! $routePathArrayItemNode->value instanceof StringNode) {
151+
continue;
152+
}
153+
154+
$methodPrefix = $routePathArrayItemNode->value;
155+
$newMethodPath = $classRoutePath . $methodPrefix->value;
156+
157+
$routePathArrayItemNode->value = new StringNode($newMethodPath);
158+
$this->docBlockUpdater->updateRefactoredNodeWithPhpDocInfo($classMethod);
159+
160+
$hasChanged = true;
161+
} elseif ($methodRouteAnnotationOrAttribute instanceof Attribute) {
162+
foreach ($methodRouteAnnotationOrAttribute->args as $methodRouteArg) {
163+
if ($methodRouteArg->name === null || $methodRouteArg->name->toString() === self::PATH) {
164+
if (! $methodRouteArg->value instanceof String_) {
165+
continue;
166+
}
167+
168+
$methodRouteString = $methodRouteArg->value;
169+
$methodRouteArg->value = new String_(sprintf(
170+
'%s%s',
171+
$classRoutePath,
172+
$methodRouteString->value
173+
));
174+
175+
$hasChanged = true;
176+
}
177+
}
153178
}
154-
155-
$methodPrefix = $routePathArrayItemNode->value;
156-
$newMethodPath = $classRoutePath . $methodPrefix->value;
157-
158-
$routePathArrayItemNode->value = new StringNode($newMethodPath);
159-
$this->docBlockUpdater->updateRefactoredNodeWithPhpDocInfo($classMethod);
160-
161-
$hasChanged = true;
162179
}
163180
}
164181

165182
if (! $hasChanged) {
166183
return null;
167184
}
168185

169-
$this->phpDocTagRemover->removeTagValueFromNode($classPhpDocInfo, $classRouteTagValueNode);
170-
$this->docBlockUpdater->updateRefactoredNodeWithPhpDocInfo($node);
186+
if ($routeAttributeOrAnnotation instanceof DoctrineAnnotationTagValueNode) {
187+
$classPhpDocInfo = $this->phpDocInfoFactory->createFromNodeOrEmpty($node);
188+
189+
$this->phpDocTagRemover->removeTagValueFromNode($classPhpDocInfo, $routeAttributeOrAnnotation);
190+
$this->docBlockUpdater->updateRefactoredNodeWithPhpDocInfo($node);
191+
} else {
192+
foreach ($node->attrGroups as $attrGroupKey => $attrGroup) {
193+
foreach ($attrGroup->attrs as $attribute) {
194+
if ($attribute === $routeAttributeOrAnnotation) {
195+
unset($node->attrGroups[$attrGroupKey]);
196+
}
197+
}
198+
}
199+
}
171200

172201
return $node;
173202
}
174203

175204
private function shouldSkipClass(Class_ $class): bool
176205
{
177-
if (! $class->extends instanceof Name) {
178-
return true;
179-
}
180-
181206
if (! $this->controllerAnalyzer->isController($class)) {
182207
return true;
183208
}
184209

185210
foreach ($class->getMethods() as $classMethod) {
186-
if (! $classMethod->isPublic()) {
187-
continue;
188-
}
189-
190-
if ($classMethod->isMagic()) {
191-
continue;
192-
}
193-
194-
$classMethodPhpDocInfo = $this->phpDocInfoFactory->createFromNode($classMethod);
195-
if (! $classMethodPhpDocInfo instanceof PhpDocInfo) {
211+
if (! $classMethod->isPublic() || $classMethod->isMagic()) {
196212
continue;
197213
}
198214

199215
// special cases for FOS rest that should be skipped
200-
if ($classMethodPhpDocInfo->hasByAnnotationClasses(self::SKIPPED_ANNOTATIONS)) {
216+
if ($this->attrinationFinder->hasByMany($class, self::FOS_REST_ANNOTATIONS)) {
201217
return true;
202218
}
203219
}
204220

205221
return false;
206222
}
223+
224+
private function resolveRoutePath(DoctrineAnnotationTagValueNode $doctrineAnnotationTagValueNode): ?string
225+
{
226+
$classRoutePathNode = $doctrineAnnotationTagValueNode->getSilentValue() ?: $doctrineAnnotationTagValueNode->getValue(
227+
self::PATH
228+
);
229+
230+
if (! $classRoutePathNode instanceof ArrayItemNode) {
231+
return null;
232+
}
233+
234+
if (! $classRoutePathNode->value instanceof StringNode) {
235+
return null;
236+
}
237+
238+
return $classRoutePathNode->value->value;
239+
}
240+
241+
private function resolveRoutePathFromAttribute(Attribute $attribute): ?string
242+
{
243+
foreach ($attribute->args as $arg) {
244+
// silent or "path"
245+
if ($arg->name === null || $arg->name->toString() === self::PATH) {
246+
$routeExpr = $arg->value;
247+
if ($routeExpr instanceof String_) {
248+
return $routeExpr->value;
249+
}
250+
}
251+
}
252+
253+
return null;
254+
}
207255
}

src/Enum/SymfonyAttribute.php

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,4 +15,9 @@ final class SymfonyAttribute
1515
* @var string
1616
*/
1717
public const EVENT_LISTENER_ATTRIBUTE = 'Symfony\Component\EventDispatcher\Attribute\AsEventListener';
18+
19+
/**
20+
* @var string
21+
*/
22+
public const ROUTE = 'Symfony\Component\Routing\Attribute\Route';
1823
}

0 commit comments

Comments
 (0)