diff --git a/config/sets/symfony/symfony-code-quality.php b/config/sets/symfony/symfony-code-quality.php index 330a73ba..809ea40e 100644 --- a/config/sets/symfony/symfony-code-quality.php +++ b/config/sets/symfony/symfony-code-quality.php @@ -5,6 +5,7 @@ use Rector\Config\RectorConfig; use Rector\Symfony\CodeQuality\Rector\BinaryOp\ResponseStatusCodeRector; use Rector\Symfony\CodeQuality\Rector\Class_\EventListenerToEventSubscriberRector; +use Rector\Symfony\CodeQuality\Rector\Class_\InlineClassRoutePrefixRector; use Rector\Symfony\CodeQuality\Rector\Class_\LoadValidatorMetadataToAnnotationRector; use Rector\Symfony\CodeQuality\Rector\ClassMethod\ActionSuffixRemoverRector; use Rector\Symfony\CodeQuality\Rector\ClassMethod\ParamTypeFromRouteRequiredRegexRector; @@ -30,5 +31,8 @@ // tests AssertSameResponseCodeWithDebugContentsRector::class, + + // routing + InlineClassRoutePrefixRector::class, ]); }; diff --git a/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/Fixture/explicit_silent_mix.php.inc b/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/Fixture/explicit_silent_mix.php.inc new file mode 100644 index 00000000..1d110132 --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/Fixture/explicit_silent_mix.php.inc @@ -0,0 +1,40 @@ + +----- + diff --git a/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/Fixture/inversed_explicit_silent_mix.php.inc b/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/Fixture/inversed_explicit_silent_mix.php.inc new file mode 100644 index 00000000..e2ac7f1b --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/Fixture/inversed_explicit_silent_mix.php.inc @@ -0,0 +1,40 @@ + +----- + diff --git a/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/Fixture/skip_no_controller.php.inc b/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/Fixture/skip_no_controller.php.inc new file mode 100644 index 00000000..2ce30425 --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/Fixture/skip_no_controller.php.inc @@ -0,0 +1,18 @@ + +----- + diff --git a/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/InlineClassRoutePrefixRectorTest.php b/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/InlineClassRoutePrefixRectorTest.php new file mode 100644 index 00000000..ee362781 --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/InlineClassRoutePrefixRectorTest.php @@ -0,0 +1,28 @@ +doTestFile($filePath); + } + + public static function provideData(): Iterator + { + return self::yieldFilesFromDirectory(__DIR__ . '/Fixture'); + } + + public function provideConfigFilePath(): string + { + return __DIR__ . '/config/configured_rule.php'; + } +} diff --git a/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/config/configured_rule.php b/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/config/configured_rule.php new file mode 100644 index 00000000..5044168a --- /dev/null +++ b/rules-tests/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector/config/configured_rule.php @@ -0,0 +1,10 @@ +rule(InlineClassRoutePrefixRector::class); +}; diff --git a/rules/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector.php b/rules/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector.php new file mode 100644 index 00000000..7f6ac1b0 --- /dev/null +++ b/rules/CodeQuality/Rector/Class_/InlineClassRoutePrefixRector.php @@ -0,0 +1,207 @@ +shouldSkipClass($node)) { + return null; + } + + // 1. detect and remove class-level Route annotation + $classPhpDocInfo = $this->phpDocInfoFactory->createFromNode($node); + if (! $classPhpDocInfo instanceof PhpDocInfo) { + return null; + } + + $classRouteTagValueNode = $classPhpDocInfo->getByAnnotationClass(SymfonyAnnotation::ROUTE); + if (! $classRouteTagValueNode instanceof DoctrineAnnotationTagValueNode) { + return null; + } + + $classRoutePathNode = $classRouteTagValueNode->getSilentValue() ?: $classRouteTagValueNode->getValue('path'); + if (! $classRoutePathNode instanceof ArrayItemNode) { + return null; + } + + if (! $classRoutePathNode->value instanceof StringNode) { + return null; + } + + $classRoutePath = $classRoutePathNode->value->value; + + // 2. inline prefix to all method routes + $hasChanged = false; + + foreach ($node->getMethods() as $classMethod) { + if (! $classMethod->isPublic()) { + continue; + } + + if ($classMethod->isMagic()) { + continue; + } + + // can be route method + $methodPhpDocInfo = $this->phpDocInfoFactory->createFromNode($classMethod); + if (! $methodPhpDocInfo instanceof PhpDocInfo) { + continue; + } + + $methodRouteTagValueNodes = $methodPhpDocInfo->findByAnnotationClass(SymfonyAnnotation::ROUTE); + foreach ($methodRouteTagValueNodes as $methodRouteTagValueNode) { + $routePathArrayItemNode = $methodRouteTagValueNode->getSilentValue() ?? $methodRouteTagValueNode->getValue( + 'path' + ); + if (! $routePathArrayItemNode instanceof ArrayItemNode) { + continue; + } + + if (! $routePathArrayItemNode->value instanceof StringNode) { + continue; + } + + $methodPrefix = $routePathArrayItemNode->value; + $newMethodPath = $classRoutePath . $methodPrefix->value; + + $routePathArrayItemNode->value = new StringNode($newMethodPath); + $this->docBlockUpdater->updateRefactoredNodeWithPhpDocInfo($classMethod); + + $hasChanged = true; + } + } + + if (! $hasChanged) { + return null; + } + + $this->phpDocTagRemover->removeTagValueFromNode($classPhpDocInfo, $classRouteTagValueNode); + $this->docBlockUpdater->updateRefactoredNodeWithPhpDocInfo($node); + + return $node; + } + + private function shouldSkipClass(Class_ $class): bool + { + if (! $class->extends instanceof Name) { + return true; + } + + if (! $this->controllerAnalyzer->isController($class)) { + return true; + } + + foreach ($class->getMethods() as $classMethod) { + if (! $classMethod->isPublic()) { + continue; + } + + if ($classMethod->isMagic()) { + continue; + } + + $classMethodPhpDocInfo = $this->phpDocInfoFactory->createFromNode($classMethod); + if (! $classMethodPhpDocInfo instanceof PhpDocInfo) { + continue; + } + + // special cases for FOS rest that should be skipped + if ($classMethodPhpDocInfo->hasByAnnotationClasses(self::SKIPPED_ANNOTATIONS)) { + return true; + } + } + + return false; + } +} diff --git a/src/ApplicationMetadata/ListenerServiceDefinitionProvider.php b/src/ApplicationMetadata/ListenerServiceDefinitionProvider.php index 8da9a2ae..47ea57f4 100644 --- a/src/ApplicationMetadata/ListenerServiceDefinitionProvider.php +++ b/src/ApplicationMetadata/ListenerServiceDefinitionProvider.php @@ -54,13 +54,11 @@ public function extract(): array $eventName = $tag->getEvent(); - if ($tag->getMethod() === '') { - // fill method based on the event - if (str_starts_with($tag->getEvent(), 'kernel.')) { - [, $event] = explode('.', $tag->getEvent()); - $methodName = 'onKernel' . ucfirst($event); - $tag->changeMethod($methodName); - } + // fill method based on the event + if ($tag->getMethod() === '' && str_starts_with($tag->getEvent(), 'kernel.')) { + [, $event] = explode('.', $tag->getEvent()); + $methodName = 'onKernel' . ucfirst($event); + $tag->changeMethod($methodName); } $this->listenerClassesToEvents[$eventListener->getClass()][$eventName][] = $eventListener; diff --git a/src/Enum/FosAnnotation.php b/src/Enum/FosAnnotation.php new file mode 100644 index 00000000..9679e587 --- /dev/null +++ b/src/Enum/FosAnnotation.php @@ -0,0 +1,23 @@ +