Что умеет Rector: пишем кастомные правила для автоматизации рефакторинга PHP-проектов

от автора

Привет, Хабр! Меня зовут Сережа Сахаров, я PHP-разработчик в Lamoda Tech. Одной из первых задач в компании для меня стал рефакторинг крупной части кодовой базы. Я знал о существовании инструмента для выполнения автоматизированного рефакторинга Rector, но ранее не доводилось применять его на практике. 

В процессе я получил интересный опыт, который выходит за рамки шаблонного применения инструмента — например, добавил еще несколько кастомных правил, разобрался во внутреннем устройстве и механизмах работы, и хочу поделиться этим с PHP-сообществом. Если вам часто приходится сталкиваться с рефакторингом старых PHP-проектов, при этом потребности выходят за пределы штатного набора правил Rector, эта статья для вас.

Принцип работы Rector

Rector — это инструмент для автоматизации рефакторинга PHP-проектов, основанный на статическом анализаторе PHPStan. Принцип работы заключается в том, что Rector на основании типов, используемых в коде, выполняет заранее определенную последовательность действий. Она описана в специальных классах, называемых правилами, которые в процессе работы инструмента применяются ко всему коду.

Для более детального погружения в тему я прочитал книгу «Rector — The Power of Automated Refactoring». В ней инструмент рассматривается в контексте философии Unix way, который подразумевает, что мы можем объединять существующие инструменты для решения новых задач. Согласно этому подходу, Rector входит в две из четырех категорий PHP-инструментов, которые помогают следить за кодом:

Категория

Инструменты

Стандарты кодирования

PHP CS Fixer, PHP_CodeSniffer, EasyCodingStandard

Статические анализаторы

PHP-Parser, PHPStan, Psalm

Upgrade

Rector, Symfony-Upgrade-Fixer

Downgrade

Rector, PHP-Backporter, Galapagos, Transphpilea, 7to5

Контекст

У нас на проекте есть внешний пакет paillechat/php-enum, который реализует функциональность перечислений (enum). Ее не было в языке до версии 8.1, и после перехода проекта на эту версию мне поставили задачу реализовать перечисления нативно. Вспомнив про Rector и выполняя подготовительные задачи, я понял, что этот инструмент вполне подойдет для моей работы.

Rector поставляется с набором типовых правил, которые можно применить к проекту. По состоянию на март 2025 года их число достигло 640, но не все они могут подойти вам. Однако это не проблема, потому что Rector не ограничивается штатными правилами, и его можно использовать более широко, подгоняя под свои кейсы и задачи.

В моем случае типовое использование не подходило, а все доступные материалы по разработке правил сводились к тому, что дублировали документацию. Поэтому я принял решение написать свое правило.

Перед этим я исследовал текущую кодовую базу Rector и существующие правила по переходу с использования библиотек для реализации функциональности Enum, таких как spatie/enum (SpatieEnumClassToEnumRector) и myclabs/php-enum (MyCLabsClassToEnumRector). Стало понятно, что текущей функциональности мне не хватает — не только в плане правил, но и в плане фабрики для генерации перечислений (EnumFactory), чтобы подменить Enum библиотеки на нативную реализацию. Ее проблема заключается в том, что она может генерировать только типизированные перечисления (BackedEnum), но нам нужны обычные перечисления (UnitEnum).

Помимо того, что нужно было написать новую фабрику для генерации обычных перечислений, требовалось понять, как использовать ее в своих правилах.

Начало работы

В результате анализа задачи я пришел к выводу, что для рефакторинга необходимо выполнить три действия:

  1. Заменить использование класса Enum библиотеки paillechat/php-enum на нативный класс Enum.

  2. Заменить методы библиотеки на методы нативных перечислений. 

  3. Поправить использование приведения типов. 

На основе этих действий я создал следующие правила:

  1. PaillechatEnumToUnitEnumRector

  2. PaillechatEnumMethodCallToEnumConstRector

  3. RecastingPaillechatEnumMethodCallToEnumCaseMethodCallRector

Процесс создания правил для Rector является универсальным, поэтому я не буду разбирать каждое в отдельности, а покажу процесс на основе PaillechatEnumToUnitEnumRector. Вы можете применить эти этапы к любому другому правилу.  

Я работал с версией 0.14.5. Она не самая новая, но другую использовать не удалось из-за ограничений версий проекта и библиотек. С полным кодом вы можете ознакомиться здесь.

1. Создаем свое правило

Для создания правила необходимо выполнить три простых шага:

  1. Описать правила — объяснить для пользователя работу правила и привести примеры, как было и как стало.

  2. Определить цели рефакторинга — указать, на какие конкретно сущности в нашем коде нужно смотреть, например, на метод в коде или класс.

  3. Описать логику и процесс рефакторинга.

Шаг 1

Добавляем короткое описание для правила PaillechatEnumToUnitEnumRector. Помимо этого, необходимо привести примеры кода до и после применения правила.

public function getRuleDefinition(): RuleDefinition {     return new RuleDefinition(         'Refactor Paillechat enum class to native Enum',         [new CodeSample( <<<'CODE_SAMPLE' use \Paillechat\Enum\Enum;  /** * @method static self DRAFT() * @method static self PUBLISHED() * @method static self ARCHIVED() */ class StatusEnum extends Enum {    private const DRAFT = 'draft';    private const PUBLISHED = 'published';    private const ARCHIVED = 'archived'; } CODE_SAMPLE            , <<<'CODE_SAMPLE' enum StatusEnum {    case DRAFT;    case PUBLISHED;    case ARCHIVED; } CODE_SAMPLE     )]); }

Шаг 2

Указываем, на какие конкретно ноды в нашем коде нужно смотреть — например, на Class_::class. Полный список нод можно найти здесь. Данный метод может возвращать не только конкретную ноду, но и их список, если есть потребность в изменении сразу нескольких нод.

public function getNodeTypes(): array {     return [Class_::class]; }

Шаг 3

Ключевой шаг — описываем логику по рефакторингу, выполняем необходимые проверки и вносим изменения в абстрактное синтаксическое дерево, которое представляет структуру приложения в виде дерева объявлений, инструкций и выражений. В данном случае мы проверяем тип текущей ноды на соответствие классу Enum библиотеки paillechat/php-enum. Если это тот класс, который мы хотим заменить, то заменяем его ноду на новую, сгенерированную для использования нативного перечисления. Это реализуется с помощью фабрики CustomEnumFactory. О том, как ее создать и подключить, я описал в отдельном разделе.

public function refactor(Node $node) {     if (!$this->isObjectType($node, new ObjectType((string) $this->enumToRefactor)))     {       return null;     }      return $this->enumFactory->createFromClass($node); }

2. Создаем конфигурационный файл и прописываем правила

Для того, чтобы эти правила работали, их нужно описать в конфигурационном файле. Создадим пустой файл с помощью команды vendor/bin/rector init и опишем в нем правила, которые необходимо запустить. Через метод paths() мы задаем список директорий, в которых Rector будет проводить рефакторинг, и отдельно, через метод skip() указываем те, которые стоит пропустить. 

Важно отметить, что в используемой версии Rector применяется DI-контейнер Symfony, который позволяет управлять инъекцией зависимостей. Для этого добавляем в контейнер свою фабрику, чтобы ее можно было использовать в любых кастомных правилах.

С помощью метода ruleWithConfiguration мы можем сконфигурировать правила, которые Rector применит к коду, а с помощью метода parallel — указать, что обработка должна проходить в параллельном режиме.

return static function (RectorConfig $rectorConfig): void {     $rectorConfig->paths([         __DIR__ . '/src',         __DIR__ . '/tests',     ]);      $rectorConfig->skip([         __DIR__ . '/**/_generated/*',     ]);          $services = $rectorConfig->services();     $services->set(CustomEnumFactory::class)->autowire();      $rectorConfig->symfonyContainerXml(__DIR__ . '/var/cache/test/Service_KernelTestDebugContainer.xml');          $enumToRefactor = PaillechatEnumFqcnValueObject::fromString(ServiceName::class);     $rectorConfig->ruleWithConfiguration(PaillechatEnumToUnitEnumRector::class, [$enumToRefactor]);     $rectorConfig->ruleWithConfiguration(PaillechatEnumMethodCallToEnumConstRector::class, [$enumToRefactor]);     $rectorConfig->ruleWithConfiguration(RecastingPaillechatEnumMethodCallToEnumCaseMethodCallRector::class, [$enumToRefactor]);          $rectorConfig->parallel(seconds: 360); };

3. Смотрим на итоговый результат

В результате были созданы три правила, фабрика для генерации простых перечислений, конфигурационный файл, а также вспомогательный файл для передачи FQCN целевых классов в наши правила.

  1. PaillechatEnumToUnitEnumRector — правило для преобразования классов библиотеки Paillechat в нативные типы Enum. Оно заменяет использование класса библиотеки на использование класса самого языка, реализуя нативность.

  2. Класс PaillechatEnumMethodCallToEnumConstRector — для преобразования вызова методов библиотеки в методы нативного типа Enum.

  3. RecastingPaillechatEnumMethodCallToEnumCaseMethodCallRector — заменяет приведение к типу на обращение к свойству перечисления  name.

  4. Класс CustomEnumFactory — фабрика для создания Unit Enum.

  5. Конфигурационный файл.

  6. Класс PaillechatEnumFqcnValueObject — вспомогательный класс для передачи FQCN целевых классов в наши правила.

С полным репозиторием вы можете ознакомиться на GitHub

4. Производим отладку правил

Процесс отладки происходит в три шага:

1. Проверяем корректность работы правил с помощью команды:

vendor/bin/rector process --dry-run

Опция dry-run здесь используется, чтобы показать изменения, которые вносит Rector без изменения кода.

2. После проверки корректности внесенных изменений повторно выполняем команду, но без опции dry-run:

vendor/bin/rector process

3. Запускаем автотесты всего проекта, чтобы проверить, что измененный код работает корректно.

Неожиданные возможности Rector

Отладку я проводил в несколько итераций, и после первой увидел, что инструмент отработал даже лучше, чем я ожидал! Rector поправил тот код, который сложно заметить при ручной проверке, и нашел проблему, которую можно было обнаружить лишь на этапе тестирования.

Но запуск автотестов выявил пограничный случай, который не был учтен в написанных правилах — в реализации библиотеки для получения имени перечисления использовался магический метод __toString, и очень часто в коде применялось приведение типа к строке для получения имени перечисления. Когда Rector применял правила, он не обращал внимание на приведение типа, и так как магические методы для перечислений запрещены, то такой код падал в ошибку:

До применения правил

(string) Enum::ONE();

После применения правил, невалидный код

(string) Enum::ONE;

После применения нового правила, валидный код

Enum::ONE->name;

Также мы столкнулись с человеческим фактором. Я не указал отдельную директорию, в которой Rector должен применить изменения, из-за чего автотесты упали. Без автотестов на проекте пришлось бы вручную проверять каждый кейс, что потребовало бы дополнительных ресурсов времени. 

Поэтому делаем вывод, что Rector стоит использовать только на проекте, полностью покрытом автотестами, особенно если у проекта большая кодовая база.

В итоге весь рефакторинг занял около 20-30 минут. Если бы я это делал вручную, то потратил бы как минимум один рабочий день.

Теперь, когда мы рассмотрели схему создания правила, вернемся к фабрике CustomEnumFactory.

Фабрика для генерации нетипизированных перечислений

Как я уже писал, стандартная фабрика для генерации Enum мне не подходила, так как она генерирует типизированные перечисления (BackedEnum’s). А в силу ограничений и для наших нужд было достаточно использовать нетипизированные перечисления (UnitEnum).

Я посмотрел, как реализована существующая фабрика для создания типизированных перечислений, и на ее примере реализовал свою для нетипизированных. Сравним два варианта:

Существующий вариант фабрики для типизированных перечислений, используемый в правилах Rector
<?php  declare (strict_types=1); namespace Rector\Php81\NodeFactory;  use RectorPrefix202502\Nette\Utils\Strings; use PhpParser\BuilderFactory; use PhpParser\Node\ArrayItem; use PhpParser\Node\Expr\Array_; use PhpParser\Node\Identifier; use PhpParser\Node\Scalar\Int_; use PhpParser\Node\Scalar\String_; use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassConst; use PhpParser\Node\Stmt\ClassMethod; use PhpParser\Node\Stmt\Enum_; use PhpParser\Node\Stmt\EnumCase; use PHPStan\PhpDocParser\Ast\PhpDoc\MethodTagValueNode; use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode; use Rector\BetterPhpDocParser\PhpDocInfo\PhpDocInfoFactory; use Rector\NodeNameResolver\NodeNameResolver; use Rector\NodeTypeResolver\Node\AttributeKey; use Rector\PhpParser\Node\BetterNodeFinder; use Rector\PhpParser\Node\Value\ValueResolver;  final class EnumFactory {     /**      * @readonly      */     private NodeNameResolver $nodeNameResolver;        /**      * @readonly      */     private PhpDocInfoFactory $phpDocInfoFactory;        /**      * @readonly      */     private BuilderFactory $builderFactory;        /**      * @readonly      */     private ValueResolver $valueResolver;      /**      * @readonly      */     private BetterNodeFinder $betterNodeFinder;      /**      * @var string      * @see https://stackoverflow.com/a/2560017      * @see https://regex101.com/r/2xEQVj/1 for changing iso9001 to iso_9001      * @see https://regex101.com/r/Ykm6ub/1 for changing XMLParser to XML_Parser      * @see https://regex101.com/r/Zv4JhD/1 for changing needsReview to needs_Review      */     private const PASCAL_CASE_TO_UNDERSCORE_REGEX = '/(?<=[A-Z])(?=[A-Z][a-z])|(?<=[^A-Z])(?=[A-Z])|(?<=[A-Za-z])(?=[^A-Za-z])/';     /**      * @var string      * @see https://regex101.com/r/FneU33/1      */     private const MULTI_UNDERSCORES_REGEX = '#_{2,}#';        public function __construct(NodeNameResolver $nodeNameResolver, PhpDocInfoFactory $phpDocInfoFactory, BuilderFactory $builderFactory, ValueResolver $valueResolver, BetterNodeFinder $betterNodeFinder)     {         $this->nodeNameResolver = $nodeNameResolver;         $this->phpDocInfoFactory = $phpDocInfoFactory;         $this->builderFactory = $builderFactory;         $this->valueResolver = $valueResolver;         $this->betterNodeFinder = $betterNodeFinder;     }        public function createFromClass(Class_ $class) : Enum_     {         $shortClassName = $this->nodeNameResolver->getShortName($class);         $enum = new Enum_($shortClassName, [], ['startLine' => $class->getStartLine(), 'endLine' => $class->getEndLine()]);         $enum->namespacedName = $class->namespacedName;         $constants = $class->getConstants();         $enum->stmts = $class->getTraitUses();         if ($constants !== []) {             $value = $this->valueResolver->getValue($constants[0]->consts[0]->value);             $enum->scalarType = \is_string($value) ? new Identifier('string') : new Identifier('int');             // constant to cases             foreach ($constants as $constant) {                 $enum->stmts[] = $this->createEnumCaseFromConst($constant);             }         }         $enum->stmts = \array_merge($enum->stmts, $class->getMethods());         return $enum;     }        public function createFromSpatieClass(Class_ $class, bool $enumNameInSnakeCase = \false) : Enum_     {         $shortClassName = $this->nodeNameResolver->getShortName($class);         $enum = new Enum_($shortClassName, [], ['startLine' => $class->getStartLine(), 'endLine' => $class->getEndLine()]);         $enum->namespacedName = $class->namespacedName;         // constant to cases         $phpDocInfo = $this->phpDocInfoFactory->createFromNodeOrEmpty($class);         $docBlockMethods = $phpDocInfo->getTagsByName('@method');         if ($docBlockMethods !== []) {             $mapping = $this->generateMappingFromClass($class);             $identifierType = $this->getIdentifierTypeFromMappings($mapping);             $enum->scalarType = new Identifier($identifierType);             foreach ($docBlockMethods as $docBlockMethod) {                 $enum->stmts[] = $this->createEnumCaseFromDocComment($docBlockMethod, $class, $mapping, $enumNameInSnakeCase);             }         }         return $enum;     }        private function createEnumCaseFromConst(ClassConst $classConst) : EnumCase     {         $constConst = $classConst->consts[0];         $enumCase = new EnumCase($constConst->name, $constConst->value, [], ['startLine' => $constConst->getStartLine(), 'endLine' => $constConst->getEndLine()]);         // mirror comments         $enumCase->setAttribute(AttributeKey::PHP_DOC_INFO, $classConst->getAttribute(AttributeKey::PHP_DOC_INFO));         $enumCase->setAttribute(AttributeKey::COMMENTS, $classConst->getAttribute(AttributeKey::COMMENTS));         return $enumCase;     }        /**      * @param array<int|string, mixed> $mapping      */     private function createEnumCaseFromDocComment(PhpDocTagNode $phpDocTagNode, Class_ $class, array $mapping = [], bool $enumNameInSnakeCase = \false) : EnumCase     {         /** @var MethodTagValueNode $nodeValue */         $nodeValue = $phpDocTagNode->value;         $enumValue = $mapping[$nodeValue->methodName] ?? $nodeValue->methodName;         if ($enumNameInSnakeCase) {             $enumName = \strtoupper(Strings::replace($nodeValue->methodName, self::PASCAL_CASE_TO_UNDERSCORE_REGEX, '_$0'));             $enumName = Strings::replace($enumName, self::MULTI_UNDERSCORES_REGEX, '_');         } else {             $enumName = \strtoupper($nodeValue->methodName);         }         $enumExpr = $this->builderFactory->val($enumValue);         return new EnumCase($enumName, $enumExpr, [], ['startLine' => $class->getStartLine(), 'endLine' => $class->getEndLine()]);     }        /**      * @return array<int|string, mixed>      */     private function generateMappingFromClass(Class_ $class) : array     {         $classMethod = $class->getMethod('values');         if (!$classMethod instanceof ClassMethod) {             return [];         }         $returns = $this->betterNodeFinder->findReturnsScoped($classMethod);         /** @var array<int|string, mixed> $mapping */         $mapping = [];         foreach ($returns as $return) {             if (!$return->expr instanceof Array_) {                 continue;             }             $mapping = $this->collectMappings($return->expr->items, $mapping);         }         return $mapping;     }        /**      * @param null[]|ArrayItem[] $items      * @param array<int|string, mixed> $mapping      * @return array<int|string, mixed>      */     private function collectMappings(array $items, array $mapping) : array     {         foreach ($items as $item) {             if (!$item instanceof ArrayItem) {                 continue;             }             if (!$item->key instanceof Int_ && !$item->key instanceof String_) {                 continue;             }             if (!$item->value instanceof Int_ && !$item->value instanceof String_) {                 continue;             }             $mapping[$item->key->value] = $item->value->value;         }         return $mapping;     }        /**      * @param array<int|string, mixed> $mapping      */     private function getIdentifierTypeFromMappings(array $mapping) : string     {         $callableGetType = static fn($value): string => \gettype($value);         $valueTypes = \array_map($callableGetType, $mapping);         $uniqueValueTypes = \array_unique($valueTypes);         if (\count($uniqueValueTypes) === 1) {             $identifierType = \reset($uniqueValueTypes);             if ($identifierType === 'integer') {                 $identifierType = 'int';             }         } else {             $identifierType = 'string';         }          return $identifierType;     } }

Также можно посмотреть на GitHub.

Мой вариант фабрики для нетипизированных перечислений
<?php  declare(strict_types=1);  namespace Utils\Rector\Rector;  use PhpParser\Node\Stmt\Class_; use PhpParser\Node\Stmt\ClassConst; use PhpParser\Node\Stmt\Enum_; use PhpParser\Node\Stmt\EnumCase; use Rector\NodeNameResolver\NodeNameResolver; use Rector\NodeTypeResolver\Node\AttributeKey;  final class CustomEnumFactory {    public function __construct(        private readonly NodeNameResolver $nodeNameResolver    ) {    }     public function createFromClass(Class_ $class): Enum_    {        $shortClassName = $this->nodeNameResolver->getShortName($class);        $enum = new Enum_($shortClassName, [], ['startLine' => $class->getStartLine(), 'endLine' => $class->getEndLine()]);        $enum->namespacedName = $class->namespacedName;        $constants = $class->getConstants();        $enum->stmts = $class->getTraitUses();         if ($constants !== []) {            foreach ($constants as $constant) {                $enum->stmts[] = $this->createEnumCaseFromConst($constant);            }        }         $enum->stmts = \array_merge($enum->stmts, $class->getMethods());         return $enum;    }     private function createEnumCaseFromConst(ClassConst $classConst): EnumCase    {        $constConst = $classConst->consts[0];        $enumCase = new EnumCase($constConst->name, null, [], ['startLine' => $constConst->getStartLine(), 'endLine' => $constConst->getEndLine()]);         $enumCase->setAttribute(AttributeKey::PHP_DOC_INFO, $classConst->getAttribute(AttributeKey::PHP_DOC_INFO));        $enumCase->setAttribute(AttributeKey::COMMENTS, $classConst->getAttribute(AttributeKey::COMMENTS));         return $enumCase;    } }

Также можно посмотреть на GitHub.

Бонус: пример разработки правил через тестирование 

Есть два варианта написания правил — когда мы их пишем, проверяя работу сразу на проекте, и когда мы следуем канонам TDD, разрабатывая правила через написание тестов.

Изначально для реализации задачи я выбрал первый путь, потому что он менее затратен по времени. Так получились более универсальные правила, которые подходят для применения в любом проекте, и было принято решение покрыть их тестами. Ознакомиться с ними вы можете на GitHub

Выводы

Реализуя свою задачу, я убедился в том, что Rector отлично справляется с тем, чтобы автоматизировать рутинные операции для рефакторинга кода на PHP. Rector пройдется по всему коду, автоматически применит правила, и на выходе вы получите код совершенно иного уровня качества, с которым гораздо приятнее работать. Без него вам пришлось бы самостоятельно анализировать каждую строчку кода, определять необходимость изменений и вручную вносить их. При этом с ростом объема изменяемого кода растет и риск внесения ошибок. С Rector задача становится в разы проще — он не пропустит случайно какую-то переменную, потому что устал или не выпил с утра кофе.

Вот выводы, к которым я пришел за время использования Rector:

1. Правила работают настолько хорошо, насколько хорошо вы их написали. 

Важно учесть все пограничные случаи, варианты использования в коде. Если вы что-то не учли, Rector не предугадает это, и велика вероятность, что в процессе рефакторинга пропустит важные участки кода.

2. Rector выполняет ровно то, что вы ему указываете.

И выполняет очень рьяно, поэтому что-то может работать неправильно после его преобразований. 

3. Rector не предназначен для правки стиля кода.

Я встречал мнение, что есть правила, которые вредно применять, потому что Rector вносит изменения, которые ломают некоторые зависимости. Инструмент не всегда понимает те кейсы использования, в которых этот код применяется. Как пример, преследуя задачу угодить пользователю, Rector может сломать доктрину.

Как мы разбирали в самом начале, Rector лишь один из инструментов, который помогает следить за кодом, и его цель — рефакторинг. Он не предназначен для поддержания код стайла, когда вам нужно отформатировать код и привести к нужному виду каждый раз после какого-то изменения или новой фичи.

Rector подойдет, если ваш проект давно не обновлялся, или вы хотите перейти новую версию фреймворка, и нужно поднять код до его уровня. В ежедневном применении смысла мало.

4. Rector подойдет для проектов, покрытых автотестами.

Так как инструмент выполняет преобразования кода в автоматическом режиме, могут возникнуть проблемы, например, при применении правила ReadOnlyPropertyRector на проекте с библиотекой Doctrine, когда установка readonly property для сущностей ломает работу приложения. В целом, это можно заметить при ручном тестировании. Но это дорогое удовольствие, к тому всегда присутствует человеческий фактор, так что лучше заранее покрыть код тестами. 

Полезные материалы

А вы использовали Rector? Поделитесь своим опытом в комментариях.


ссылка на оригинал статьи https://habr.com/ru/articles/892160/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *