Aller au contenu

Créer une règle Rector de rétrogradation personnalisée

Le pipeline de backport de NextPDF rétrograde le code source PHP 8.4 de nextpdf/nextpdf afin qu’il s’exécute sur PHP 8.1 (et, pour le cœur, sur PHP 7.4). Il s’appuie sur les jeux de rétrogradation intégrés de Rector pour la plupart des fonctionnalités du langage, ainsi que sur un petit ensemble de règles personnalisées pour celles que Rector ne couvre pas nativement.

Ce guide t’explique comment ajouter une nouvelle règle personnalisée. Il reprend la structure des trois règles déjà présentes dans le dépôt : DowngradeAsymmetricVisibilityRector, DowngradeCloneWithRector et DowngradeTraitConstantsRector. Toutes les trois vivent dans rector/rules/, sont enregistrées dans rector/config/ et disposent de tests basés sur des fixtures dans tests/Rector/.

Utilise ce guide lorsqu’une fonctionnalité de NextPDF emploie une syntaxe PHP que la cible de build ne prend pas en charge et pour laquelle Rector ne fournit aucune rétrogradation intégrée. Avant de commencer, confirme que Rector ne dispose réellement d’aucune règle. La chaîne intégrée withDowngradeSets() gère déjà les classes readonly, les constantes de classe typées, l’opérateur pipe et bien d’autres fonctionnalités.

Tu écris et exécutes les règles personnalisées dans le dépôt nextpdf-backport. Ses outils de développement sont déclarés dans composer.json.

  1. Clone le dépôt de backport, puis installe ses dépendances.
  2. Vérifie que Rector et PHPUnit se résolvent correctement.
Fenêtre de terminal
git clone https://github.com/nextpdf-labs/backport.git
cd backport
composer install
Fenêtre de terminal
vendor/bin/rector --version
vendor/bin/phpunit --version

Le dépôt exige PHP 8.4 pour s’exécuter (les règles manipulent des arbres syntaxiques 8.4), même si la sortie de build cible PHP 8.1 ou PHP 7.4. Le composer.jsonrequire épingle php à >=8.4 <9.0. Les règles personnalisées sont chargées automatiquement sous le namespace NextPDF\Backport\ mappé sur rector/rules/ ; les tests le sont sous NextPDF\Backport\Tests\ mappé sur tests/.

Chaque règle personnalisée étend Rector\Rector\AbstractRector et implémente trois méthodes. Le contrat est le même pour les trois règles existantes.

MembreTypeRôle
getRuleDefinition()RuleDefinitionDescription destinée aux humains, plus des paires before/after CodeSample pour le générateur de documentation des règles.
getNodeTypes()array<class-string<Node>>Les classes de nœuds de l’arbre syntaxique abstrait (AST) que la règle doit visiter. Rector n’appelle refactor() que pour celles-ci.
refactor(Node $node)?Node ou `Stmt[]null`

La déclaration du type de nœud conditionne tout. DowngradeAsymmetricVisibilityRector cible [Property::class, Param::class] parce que la visibilité asymétrique (public private(set)) peut apparaître aussi bien sur une propriété de classe que sur un paramètre de constructeur promu. DowngradeTraitConstantsRector cible [Trait_::class] parce qu’elle réécrit tout le corps du trait en une seule passe. DowngradeCloneWithRector cible [FileNode::class, Return_::class, Expression::class] parce que le clone-with (clone($obj, [...])) apparaît à la fois en position return et en position d’affectation ; elle utilise la visite de FileNode pour réinitialiser un compteur par fichier.

Une règle qui renvoie null depuis refactor() signale « aucun changement ». Une règle qui renvoie un nœud signale un remplacement. Une règle qui renvoie Stmt[] (une liste d’instructions) décompose une instruction en plusieurs. C’est ainsi que DowngradeCloneWithRector transforme un unique return clone($this, [...]); en une affectation de clone, une affectation de propriété par redéfinition, puis un return final.

Les deux configurations du pipeline appellent ->withDowngradeSets(php81: true) et ->withDowngradeSets(php74: true). Ces jeux appliquent toutes les règles de rétrogradation intégrées pour la cible. Les règles personnalisées ne servent qu’à combler les manques : la visibilité asymétrique de PHP 8.4, le clone-with de PHP 8.5 et les constantes de trait de PHP 8.2. Rector ne rétrograde aucune de ces fonctionnalités de lui-même. N’écris une règle personnalisée qu’après avoir confirmé le même manque.

Cette procédure ajoute une nouvelle règle. L’exemple suivi reprend la forme des règles existantes ; remplace-le par ta propre fonctionnalité.

  1. Crée la classe de règle dans rector/rules/. Nomme-la Downgrade<Feature>Rector et place-la dans le namespace NextPDF\Backport pour que le mappage d’autoload existant la prenne en compte.
  2. Étends Rector\Rector\AbstractRector et marque la classe final.
  3. Implémente getNodeTypes() pour renvoyer l’ensemble le plus restreint de classes de nœuds AST dont la règle a besoin. Un ensemble plus restreint réduit le nombre de nœuds visités par Rector.
  4. Implémente refactor(). Vérifie que le nœud correspond à l’un des types déclarés, assure-toi que la syntaxe correspond précisément à celle que tu cibles, transforme-le et renvoie le nouveau nœud (ou Stmt[], ou null si aucun changement n’est nécessaire).
  5. Implémente getRuleDefinition() avec une paire before/after CodeSample par cas distinct que la règle gère.
  6. Maintiens le fichier au niveau 10 de PHPStan : tous les paramètres, valeurs de retour et propriétés sont typés, et les génériques PHPDoc décrivent la forme des tableaux.

La règle de visibilité asymétrique constitue le plus petit exemple complet. Elle supprime les drapeaux de visibilité d’écriture et garantit qu’une visibilité de lecture de base subsiste :

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;
}
}

La condition de garde du premier if est la partie critique. Quand la propriété ne possède aucun drapeau de visibilité d’écriture, la règle renvoie null et laisse le nœud intact. Une règle qui transformerait le nœud sans condition réécrirait du code qu’elle ne devrait pas toucher.

Chaque règle possède un test basé sur des fixtures qui l’exécute sur des fichiers .php.inc et vérifie que la sortie correspond. L’infrastructure de test provient du Rector\Testing\PHPUnit\AbstractRectorTestCase de Rector.

Un cas de test est court et suit la même forme pour les trois règles existantes :

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';
}
}

Le test pointe vers un fichier de config par règle dans tests/Rector/config/ qui n’enregistre que la règle testée, afin qu’une fixture exerce une seule règle de manière isolée :

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,
]);

Une fixture est un fichier .php.inc avec l’entrée, un séparateur ----- et la sortie attendue. Quand la règle ne change rien, omets le séparateur et le second bloc. Une fixture de transformation pour la règle des constantes de trait ressemble à ceci :

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;
}
}
?>

Pour écrire les tests d’une nouvelle règle :

  1. Crée tests/Rector/Fixtures/Downgrade<Feature>/ et ajoute un .php.inc par cas.
  2. Couvre à la fois les cas avec transformation (avec un séparateur -----) et les cas ignorés (sans séparateur), par exemple une propriété sans visibilité d’écriture doit passer inchangée.
  3. Ajoute un tests/Rector/config/downgrade_<feature>.php qui n’enregistre que ta règle.
  4. Ajoute un tests/Rector/Downgrade<Feature>RectorTest.php qui produit le répertoire de fixtures et pointe vers la config.
  5. Lance la suite de tests.
Fenêtre de terminal
composer test

Le dépôt contient aussi RectorRulesBehaviorTest et RectorRulesMetadataTest, qui vérifient le comportement entre règles et la validité du getRuleDefinition() de chaque règle. Lance composer test en entier pour que ces garde-fous couvrent ta nouvelle règle.

Une règle n’est pas active dans la build tant qu’elle n’est pas enregistrée dans les configs du pipeline. Il existe deux cibles de build, chacune avec sa propre config dans rector/config/.

  1. Ouvre rector/config/rector-php81.php et ajoute ta classe de règle à la liste ->withRules([...]).
  2. Si la fonctionnalité doit aussi être rétrogradée pour la build du cœur en PHP 7.4, ajoute la même classe à rector/config/rector-php74.php.
  3. Ajoute un commentaire qui nomme la version de PHP ayant introduit la fonctionnalité, à l’image des entrées existantes.
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,
]);

L’orchestrateur de build (scripts/build.php) fusionne les dépôts source, exécute Rector avec ces configs, ajuste le composer.json généré, puis exécute une vérification de syntaxe php -l sur la sortie. Vérifie ta règle avec PHPStan et la build complète avant de t’y fier.

Fenêtre de terminal
composer analyse
composer build:dry
  • L’ordre d’enregistrement n’a pas d’importance, mais l’ordre des règles en a, conceptuellement. Le mécanisme multi-passes de Rector parcourt de nouveau l’arbre jusqu’à ce qu’aucune règle n’apporte de changement supplémentaire ; tu n’as donc pas à ordonner les règles à la main dans la config. Malgré cela, documente toute dépendance d’ordre dans le docblock de la classe, comme le fait DowngradeCloneWithRector : son expansion produit $clone->prop = $val, qui échouerait sur une propriété readonly, donc DowngradeReadonlyPropertyRector doit s’exécuter pour la même cible.
  • Fournis des attributs vides quand tu construis un nœud de remplacement à partir d’un autre type de nœud. DowngradeTraitConstantsRector construit une Property à partir d’un ClassConst et transmet [] pour les attributs au lieu des attributs du nœud source. Réutiliser les attributs d’origine laisserait un pointeur origNode vers le mauvais type de nœud et déclencherait une assertion dans l’imprimeur qui préserve le format.
  • Réinitialise l’état par fichier lors de la visite du FileNode. DowngradeCloneWithRector déclare FileNode::class dans getNodeTypes() uniquement pour réinitialiser son compteur de variables temporaires au début de chaque fichier, afin que les noms de variables générés n’entrent pas en collision d’un fichier à l’autre.
  • Filtre précisément, puis renvoie null. Une transformation clone-with doit vérifier que le nom de l’appel est clone et que le second argument est un littéral de tableau avant d’agir ; un simple clone $obj n’atteint jamais la règle en tant qu’appel de fonction, et un appel à deux arguments dont le second n’est pas un tableau reste tel quel.
  • Retire les modificateurs que la cible ne peut pas exprimer. Quand la règle des constantes de trait transforme une constante en propriété statique, elle conserve la visibilité et ajoute static, mais elle ne doit pas conserver le modificateur final, car les propriétés de PHP 8.1 ne peuvent pas être final.
  • Maintiens la règle au niveau 10 de PHPStan. Le dépôt exécute composer analyse au niveau 10 sur rector/rules et scripts. Type chaque signature et annote la forme des tableaux ; une règle qui ne passerait pas l’analyseur est un défaut, pas un brouillon.