diff --git a/composer.json b/composer.json index 758a195083d..335e6a7f5ba 100644 --- a/composer.json +++ b/composer.json @@ -78,7 +78,8 @@ "Rector\\ElasticSearchDSL\\": "packages/ElasticSearchDSL/src", "Rector\\SymfonyPHPUnit\\": "packages/SymfonyPHPUnit/src", "Rector\\Architecture\\": "packages/Architecture/src", - "Rector\\PHPUnitSymfony\\": "packages/PHPUnitSymfony/src" + "Rector\\PHPUnitSymfony\\": "packages/PHPUnitSymfony/src", + "Rector\\SymfonyCodeQuality\\": "packages/SymfonyCodeQuality/src" } }, "autoload-dev": { @@ -118,7 +119,8 @@ "Rector\\ElasticSearchDSL\\Tests\\": "packages/ElasticSearchDSL/tests", "Rector\\SymfonyPHPUnit\\Tests\\": "packages/SymfonyPHPUnit/tests", "Rector\\Architecture\\Tests\\": "packages/Architecture/tests", - "Rector\\PHPUnitSymfony\\Tests\\": "packages/PHPUnitSymfony/tests" + "Rector\\PHPUnitSymfony\\Tests\\": "packages/PHPUnitSymfony/tests", + "Rector\\SymfonyCodeQuality\\Tests\\": "packages/SymfonyCodeQuality/tests" }, "classmap": [ "packages/Symfony/tests/Rector/FrameworkBundle/AbstractToConstructorInjectionRectorSource", @@ -167,4 +169,4 @@ "dev-master": "0.5-dev" } } -} +} \ No newline at end of file diff --git a/config/set/symfony/symfony-code-quality.yaml b/config/set/symfony/symfony-code-quality.yaml index 3f57cb4e80a..350e7cc5ce8 100644 --- a/config/set/symfony/symfony-code-quality.yaml +++ b/config/set/symfony/symfony-code-quality.yaml @@ -1,3 +1,4 @@ services: Rector\Symfony\Rector\BinaryOp\ResponseStatusCodeRector: ~ Rector\Symfony\Rector\Class_\MakeCommandLazyRector: ~ + Rector\SymfonyCodeQuality\Rector\Class_\EventListenerToEventSubscriberRector: ~ diff --git a/packages/SymfonyCodeQuality/src/Rector/Class_/EventListenerToEventSubscriberRector.php b/packages/SymfonyCodeQuality/src/Rector/Class_/EventListenerToEventSubscriberRector.php new file mode 100644 index 00000000000..96c8a485ba4 --- /dev/null +++ b/packages/SymfonyCodeQuality/src/Rector/Class_/EventListenerToEventSubscriberRector.php @@ -0,0 +1,352 @@ + [self::KERNEL_EVENTS_CLASS, 'REQUEST'], + 'kernel.exception' => [self::KERNEL_EVENTS_CLASS, 'EXCEPTION'], + 'kernel.view' => [self::KERNEL_EVENTS_CLASS, 'VIEW'], + 'kernel.controller' => [self::KERNEL_EVENTS_CLASS, 'CONTROLLER'], + 'kernel.controller_arguments' => [self::KERNEL_EVENTS_CLASS, 'CONTROLLER_ARGUMENTS'], + 'kernel.response' => [self::KERNEL_EVENTS_CLASS, 'RESPONSE'], + 'kernel.terminate' => [self::KERNEL_EVENTS_CLASS, 'TERMINATE'], + 'kernel.finish_request' => [self::KERNEL_EVENTS_CLASS, 'FINISH_REQUEST'], + // console events + 'console.command' => [self::CONSOLE_EVENTS_CLASS, 'COMMAND'], + 'console.terminate' => [self::CONSOLE_EVENTS_CLASS, 'TERMINATE'], + 'console.error' => [self::CONSOLE_EVENTS_CLASS, 'ERROR'], + ]; + + /** + * @var AnalyzedApplicationContainerInterface + */ + private $analyzedApplicationContainer; + + /** + * @var mixed[][] + */ + private $listenerClassesToEvents = []; + + /** + * @var bool + */ + private $areListenerClassesLoaded = false; + + /** + * @var DocBlockManipulator + */ + private $docBlockManipulator; + + public function __construct( + AnalyzedApplicationContainerInterface $analyzedApplicationContainer, + DocBlockManipulator $docBlockManipulator + ) { + $this->analyzedApplicationContainer = $analyzedApplicationContainer; + $this->docBlockManipulator = $docBlockManipulator; + } + + public function getDefinition(): RectorDefinition + { + return new RectorDefinition( + 'Change Symfony Event listener class to Event Subscriber based on configuration in service.yaml file', + [ + new CodeSample( + <<<'CODE_SAMPLE' + 'methodToBeCalled']; + } + + public function methodToBeCalled() + { + } +} +CODE_SAMPLE + ), + ] + ); + } + + /** + * @return string[] + */ + public function getNodeTypes(): array + { + return [Class_::class]; + } + + /** + * @param Class_ $node + */ + public function refactor(Node $node): ?Node + { + // anonymous class + if ($node->name === null) { + return null; + } + + // is already a subscriber + if ($this->isAlreadyEventSubscriber($node)) { + return null; + } + + // there must be event dispatcher in the application + $listenerClassToEvents = $this->getListenerClassesToEventsToMethods(); + if ($listenerClassToEvents === []) { + return null; + } + + $className = $this->getName($node); + if (! isset($listenerClassToEvents[$className])) { + return null; + } + + return $this->changeListenerToSubscriberWithMethods($node, $listenerClassToEvents[$className]); + } + + private function isAlreadyEventSubscriber(Class_ $class): bool + { + foreach ((array) $class->implements as $implement) { + if ($this->isName($implement, 'Symfony\Component\EventDispatcher\EventSubscriberInterface')) { + return true; + } + } + + return false; + } + + /** + * @return string[][] + */ + private function getListenerClassesToEventsToMethods(): array + { + if ($this->areListenerClassesLoaded) { + return $this->listenerClassesToEvents; + } + + if (! $this->analyzedApplicationContainer->hasService('event_dispatcher')) { + $this->areListenerClassesLoaded = true; + + return []; + } + + /** @var TraceableEventDispatcher $applicationEventDispatcher */ + $applicationEventDispatcher = $this->analyzedApplicationContainer->getService('event_dispatcher'); + + foreach ($applicationEventDispatcher->getListeners() as $eventName => $listenersInEvent) { + foreach ($listenersInEvent as $listener) { + // must be array → class and method + if (! is_array($listener)) { + continue; + } + + $listenerClass = get_class($listener[0]); + + // skip Symfony core listeners + if (Strings::match($listenerClass, '#^(Symfony|Sensio|Doctrine)\\\\#')) { + continue; + } + + $listenerPriority = $applicationEventDispatcher->getListenerPriority($eventName, $listener); + + // group event name - method - class :) + $this->listenerClassesToEvents[$listenerClass][$eventName][] = [$listener[1], $listenerPriority]; + } + } + + $this->areListenerClassesLoaded = true; + + return $this->listenerClassesToEvents; + } + + /** + * @param mixed[] $eventsToMethods + */ + private function changeListenerToSubscriberWithMethods(Class_ $class, array $eventsToMethods): Class_ + { + $class->implements[] = new FullyQualified(self::EVENT_SUBSCRIBER_INTERFACE); + + $classShortName = (string) $class->name; + // remove suffix + $classShortName = Strings::replace($classShortName, '#^(.*?)(Listener)?$#', '$1'); + $class->name = new Identifier($classShortName . 'EventSubscriber'); + + $clasMethod = $this->createGetSubscribedEventsClassMethod($eventsToMethods); + $class->stmts[] = $clasMethod; + + return $class; + } + + /** + * @return String_|ClassConstFetch + */ + private function createEventName(string $eventName): Node + { + if (class_exists($eventName)) { + return $this->createClassConstantReference($eventName); + } + + // is string a that could be caught in constant, e.g. KernelEvents? + if (isset($this->eventNamesToClassConstants[$eventName])) { + [$class, $constant] = $this->eventNamesToClassConstants[$eventName]; + + return $this->createClassConstant($class, $constant); + } + + return new String_($eventName); + } + + /** + * @param mixed[][] $eventsToMethods + */ + private function createGetSubscribedEventsClassMethod(array $eventsToMethods): ClassMethod + { + $getSubscribedEventsMethod = $this->nodeFactory->createPublicMethod('getSubscribedEvents'); + + $eventsToMethodsArray = new Array_(); + + $this->makeStatic($getSubscribedEventsMethod); + + foreach ($eventsToMethods as $eventName => $methodNamesWithPriorities) { + $eventName = $this->createEventName($eventName); + + if (count($methodNamesWithPriorities) === 1) { + $this->createSingleMethod($methodNamesWithPriorities, $eventName, $eventsToMethodsArray); + } else { + $this->createMultipleMethods($methodNamesWithPriorities, $eventName, $eventsToMethodsArray); + } + } + + $getSubscribedEventsMethod->stmts[] = new Return_($eventsToMethodsArray); + $this->decorateClassMethodWithReturnType($getSubscribedEventsMethod); + + return $getSubscribedEventsMethod; + } + + private function decorateClassMethodWithReturnType(ClassMethod $classMethod): void + { + if ($this->isAtLeastPhpVersion('7.0')) { + $classMethod->returnType = new Identifier('array'); + } + + $this->docBlockManipulator->addReturnTag($classMethod, 'mixed[]'); + } + + /** + * @param ClassConstFetch|String_ $expr + * @param mixed[] $methodNamesWithPriorities + */ + private function createSingleMethod( + array $methodNamesWithPriorities, + Node\Expr $expr, + Array_ $eventsToMethodsArray + ): void { + [$methodName, $priority] = $methodNamesWithPriorities[0]; + + if ($priority) { + $methodNameWithPriorityArray = new Array_(); + $methodNameWithPriorityArray->items[] = new ArrayItem(new String_($methodName)); + $methodNameWithPriorityArray->items[] = new ArrayItem(new Node\Scalar\LNumber((int) $priority)); + + $eventsToMethodsArray->items[] = new ArrayItem($methodNameWithPriorityArray, $expr); + } else { + $eventsToMethodsArray->items[] = new ArrayItem(new String_($methodName), $expr); + } + } + + /** + * @param ClassConstFetch|String_ $expr + * @param mixed[] $methodNamesWithPriorities + */ + private function createMultipleMethods( + array $methodNamesWithPriorities, + Node\Expr $expr, + Array_ $eventsToMethodsArray + ): void { + $multipleMethodsArray = new Array_(); + + foreach ($methodNamesWithPriorities as $methodNamesWithPriority) { + [$methodName, $priority] = $methodNamesWithPriority; + + if ($priority) { + $methodNameWithPriorityArray = new Array_(); + $methodNameWithPriorityArray->items[] = new ArrayItem(new String_($methodName)); + $methodNameWithPriorityArray->items[] = new ArrayItem(new Node\Scalar\LNumber((int) $priority)); + + $multipleMethodsArray->items[] = new ArrayItem($methodNameWithPriorityArray); + } else { + $multipleMethodsArray->items[] = new ArrayItem(new String_($methodName)); + } + } + + $eventsToMethodsArray->items[] = new ArrayItem($multipleMethodsArray, $expr); + } +} diff --git a/packages/SymfonyCodeQuality/tests/Rector/Class_/EventListenerToEventSubscriberRector/EventListenerToEventSubscriberRectorTest.php b/packages/SymfonyCodeQuality/tests/Rector/Class_/EventListenerToEventSubscriberRector/EventListenerToEventSubscriberRectorTest.php new file mode 100644 index 00000000000..d5a5d2f7d6f --- /dev/null +++ b/packages/SymfonyCodeQuality/tests/Rector/Class_/EventListenerToEventSubscriberRector/EventListenerToEventSubscriberRectorTest.php @@ -0,0 +1,31 @@ +get(ParameterProvider::class); + $parameterProvider->changeParameter(Option::KERNEL_CLASS_PARAMETER, ListenersKernel::class); + } + + public function test(): void + { + // wtf: all test have to be in single file due to autoloading race-condigition and container creating issue of fixture + $this->doTestFile(__DIR__ . '/Fixture/fixture.php.inc'); + } + + protected function getRectorClass(): string + { + return EventListenerToEventSubscriberRector::class; + } +} diff --git a/packages/SymfonyCodeQuality/tests/Rector/Class_/EventListenerToEventSubscriberRector/Fixture/fixture.php.inc b/packages/SymfonyCodeQuality/tests/Rector/Class_/EventListenerToEventSubscriberRector/Fixture/fixture.php.inc new file mode 100644 index 00000000000..9f3f6fd865e --- /dev/null +++ b/packages/SymfonyCodeQuality/tests/Rector/Class_/EventListenerToEventSubscriberRector/Fixture/fixture.php.inc @@ -0,0 +1,90 @@ + +----- + 'methodToBeCalled']; + } +} + +class WithPriorityEventSubscriber implements \Symfony\Component\EventDispatcher\EventSubscriberInterface +{ + public function callMe() + { + } + /** + * @return mixed[] + */ + public static function getSubscribedEvents(): array + { + return ['some_event' => ['callMe', 1540]]; + } +} + +class MultipleMethodsEventSubscriber implements \Symfony\Component\EventDispatcher\EventSubscriberInterface +{ + public function callMe() + { + } + + public function singles() + { + } + + public function meToo() + { + } + /** + * @return mixed[] + */ + public static function getSubscribedEvents(): array + { + return ['single_event' => 'singles', 'multi_event' => ['callMe', 'meToo']]; + } +} + +?> diff --git a/packages/SymfonyCodeQuality/tests/Rector/Class_/EventListenerToEventSubscriberRector/Source/ListenersKernel.php b/packages/SymfonyCodeQuality/tests/Rector/Class_/EventListenerToEventSubscriberRector/Source/ListenersKernel.php new file mode 100644 index 00000000000..aa34a8067cd --- /dev/null +++ b/packages/SymfonyCodeQuality/tests/Rector/Class_/EventListenerToEventSubscriberRector/Source/ListenersKernel.php @@ -0,0 +1,88 @@ +register('event_dispatcher', EventDispatcher::class); + + $this->registerSimpleListener($containerBuilder, $eventDispatcherDefinition); + $this->registerWithPriority($containerBuilder, $eventDispatcherDefinition); + $this->registerMultiple($containerBuilder, $eventDispatcherDefinition); + } + + public function getCacheDir() + { + return sys_get_temp_dir() . '/_tmp'; + } + + public function getLogDir() + { + return sys_get_temp_dir() . '/_tmp'; + } + + private function registerSimpleListener(ContainerBuilder $containerBuilder, Definition $eventDispatcherDefinition): void + { + $containerBuilder->register(SomeListener::class); + + /* @see \Symfony\Component\EventDispatcher\EventDispatcher::addListener() */ + $eventDispatcherDefinition->addMethodCall( + 'addListener', + ['some_event', [new Reference(SomeListener::class), 'methodToBeCalled']] + ); + } + + private function registerWithPriority(ContainerBuilder $containerBuilder, Definition $eventDispatcherDefinition): void + { + $containerBuilder->register(WithPriorityListener::class); + + /* @see \Symfony\Component\EventDispatcher\EventDispatcher::addListener() */ + $eventDispatcherDefinition->addMethodCall( + 'addListener', + ['some_event', [new Reference(WithPriorityListener::class), 'callMe'], 1540] + ); + } + + private function registerMultiple(ContainerBuilder $containerBuilder, Definition $eventDispatcherDefinition): void + { + $containerBuilder->register(MultipleMethods::class); + + /* @see \Symfony\Component\EventDispatcher\EventDispatcher::addListener() */ + $eventDispatcherDefinition->addMethodCall( + 'addListener', + ['single_event', [new Reference(MultipleMethods::class), 'singles']] + ); + + $eventDispatcherDefinition->addMethodCall( + 'addListener', + ['multi_event', [new Reference(MultipleMethods::class), 'callMe']] + ); + + $eventDispatcherDefinition->addMethodCall( + 'addListener', + ['multi_event', [new Reference(MultipleMethods::class), 'meToo']] + ); + } +} diff --git a/phpstan.neon b/phpstan.neon index 2f163b68df2..1013e91730d 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -177,3 +177,6 @@ parameters: - '#Parameter \#1 \$error_handler of function set_error_handler expects \(callable\)\|null, Closure\(mixed, mixed, mixed, mixed\)\: void given#' - '#Parameter \#1 \$error_handler of function set_error_handler expects \(callable\)\|null, callable given#' - '#Method Rector\\NodeTypeResolver\\PerNodeTypeResolver\\NameTypeResolver\:\:resolveFullyQualifiedName\(\) should return string\|null but returns PhpParser\\Node\\Name\|null#' + + # array is callable + - '#Parameter \#2 \$listener of method Symfony\\Component\\EventDispatcher\\Debug\\TraceableEventDispatcher\:\:getListenerPriority\(\) expects callable\(\)\: mixed, array given#'