10min.

Comment écrire une règle Rector

Afin de mettre à jour Symfony sur l’application d’un client, nous avons dû corriger quelques usages de Doctrine au préalable. Il y a quelques années de cela, il était commun d’écrire ce genre de code dans un contrôleur :

$order = $this->getDoctrine()->getRepository('App:Order')->find($id);

La syntaxe <Namespace>:<EntityName> étant dépréciée, il faut mettre à jour en utilisant la syntaxe FQCN.

use App\Entity\Order;

$order = $this->getDoctrine()->getRepository(Order::class)->find($id);

A travers cet article, nous verrons comment créer une règle custom Rector, qui répondra à ce besoin.

Mais quitte à écrire une règle, ne pouvons-nous pas corriger le code ci-dessus pour qu’il ne transgresse pas la loi de Demeter ?

use App\Repository\OrderRepository;

class OrderController
{
    public function __construct(
        private readonly OrderRepository $orderRepository,
    ) {
    }

    public function show(string $id)
    {
        $order = $this->orderRepository->find($id);
        //...
    }

}

Section intitulée mise-en-place-de-rectorMise en place de rector

À JoliCode, nous n’aimons pas ajouter les outils directement aux applications Symfony :

  • Ils peuvent mettre en conflit certaines dépendances ;
  • Ils alourdissent le projet ;
  • Pour des projets avec plusieurs applications (micro ou macro services), il faut installer N fois les outils ;
  • « Ce n’est pas bien rangé ».

Nous avons l’habitude de les mettre dans le dossier tools/<outils> :

tools
├─ rector
│  ├─ composer.json
│  └─ composer.lock
├─ phpstan
│  ├─ composer.json
│  └─ composer.lock
└─ php-cs-fixer
   ├─ composer.json
   └─ composer.lock

Dans le dossier tools/rector, nous avons le fichier composer.json suivant :

{
    "type": "project",
    "license": "proprietary",
    "require": {
        "rector/rector": "^1.0.1"
    },
    "require-dev": {
        "phpunit/phpunit": "^9.6.16",
        "symfony/var-dumper": "^5.4.35"
    },
    "autoload": {
        "psr-4": {
            "App\\": "src/"
        }
    },
    "autoload-dev": {
        "psr-4": {
            "App\\Tests\\": "tests/"
        }
    }
}

Vous noterez que nous avons pris un peu d’avance en ajoutant :

  • symfony/var-dumper pour pouvoir utiliser dump() dans les tests et lors du debug ;
  • phpunit/phpunit pour pouvoir tester nos règles ;
  • l’autoloading de notre « application ».

Une fois les dépendances installées, nous pouvons créer la configuration de base.

$ cd tools/rector
$ vendor/bin/rector

 No "rector.php" config found. Should we generate it for you? [yes]:
 > yes


 [OK] The config is added now. Re-run command to make Rector do the work!

Nous n’allons pas nous attarder sur la configuration de Rector, mais la configuration de base est la suivante :

<?php

declare(strict_types=1);

use Rector\Config\RectorConfig;
use Rector\TypeDeclaration\Rector\ClassMethod\AddVoidReturnTypeWhereNoReturnRector;

return RectorConfig::configure()
    ->withPaths([
        __DIR__ . '/src',
        __DIR__ . '/tests',
    ])
    // uncomment to reach your current PHP version
    // ->withPhpSets()
    ->withRules([
        AddVoidReturnTypeWhereNoReturnRector::class,
    ]);

Nous allons néanmoins changer le dossier de base de notre application, et adapter l’autoloading pour que Rector puisse trouver nos classes.

$base = __DIR__ . '/../..';

return RectorConfig::configure()
    ->autoloadPaths([
        __DIR__ . '/vendor/autoload.php',
        $base . '/vendor/autoload.php',
    ]);
    ->paths([
        $base . '/src',
    ]);

Note : Nous aurions aussi pu mettre le fichier rector.php à la racine de notre application, mais nous avons choisi de le mettre dans le dossier tools/rector pour des raisons de clarté.

Section intitulée creation-de-la-regleCréation de la règle

Il existe une commande pour créer une règle :

vendor/bin/rector custom-rule

Cependant, à l’heure où nous écrivons ces lignes, cette commande ne fonctionne pas. Nous allons donc créer la règle à la main.

Section intitulée initialisation-de-la-regleInitialisation de la règle

Nous allons créer le fichier suivant :

<?php
// src/Rector/RepositoryRector.php

namespace App\Rector;

use Rector\Rector\AbstractRector;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;
use PhpParser\Node;
use PhpParser\Node\Stmt\Class_;

final class RepositoryRector extends AbstractRector
{
    /**
     * @return array<class-string<Node>>
     */
    public function getNodeTypes(): array
    {
        return [Class_::class];
    }

    /**
     * @param Class_ $node
     */
    public function refactor(Node $node): ?Node
    {
        return $node;
    }

    public function getRuleDefinition(): RuleDefinition
    {
        throw new \LogicException('Not implemented yet');
    }
}

Ce code est la version minimal d’une règle Rector :

  • Elle doit étendre AbstractRector ;
  • Elle doit implémenter la méthode getNodeTypes() qui retourne un tableau de classes de nœuds que la règle va traiter ;
  • Elle doit implémenter la méthode refactor() qui va effectuer le traitement ;
  • Elle doit implémenter la méthode getRuleDefinition() qui retourne un objet RuleDefinition qui permet de documenter la règle. Mais ici, nous sommes fainéants, nous allons lever une exception.

Section intitulée quot-une-classe-de-noeud-que-la-regle-va-traiter-quot« une classe de nœud que la règle va traiter » ?

Rector utilise un parseur PHP pour analyser et transformer le code. Ce parseur va traduire le code en un arbre de nœud. Chaque nœud représente une partie du code. Par exemple, une classe, une méthode, une variable, un appel de méthode, etc.

Le parseur dont nous parlons ici est présent dans PHPStan, Symfony, PHP-CS-Fixer, Jane, etc. Vous l’avez devinez, il s’agit de nikic/PHP-Parser.

Pour utiliser convenablement Rector, il est important de comprendre comment fonctionne ce parseur. Nous vous conseillons de vous familiariser un peu avec avant de continuer, par exemple en jetant un œil à notre article sur les grands principes de l’analyse statique en PHP et la manipulation de l’AST.

Petite aparté :

Dans notre exemple, nous voulons changer des appels de méthode. Cependant, nous voulons aussi changer le constructeur d’une classe. Nous allons donc écouter la plus grande unité possible pour notre cas d’usage: la classe.

Section intitulée initialisation-des-testsInitialisation des tests

Lancer Rector avec notre règle ne va pas être très pratique pour débugger. Nous allons donc écrire des tests pour notre règle.

Nous allons suivre le format mis en avant par Rector pour architecturer nos tests :

tests
└─ Rector
   └─ RepositoryRector
      ├─ RepositoryRectorTest.php
      ├─ config
      │  └─ config.php
      └─ Fixture
         └─ php8.php.inc

Le fichier config.php retourne la config de rector :

<?php

use App\Rector\RepositoryRector;
use Rector\Config\RectorConfig;

return static function (RectorConfig $rectorConfig): void {
    $rectorConfig->rule(RepositoryRector::class);
    $rectorConfig->importNames();
};

Dans le dossier Fixture, nous allons mettre des fichiers PHP qui vont être transformés par notre règle. Dans chaque fichier, il y a en haut la version « avant », et en bas la version « après ».

Ici, php8.php.inc :

<?php

namespace App\FooBar;

use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Bar;

class Service
{
    private EntityManagerInterface $em;

    public function __construct(EntityManagerInterface $em)
    {
        $this->em = $em;
    }

    public function doSomething()
    {
        $this->em->getRepository(\App\Entity\Foo::class);
        $this->em->getRepository(Bar::class);
        $this->em->getRepository('App:Baz');
    }
}
?>
-----
<?php

namespace App\FooBar;

use App\Repository\FooRepository;
use App\Repository\BarRepository;
use App\Repository\BazRepository;
use Doctrine\ORM\EntityManagerInterface;
use App\Entity\Bar;

class Service
{
    private EntityManagerInterface $em;

    public function __construct(EntityManagerInterface $em, private FooRepository $fooRepository, private BarRepository $barRepository, private BazRepository $bazRepository)
    {
        $this->em = $em;
    }

    public function doSomething()
    {
        $this->fooRepository;
        $this->barRepository;
        $this->bazRepository;
    }
}
?>

Et pour finir, le fichier de test :

<?php

namespace App\Tests\Rector\RepositoryRector;

use PHPUnit\Framework\Attributes\DataProvider;
use Rector\Testing\PHPUnit\AbstractRectorTestCase;

final class RepositoryRectorTest extends AbstractRectorTestCase
{
    /** @dataProvider provideData */
    public function test(string $filePath): void
    {
        $this->doTestFile($filePath);
    }

    public static function provideData(): iterable
    {
        return self::yieldFilesFromDirectory(__DIR__ . '/Fixture');
    }

    public function provideConfigFilePath(): string
    {
        return __DIR__ . '/config/config.php';
    }
}

Section intitulée ecriture-de-la-regleÉcriture de la règle

Nous n’allons pas détailler tout le code de la règle, car ce n’est pas le but de l’article. À la place, nous allons juste mettre en avant quelques points importants.

<?php

namespace App\Rector;

use PHPStan\Type\ObjectType;
use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PhpParser\Node\Expr\StaticCall;
use PhpParser\Node\Name;
use PhpParser\Node\Scalar\String_;
use PhpParser\Node\Stmt\ClassMethod;
use PhpParser\Node\Stmt\Class_;
use PhpParser\Node\Stmt\Expression;
use Rector\NodeManipulator\ClassDependencyManipulator;
use Rector\PostRector\ValueObject\PropertyMetadata;
use Rector\Rector\AbstractRector;
use Rector\Symfony\NodeAnalyzer\DependencyInjectionMethodCallAnalyzer;
use Rector\ValueObject\MethodName;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;

final class RepositoryRector extends AbstractRector
{
    // Nous n’utilisons pas CPP, car le code doit fonctionner avec PHP 7.4
    private ClassDependencyManipulator $classDependencyManipulator;
    private DependencyInjectionMethodCallAnalyzer $dependencyInjectionMethodCallAnalyzer;

    public function __construct(
        ClassDependencyManipulator $classDependencyManipulator,
        DependencyInjectionMethodCallAnalyzer $dependencyInjectionMethodCallAnalyzer
    ) {
        $this->classDependencyManipulator = $classDependencyManipulator;
        $this->dependencyInjectionMethodCallAnalyzer = $dependencyInjectionMethodCallAnalyzer;
    }

    /**
     * @return array<class-string<Node>>
     */
    public function getNodeTypes(): array
    {
        return [Class_::class];
    }

    /**
     * @param Class_ $node
     */
    public function refactor(Node $node): ?Node
    {
        // Nous sauvegardons la classe, pour un future usage
        $class = $node;

        // Nous créons un tableau de propriétés qui seront injectées dans le constructeur
        $propertyMetadatas = [];

        // Méthode très pratique pour traverser l’arbre de noeuds
        $this->traverseNodesWithCallable($class, function (Node $node) use ($class, &$propertyMetadatas): ?Node {
            // Nous ne voulons que l'appel de méthode
            if (!$node instanceof MethodCall) {
                return null;
            }
            // qui sont nommé "getRepository"
            if (!$this->isName($node->name, 'getRepository')) {
                return null;
            }
            // et qui sont appelées via Doctrine
            if (!$this->isCallerDoctrine($node, $class)) {
                return null;
            }

            // Nous prenons la valeur du premier arguments
            $arg = $node->args[0]->value;
            if ($arg instanceof String_) {
                // Et si c’est une string, nous la  remplacons par son FQCN
                $entity = str_replace('App:', 'App\\Entity\\', $arg->value);
                $newValue = $this->nodeFactory->createClassConstFetch($entity, 'class');
                $node->args[0]->value = $newValue;
            }

            // Nous calculons le remplacement de l’appel de méthode par un appel de propriété.
            $propertyMetadata = $this->dependencyInjectionMethodCallAnalyzer->replaceMethodCallWithPropertyFetchAndDependency(
                $class,
                $node
            );

            if (!$propertyMetadata instanceof PropertyMetadata) {
                return null;
            }

            // Mais nous remplaçons la propriété et sa classe par le Repository associé
            // Note : On suppose que le repo existe, et qu’il est situé dans App\Repository\<Entity>Repository
            $propertyMetadata = new PropertyMetadata(
                $propertyMetadata->getName() . "Repository",
                new ObjectType('App\Repository\\' . ucfirst($propertyMetadata->getName()) . 'Repository')
            );

            $propertyMetadatas[] = $propertyMetadata;

            // Nous remplaçons l’appel de méthode par un appel de propriété
            return $this->nodeFactory->createPropertyFetch('this', $propertyMetadata->getName());
        });

        if ($propertyMetadatas === []) {
            return null;
        }

        // Nous ajoutons les dépendances au constructeur
        foreach ($propertyMetadatas as $propertyMetadata) {
            $this->classDependencyManipulator->addConstructorDependency($class, $propertyMetadata);
        }

        // Cas spécial pour les Command qui doivent appeler le constructeur parent
        $this->decorateCommandConstructor($class);

        return $node;
    }

    public function getRuleDefinition(): RuleDefinition
    {
        throw new \LogicException('Not implemented yet');
    }


    private function isCallerDoctrine(MethodCall $node, Class_ $class): bool
    {
        // Nous regardons si l’appelant est une instance de Doctrine\ORM\EntityManagerInterface
        if ($this->isObjectType($node->var, new ObjectType("Doctrine\ORM\EntityManagerInterface"))) {
            return true;
        }

        // Nous regardons si l’appelant est une instance de Doctrine\Persistence\ManagerRegistry
        if ($this->isObjectType($node->var, new ObjectType("Doctrine\Persistence\ManagerRegistry"))) {
            return true;
        }

        // Gestion du manque de return type sur Symfony\Bundle\FrameworkBundle\Controller\AbstractController::getDoctrine()
        if ($node->var instanceof MethodCall && $this->isName($node->var->name, 'getDoctrine')) {
            if (!$this->isObjectType($class, new ObjectType("Symfony\Bundle\FrameworkBundle\Controller\AbstractController"))) {
                // Does not work, WTF? => looks like there is a bug somewhere in rector
                // return false;
                return true;
            }
            return true;
        }

        return false;
    }

    // Cas spécial pour les Command qui doivent appeler le constructeur parent
    private function decorateCommandConstructor(Class_ $class): void
    {
        if (!$this->isObjectType($class, new ObjectType('Symfony\Component\Console\Command\Command'))) {
            return;
        }

        $constructClassMethod = $class->getMethod(MethodName::CONSTRUCT);
        if (!$constructClassMethod instanceof ClassMethod) {
            return;
        }

        if ((array) $constructClassMethod->stmts === []) {
            $parentConstructStaticCall = new StaticCall(new Name('parent'), '__construct');
            $constructClassMethod->stmts[] = new Expression($parentConstructStaticCall);
        }
    }
}

Pour rappel, l’architecture de notre projet est la suivante :

tools/rector
├─ phpunit.xml
├─ composer.json
├─ src
│  └─ Rector
│     └─ RepositoryRector.php
├─ composer.lock
├─ tests
│  └─ Rector
│     └─ RepositoryRector
│        ├─ RepositoryRectorTest.php
│        ├─ config
│        │  └─ config.php
│        └─ Fixture
│           └─ php8.php.inc
└─ rector.php

Il faut maintenant lancer les tests :

$ vendor/bin/phpunit
PHPUnit 9.6.16 by Sebastian Bergmann and contributors.

Runtime:       PHP 8.3.2-1+ubuntu22.04.1+deb.sury.org+1
Configuration: /home/gregoire/dev/my-project/tools/rector/phpunit.xml

.                                                                   1 / 1 (100%)

Time: 00:01.026, Memory: 32.00 MB

OK (1 test, 2 assertions)

🎉🎉🎉 Tout marche bien ! 🎉🎉🎉

Section intitulée conclusionConclusion

Nous devons ajouter notre rule à la configuration de rector :

->rules([
    App\Rector\RepositoryRector::class,
])

Et voilà !

Avec ce petit guide, vous pouvez constater comme il est simple de créer vos propres règles Rector. Vous pouvez aussi les partager avec la communauté, ou les garder pour vous. C’est à vous de voir. D’ailleurs, si vous pensez que cette règle pourrait être utile à d’autres, n’hésitez pas à la partager ou à nous demander de créer un package Composer.

Rector est vraiment très puissant et permet de refactorer une grosse base de code très rapidement. C’est un outil que nous utilisons au quotidien chez JoliCode et nous ne pouvons que vous le recommander. Par contre, il n’est pas impossible que certaines règles cassent votre code. Nous espérons que vous avez fait des tests pour vous en rendre compte rapidement. Mais rassurez-vous, c’est vraiment de plus en plus rare !

Cependant, il y a (avait ?) très souvent des changements de formats de configuration, des BC breaks, des suppressions de règles, des changements de comportement, etc. Nous espérons que grâce à la très récente release de la version 1, les choses vont se stabiliser.

Enfin, vous vous demandez peut-être s’il vaut mieux écrire une règle ou changer le code à la main ? Et bien comme d’habitude, ça dépend. Nous avons mis environ 4 heures pour découvrir les règles personnalisées, et écrire celle-ci. Ce fut peut-être un peu plus long que de le faire à la main, mais beaucoup plus intéressant et fun. Et pour l’avoir fait sur d’autres projets, nous regrettons de ne pas l’avoir écrite plus tôt, car elle aurait déjà été largement rentabilisée ! De plus, le fait d’automatiser les choses les rend moins sensibles aux erreurs humaines d’inattention. À l’avenir, si le besoin s’en fait ressentir, nous envisagerons très sérieusement l’ajout d’une règle personnalisée au projet.

Commentaires et discussions

Ces clients ont profité de notre expertise