Перейти к содержимому

Собственное правило понижения для Rector

Конвейер бэкпорта NextPDF понижает исходный код nextpdf/nextpdf с PHP 8.4 так, чтобы он работал на PHP 8.1, а для ядра — на PHP 7.4. Для большинства возможностей языка он использует встроенные наборы понижения Rector, а также небольшой набор собственных правил для возможностей, которые Rector не поддерживает.

В этом руководстве показано, как добавить новое собственное правило. Оно повторяет структуру трёх правил, которые уже есть в репозитории: DowngradeAsymmetricVisibilityRector, DowngradeCloneWithRector и DowngradeTraitConstantsRector. Все три находятся в rector/rules/, регистрируются в rector/config/ и имеют тесты на основе фикстур в tests/Rector/.

Используйте это руководство, когда возможность NextPDF использует синтаксис PHP, который не поддерживается целевой сборкой, а в Rector нет встроенного понижения для этой возможности. Прежде чем начать, убедитесь, что в Rector действительно нет нужного правила. Встроенная цепочка withDowngradeSets() уже обрабатывает readonly-классы, типизированные константы классов, оператор pipe и многие другие возможности.

Собственные правила пишутся и запускаются внутри репозитория nextpdf-backport. Инструменты разработки объявлены в его composer.json.

  1. Клонируйте репозиторий бэкпорта и установите зависимости.
  2. Убедитесь, что Rector и PHPUnit разрешаются.
Окно терминала
git clone https://github.com/nextpdf-labs/backport.git
cd backport
composer install
Окно терминала
vendor/bin/rector --version
vendor/bin/phpunit --version

Для работы репозиторию требуется PHP 8.4, потому что правила работают с синтаксическими деревьями 8.4, хотя результат сборки рассчитан на PHP 8.1 или 7.4. composer.jsonrequire закрепляет php на >=8.4 <9.0. Собственные правила автоматически загружаются в пространстве имён NextPDF\Backport\, сопоставленном с rector/rules/; тесты автоматически загружаются в NextPDF\Backport\Tests\, сопоставленном с tests/.

Каждое собственное правило наследует Rector\Rector\AbstractRector и реализует три метода. Контракт один и тот же для всех трёх существующих правил.

ЭлементТипНазначение
getRuleDefinition()RuleDefinitionЧитабельное описание и пары before/after CodeSample для генератора документации правил.
getNodeTypes()array<class-string<Node>>Классы узлов абстрактного синтаксического дерева (AST), которые посещает правило. Rector вызывает refactor() только для этих классов.
refactor(Node $node)?Node или `Stmt[]null`

Объявление типов узлов управляет правилом. DowngradeAsymmetricVisibilityRector нацелено на [Property::class, Param::class], потому что асимметричная видимость (public private(set)) может встречаться и в свойстве класса, и в продвигаемом параметре конструктора. DowngradeTraitConstantsRector нацелено на [Trait_::class], потому что переписывает всё тело трейта за один проход. DowngradeCloneWithRector нацелено на [FileNode::class, Return_::class, Expression::class], потому что clone-with (clone($obj, [...])) может встречаться как в позиции return, так и в присваивании; посещение FileNode используется для сброса счётчика в пределах файла.

Правило, возвращающее null из refactor(), сигнализирует “без изменений”. Правило, возвращающее узел, сигнализирует о замене. Правило, возвращающее Stmt[] — список операторов, — разворачивает один оператор в несколько. Именно так DowngradeCloneWithRector превращает один return clone($this, [...]); в присваивание клона, по одному присваиванию свойства на каждое переопределение и итоговый return.

Две конфигурации конвейера вызывают ->withDowngradeSets(php81: true) и ->withDowngradeSets(php74: true). Эти наборы выстраивают в цепочку все встроенные правила понижения для целевой версии. Собственные правила нужны только для пробелов: асимметричная видимость PHP 8.4, clone-with PHP 8.5 и константы трейтов PHP 8.2. Ни одну из этих возможностей Rector не понижает самостоятельно. Пишите собственное правило только после того, как подтвердите такой же пробел.

Эта процедура добавляет новое правило. Сквозной пример повторяет существующие правила; подставьте свою возможность.

  1. Создайте класс правила в rector/rules/. Назовите его Downgrade<Feature>Rector и поместите в пространство имён NextPDF\Backport, чтобы его подхватило существующее сопоставление автозагрузки.
  2. Унаследуйте Rector\Rector\AbstractRector и отметьте класс как final.
  3. Реализуйте getNodeTypes() так, чтобы он возвращал самый узкий набор классов узлов AST, который нужен правилу. Чем уже набор, тем меньше узлов посещает Rector.
  4. Реализуйте refactor(). Убедитесь, что узел относится к одному из объявленных типов, проверьте точный целевой синтаксис, преобразуйте узел и верните новый узел, Stmt[] или null, если изменений нет.
  5. Реализуйте getRuleDefinition() с одной парой before/after CodeSample для каждого отдельного случая, который обрабатывает правило.
  6. Поддерживайте файл на уровне PHPStan Level 10: типизируйте каждый параметр, возвращаемое значение и свойство, а для описания форм массивов используйте дженерики PHPDoc.

Правило для asymmetric-visibility — самый компактный полный пример. Оно удаляет флаги видимости записи и следит за тем, чтобы базовая видимость чтения сохранялась:

rector/rules/DowngradeAsymmetricVisibilityRector.php
<?php
declare(strict_types=1);
namespace NextPDF\Backport;
use PhpParser\Modifiers;
use PhpParser\Node;
use PhpParser\Node\Param;
use PhpParser\Node\Stmt\Property;
use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
final class DowngradeAsymmetricVisibilityRector extends AbstractRector
{
public function getRuleDefinition(): RuleDefinition
{
return new RuleDefinition(
'Remove asymmetric visibility modifiers (public private(set) -> public)',
[
new CodeSample(
'public private(set) float $x = 0.0;',
'public float $x = 0.0;',
),
],
);
}
/**
* @return array<class-string<Node>>
*/
public function getNodeTypes(): array
{
return [Property::class, Param::class];
}
public function refactor(Node $node): ?Node
{
\assert($node instanceof Property || $node instanceof Param);
if (($node->flags & Modifiers::VISIBILITY_SET_MASK) === 0) {
return null;
}
$node->flags &= ~Modifiers::VISIBILITY_SET_MASK;
if (($node->flags & Modifiers::VISIBILITY_MASK) === 0) {
$node->flags |= Modifiers::PUBLIC;
}
return $node;
}
}

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

У каждого правила есть тест на основе фикстур: он прогоняет правило по файлам .php.inc и проверяет совпадение результата. Тестовая обвязка берётся из Rector\Testing\PHPUnit\AbstractRectorTestCase в Rector.

Тестовый класс невелик и одинаков для всех трёх существующих правил:

tests/Rector/DowngradeTraitConstantsRectorTest.php
<?php
declare(strict_types=1);
namespace NextPDF\Backport\Tests\Rector;
use Iterator;
use PHPUnit\Framework\Attributes\DataProvider;
use Rector\Testing\PHPUnit\AbstractRectorTestCase;
final class DowngradeTraitConstantsRectorTest extends AbstractRectorTestCase
{
#[DataProvider('provideData')]
public function test(string $filePath): void
{
$this->doTestFile($filePath);
}
public static function provideData(): Iterator
{
return self::yieldFilesFromDirectory(__DIR__ . '/Fixtures/DowngradeTraitConstants');
}
public function provideConfigFilePath(): string
{
return __DIR__ . '/config/downgrade_trait_constants.php';
}
}

Тест указывает на отдельный для каждого правила файл конфигурации в tests/Rector/config/. Он регистрирует только проверяемое правило, поэтому каждая фикстура задействует одно правило изолированно:

tests/Rector/config/downgrade_trait_constants.php
<?php
declare(strict_types=1);
use NextPDF\Backport\DowngradeTraitConstantsRector;
use Rector\Config\RectorConfig;
return RectorConfig::configure()
->withRules([
DowngradeTraitConstantsRector::class,
]);

Фикстура — это файл .php.inc с входными данными, разделителем ----- и ожидаемым результатом. Если правило не вносит изменений, опустите разделитель и второй блок. Преобразующая фикстура для правила trait-constants выглядит так:

tests/Rector/Fixtures/DowngradeTraitConstants/private_constant.php.inc
<?php
trait HasLimit
{
private const MAX_SIZE = 1024;
public function getLimit(): int
{
return self::MAX_SIZE;
}
}
?>
-----
<?php
trait HasLimit
{
private static $MAX_SIZE = 1024;
public function getLimit(): int
{
return self::$MAX_SIZE;
}
}
?>

Чтобы написать тесты для нового правила:

  1. Создайте tests/Rector/Fixtures/Downgrade<Feature>/ и добавьте по одному .php.inc на каждый случай.
  2. Охватите как преобразующие случаи с разделителем -----, так и пропускаемые случаи без разделителя — например, свойство без видимости записи, которое должно остаться без изменений.
  3. Добавьте tests/Rector/config/downgrade_<feature>.php, который регистрирует только ваше правило.
  4. Добавьте tests/Rector/Downgrade<Feature>RectorTest.php, который отдаёт каталог фикстур и указывает на конфигурацию.
  5. Запустите набор тестов.
Окно терминала
composer test

Репозиторий также включает RectorRulesBehaviorTest и RectorRulesMetadataTest. Они проверяют взаимодействие правил и подтверждают, что getRuleDefinition() каждого правила сформирован правильно. Запустите полный composer test, чтобы эти проверки увидели ваше новое правило.

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

  1. Откройте rector/config/rector-php81.php и добавьте класс своего правила в список ->withRules([...]).
  2. Если возможность нужно понижать и для сборки ядра под PHP 7.4, добавьте тот же класс в rector/config/rector-php74.php.
  3. Добавьте комментарий с указанием версии PHP, в которой появилась эта возможность, по образцу существующих записей.
rector/config/rector-php81.php
<?php
declare(strict_types=1);
use NextPDF\Backport\DowngradeAsymmetricVisibilityRector;
use NextPDF\Backport\DowngradeCloneWithRector;
use NextPDF\Backport\DowngradeTraitConstantsRector;
use Rector\Config\RectorConfig;
use Rector\DowngradePhp81\Rector\Property\DowngradeReadonlyPropertyRector;
return RectorConfig::configure()
->withDowngradeSets(php81: true)
->withRules([
// PHP 8.4 — asymmetric visibility (not covered by built-in sets)
DowngradeAsymmetricVisibilityRector::class,
// PHP 8.4 — clone-with syntax (not covered by built-in sets)
DowngradeCloneWithRector::class,
// PHP 8.2 — trait constants (not covered by built-in sets)
DowngradeTraitConstantsRector::class,
// PHP 8.1 — readonly property removal (for clone-with expansion safety)
DowngradeReadonlyPropertyRector::class,
]);

Оркестратор сборки (scripts/build.php) объединяет исходные репозитории, запускает Rector с этими конфигурациями, корректирует сгенерированный composer.json и проверяет синтаксис результата через php -l. Проверьте своё правило с помощью PHPStan и полной сборки, прежде чем полагаться на него.

Окно терминала
composer analyse
composer build:dry
  • Порядок регистрации не имеет значения, но концептуально порядок правил важен. Многопроходный механизм Rector повторяет обход, пока ни одно правило не вносит очередное изменение, поэтому вручную упорядочивать правила в конфигурации не нужно. Тем не менее задокументируйте любую зависимость от порядка в докблоке класса, как это делает DowngradeCloneWithRector: его развёртывание порождает $clone->prop = $val, что завершилось бы ошибкой на readonly-свойстве, поэтому DowngradeReadonlyPropertyRector должно выполняться для той же целевой версии.
  • Передавайте пустые атрибуты, когда строите узел-замену из узла другого вида. DowngradeTraitConstantsRector строит Property из ClassConst и передаёт [] в качестве атрибутов вместо атрибутов исходного узла. Если перенести исходные атрибуты, вы оставите указатель origNode на узел неправильного вида и нарушите assert в принтере, сохраняющем форматирование.
  • Сбрасывайте состояние уровня файла при посещении FileNode. DowngradeCloneWithRector объявляет FileNode::class в getNodeTypes() только для сброса счётчика временных переменных в начале каждого файла, чтобы сгенерированные имена переменных не конфликтовали между файлами.
  • Проверяйте точно, затем возвращайте null. Перед выполнением преобразование clone-with должно подтвердить, что имя вызова — clone и что второй аргумент — литерал массива; обычный clone $obj вообще не доходит до правила как вызов функции, а вызов с двумя аргументами, второй из которых не массив, остаётся нетронутым.
  • Убирайте модификаторы, которые целевая версия не может выразить. Когда правило trait-constants превращает константу в статическое свойство, оно сохраняет видимость и добавляет static, но не должно переносить модификатор final, потому что свойства в PHP 8.1 не могут быть final.
  • Поддерживайте правило на уровне PHPStan Level 10. Репозиторий запускает composer analyse на уровне 10 по rector/rules и scripts. Типизируйте каждую сигнатуру и аннотируйте формы массивов; правило, которое не прошло бы анализатор, — дефект, а не черновик.