[SymfonyCodeQuality] From listener to subscriber (#1759)

[SymfonyCodeQuality] From listener to subscriber
This commit is contained in:
Tomáš Votruba 2019-07-22 22:05:24 +02:00 committed by GitHub
commit bba9d1570e
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 570 additions and 3 deletions

View File

@ -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"
}
}
}
}

View File

@ -1,3 +1,4 @@
services:
Rector\Symfony\Rector\BinaryOp\ResponseStatusCodeRector: ~
Rector\Symfony\Rector\Class_\MakeCommandLazyRector: ~
Rector\SymfonyCodeQuality\Rector\Class_\EventListenerToEventSubscriberRector: ~

View File

@ -0,0 +1,352 @@
<?php declare(strict_types=1);
namespace Rector\SymfonyCodeQuality\Rector\Class_;
use Nette\Utils\Strings;
use PhpParser\Node;
use PhpParser\Node\Expr\Array_;
use PhpParser\Node\Expr\ArrayItem;
use PhpParser\Node\Expr\ClassConstFetch;
use PhpParser\Node\Identifier;
use PhpParser\Node\Name\FullyQualified;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Return_;
use Rector\Bridge\Contract\AnalyzedApplicationContainerInterface;
use Rector\NodeTypeResolver\PhpDoc\NodeAnalyzer\DocBlockManipulator;
use Rector\Rector\AbstractRector;
use Rector\RectorDefinition\CodeSample;
use Rector\RectorDefinition\RectorDefinition;
use Symfony\Component\HttpKernel\Debug\TraceableEventDispatcher;
/**
* @see \Rector\SymfonyCodeQuality\Tests\Rector\Class_\EventListenerToEventSubscriberRector\EventListenerToEventSubscriberRectorTest
*/
final class EventListenerToEventSubscriberRector extends AbstractRector
{
/**
* @var string
*/
private const EVENT_SUBSCRIBER_INTERFACE = 'Symfony\Component\EventDispatcher\EventSubscriberInterface';
/**
* @var string
*/
private const KERNEL_EVENTS_CLASS = 'Symfony\Component\HttpKernel\KernelEvents';
/**
* @var string
*/
private const CONSOLE_EVENTS_CLASS = 'Symfony\Component\Console\ConsoleEvents';
/**
* @var string[][]
*/
private $eventNamesToClassConstants = [
// kernel events
'kernel.request' => [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'
<?php
class SomeListener
{
public function methodToBeCalled()
{
}
}
// in config.yaml
services:
SomeListener:
tags:
- { name: kernel.event_listener, event: 'some_event', method: 'methodToBeCalled' }
CODE_SAMPLE
,
<<<'CODE_SAMPLE'
<?php
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
class SomeEventSubscriber implements EventSubscriberInterface
{
/**
* @return string[]
*/
public static function getSubscribedEvents(): array
{
return ['some_event' => '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);
}
}

View File

@ -0,0 +1,31 @@
<?php declare(strict_types=1);
namespace Rector\SymfonyCodeQuality\Tests\Rector\Class_\EventListenerToEventSubscriberRector;
use Rector\Configuration\Option;
use Rector\SymfonyCodeQuality\Rector\Class_\EventListenerToEventSubscriberRector;
use Rector\SymfonyCodeQuality\Tests\Rector\Class_\EventListenerToEventSubscriberRector\Source\ListenersKernel;
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
use Symplify\PackageBuilder\Parameter\ParameterProvider;
final class EventListenerToEventSubscriberRectorTest extends AbstractRectorTestCase
{
protected function setUp(): void
{
parent::setUp();
$parameterProvider = self::$container->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;
}
}

View File

@ -0,0 +1,90 @@
<?php
namespace Rector\SymfonyCodeQuality\Tests\Rector\Class_\EventListenerToEventSubscriberRector\Fixture;
class SomeListener
{
public function methodToBeCalled()
{
}
}
class WithPriorityListener
{
public function callMe()
{
}
}
class MultipleMethods
{
public function callMe()
{
}
public function singles()
{
}
public function meToo()
{
}
}
?>
-----
<?php
namespace Rector\SymfonyCodeQuality\Tests\Rector\Class_\EventListenerToEventSubscriberRector\Fixture;
class SomeEventSubscriber implements \Symfony\Component\EventDispatcher\EventSubscriberInterface
{
public function methodToBeCalled()
{
}
/**
* @return mixed[]
*/
public static function getSubscribedEvents(): array
{
return ['some_event' => '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']];
}
}
?>

View File

@ -0,0 +1,88 @@
<?php declare(strict_types=1);
namespace Rector\SymfonyCodeQuality\Tests\Rector\Class_\EventListenerToEventSubscriberRector\Source;
use Rector\SymfonyCodeQuality\Tests\Rector\Class_\EventListenerToEventSubscriberRector\Fixture\MultipleCallsListener;
use Rector\SymfonyCodeQuality\Tests\Rector\Class_\EventListenerToEventSubscriberRector\Fixture\MultipleMethods;
use Rector\SymfonyCodeQuality\Tests\Rector\Class_\EventListenerToEventSubscriberRector\Fixture\SomeListener;
use Rector\SymfonyCodeQuality\Tests\Rector\Class_\EventListenerToEventSubscriberRector\Fixture\WithPriorityListener;
use Symfony\Component\Config\Loader\LoaderInterface;
use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Definition;
use Symfony\Component\DependencyInjection\Reference;
use Symfony\Component\EventDispatcher\EventDispatcher;
use Symfony\Component\HttpKernel\Kernel;
final class ListenersKernel extends Kernel
{
public function registerBundles(): iterable
{
return [];
}
public function registerContainerConfiguration(LoaderInterface $loader): void
{
}
protected function build(ContainerBuilder $containerBuilder): void
{
$eventDispatcherDefinition = $containerBuilder->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']]
);
}
}

View File

@ -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#'