🦔

Rectorの独自ルールを作成する方法(2022年版)

2022/06/25に公開

モチベーション

Rectorという自動リファクタリングツールについて紹介した素晴らしい記事があります。

https://zenn.dev/y_ahiru/articles/auto-refactor-with-rector

しかし、記事が執筆されてから2年ほど経過し、独自ルールの作成方法が現在は結構変更されています。
そこで本稿では、上述の記事の独自ルールを作成するハンズオン部分について、2022年版を書いておこうかと思います。

サンプル

サンプルリポジトリはここにあります。

https://github.com/isanasan/rector-tutorial

環境構築
// build
docker build . -t rector-tutorial
// run
docker run -it --rm -v ${pwd}:/app tutorial bash

独自ルールを作成するハンズオン

composerをセットアップする

以下のコマンドを実行します。

composer init

対話形式で色々聞かれるので適当に入力していきましょう。

Rectorを入れる

以下のコマンドを実行します。

composer require --dev rector/rector

PHPUnitを入れる

以下のコマンドを実行します。

composer require -dev phpunit/phpunit

独自ルールのレシピを生成する

Rectorのルール作成には様々な作法があるため、すべて一から手作業で作成するのは大変です。
解決策としてrector generateコマンドが提供されており、recipeファイルを元にテストを含めた必用なファイル郡を自動生成できるようになっています。

bin/rector init-recipe

それっぽさのある出力がなされ、ルート直下にrector-recipe.phpが生成されます。

レシピを編集する

生成されたrector-recipe.phpを以下のように編集します。

rector-recipe.php
<?php

declare(strict_types=1);

namespace RectorPrefix202206;

- use PhpParser\Node\Expr\MethodCall;
+ use PhpParser\Node\Stmt\ClassMethod;
use Rector\RectorGenerator\Provider\RectorRecipeProvider;
use Rector\RectorGenerator\ValueObject\Option;
use RectorPrefix202206\Symfony\Component\DependencyInjection\Loader\Configurator\ContainerConfigurator;
return static function (ContainerConfigurator $containerConfigurator): void {
    $services = $containerConfigurator->services();
    $rectorRecipeConfiguration = [
-       Option::PACKAGE => 'Naming',
-       Option::NAME => 'RenameMethodCallRector',
-       Option::NODE_TYPES => [MethodCall::class],
-       Option::DESCRIPTION => '"something()" will be renamed to "somethingElse()"',
+       Option::PACKAGE => 'RectorTutorial',
+       Option::NAME => 'AddTestAnnotationRector',
+       Option::NODE_TYPES => [ClassMethod::class],
+       Option::DESCRIPTION => 'Rector Tutorial',
        Option::CODE_BEFORE => <<<'CODE_SAMPLE'
-       class SomeClass
+class SomeTest extends \PHPUnit\Framework\TestCase
{
-   public function run()
+   public function testSome() : void
    {
-       $this->something();
+       // do test
    }
}
CODE_SAMPLE,
        Option::CODE_AFTER => <<<'CODE_SAMPLE'
- class SomeClass
+ class SomeTest extends \PHPUnit\Framework\TestCase
{
-   public function run()
+   /**
+    * @test
+    */
+   public function some() : void
    {
-       $this->somethingElse();
+       // do test
    }
}
CODE_SAMPLE,
    ];
    $services->set(RectorRecipeProvider::class)->arg('$rectorRecipeConfiguration', $rectorRecipeConfiguration);
};

差分の内容について、もう少し解説します。

15 ~ 18行目

ここは見たまんまの内容になっていて、独自ルールのパッケージ名やルール名、ルールの内容の説明を設定しています。
Option::PACKAGEOption::NAMEの内容を元にディレクトリと名前空間が決まるイメージです。

rector-recipe.php
-       Option::PACKAGE => 'Naming',
-       Option::NAME => 'RenameMethodCallRector',
-       Option::NODE_TYPES => [MethodCall::class],
-       Option::DESCRIPTION => '"something()" will be renamed to "somethingElse()"',
+       Option::PACKAGE => 'RectorTutorial',
+       Option::NAME => 'AddTestAnnotationRector',
+       Option::NODE_TYPES => [ClassMethod::class],
+       Option::DESCRIPTION => 'Rector Tutorial',

17行目のOption::NODE_TYPES => [ClassMethod::class]の部分でリファクタリング対象のNodeクラスの配列を指定します。
選択できるNodeクラスはPhpParser\Nodeを確認すると良いでしょう。

19行目から40行目

ヒアドキュメントの中にリファクタリング前後の内容を書きます。

rector-recipe
        Option::CODE_BEFORE => <<<'CODE_SAMPLE'
-       class SomeClass
+class SomeTest extends \PHPUnit\Framework\TestCase
{
-   public function run()
+   public function testSome() : void
    {
-       $this->something();
+       // do test
    }
}
CODE_SAMPLE,
        Option::CODE_AFTER => <<<'CODE_SAMPLE'
- class SomeClass
+ class SomeTest extends \PHPUnit\Framework\TestCase
{
-   public function run()
+   /**
+    * @test
+    */
+   public function some() : void
    {
-       $this->somethingElse();
+       // do test

この内容を元にルールのテストで利用するFixtureが作成されるのでキチンと記述しておく必用があります。

recipeからコードを生成する

以下のコマンドを実行します。

bin/rector generate

それっぽさのある出力がなされた後、以下の4つのファイルが出力されます。

  • rules-tests/RectorTutorial/Rector/ClassMethod/AddTestAnnotationRector/AddTestAnnotationRectorTest.php
  • rules-tests/RectorTutorial/Rector/ClassMethod/AddTestAnnotationRector/Fixture/some_class.php.inc
  • rules-tests/RectorTutorial/Rector/ClassMethod/AddTestAnnotationRector/config/configured_rule.php
  • rules/RectorTutorial/Rector/ClassMethod/AddTestAnnotationRector.php

rules配下にはこれから独自ルールを記述するファイル、rules-tests配下には独自ルールのテストに必用なファイルがそれぞれ出力されます。

Fixtureを追加する

自動生成したFixtureだけではテスト項目に不足があるためパターンを網羅出来るようにFixtureを追加していきます。
Fixtureディレクトリ配下に以下のファイルを追加します。

no-test-prefix.php.inc
<?php

namespace Rector\Tests\RectorTutorial\Rector\ClassMethod\AddTestAnnotationRector\Fixture;

class NotTestPrefix extends \PHPUnit\Framework\TestCase
{
    public function noPrefix() : void
    {
    }
}
not-public-method.php.inc
<?php

namespace Rector\Tests\RectorTutorial\Rector\ClassMethod\AddTestAnnotationRector\Fixture;

class NotPublicMethodTest extends \PHPUnit\Framework\TestCase
{
    protected function testProtected() : void
    {
    }

    private function testPrivate() : void
    {
    }
}
not-test-class.php.inc
<?php

namespace Rector\Tests\RectorTutorial\Rector\ClassMethod\AddTestAnnotationRector\Fixture;

class NotTest
{
    public function testSome() : void
    {
    }
}

PHPUnitを設定する

phpunit.xml.distを作成して、テストを実行できるようにします。

phpunit.xml.dist
<?xml version="1.0" encoding="UTF-8"?>
<phpunit executionOrder="random"
         bootstrap="vendor/autoload.php"
         colors="true">
  <coverage processUncoveredFiles="true">
    <include>
      <directory suffix=".php">rules</directory>
    </include>
  </coverage>
  <testsuites>
    <testsuite name="all">
      <directory>rules-tests</directory>
    </testsuite>
  </testsuites>
  <php>
    <ini name="error_reporting" value="-1"/>
  </php>
</phpunit>

autoloadを設定する

composer.jsonを編集してautoloadの設定を加えます。

composer.json
  "autoload": {
    "psr-4": {
-        "Isanasan\\RectorTutorial\\": "src/"
+        "Isanasan\\RectorTutorial\\": "src/",
+        "Rector\\": "rules"
+    }
+  },
+  "autoload-dev": {
+    "psr-4": {
+        "Rector\\Tests\\": "rures-tests"
    }
  },

PHPUnitを実行する

以下のコマンドを実行します。

bin/phpunit rules-tests/

すると以下のような出力がなされ、テストが失敗します。

PHPUnit 9.5.21 #StandWithUkraine

Random Seed:   1656132964

F...                                                                4 / 4 (100%)

Time: 00:33.579, Memory: 72.50 MB

There was 1 failure:

1) Rector\Tests\RectorTutorial\Rector\ClassMethod\AddTestAnnotationRector\AddTestAnnotationRectorTest::test with data set #3 (RectorPrefix202206\Symplify\SmartFileSystem\SmartFileInfo Object (...))
rules-tests/RectorTutorial/Rector/ClassMethod/AddTestAnnotationRector/Fixture/some_class.php.inc
Failed asserting that string matches format description.
--- Expected
+++ Actual
@@ @@

 class SomeTest extends \PHPUnit\Framework\TestCase
 {
-    /**
-     * @test
-     */
-    public function some() : void
+    public function testSome() : void
     {
         // do test
     }

/app/vendor/rector/rector/packages/Testing/PHPUnit/AbstractRectorTestCase.php:128
/app/vendor/rector/rector/packages/Testing/PHPUnit/AbstractRectorTestCase.php:102
/app/rules-tests/RectorTutorial/Rector/ClassMethod/AddTestAnnotationRector/AddTestAnnotationRectorTest.php:16

FAILURES!
Tests: 4, Assertions: 9, Failures: 1.

あとはこの失敗したテストが通るように、独自ルールの実装を進めていきます。

独自ルールを実装する

以下のようにAddTestAnnotationRectorを編集します。

AddTestAnnotationRector
namespace Rector\RectorTutorial\Rector\ClassMethod;

use PhpParser\Node;
+use PhpParser\Node\Stmt\ClassMethod;
+use PhpParser\Node\Stmt\ClassLike;
+use PHPUnit\Framework\TestCase;
+use PHPStan\Type\ObjectType;
+use PHPStan\PhpDocParser\Ast\PhpDoc\PhpDocTagNode;
+use PHPStan\PhpDocParser\Ast\PhpDoc\GenericTagValueNode;
use Rector\Core\Rector\AbstractRector;
+use Rector\BetterPhpDocParser\ValueObject\PhpDocNode\PHPUnit\PHPUnitTest;
use Symplify\RuleDocGenerator\ValueObject\CodeSample\CodeSample;
use Symplify\RuleDocGenerator\ValueObject\RuleDefinition;

@@ -52,7 +59,41 @@ public function getNodeTypes(): array
     */
    public function refactor(Node $node): ?Node
    {
+        if (!$this->shouldRefactor($node)) {
+            return null;
+        }
+
+        // メソッドの PHP Doc を取得
+        $phpDocInfo = $this->phpDocInfoFactory->createFromNodeOrEmpty($node);
+
+        $phpDocInfo->addPhpDocTagNode(
+            new PhpDocTagNode('@test', new GenericTagValueNode(''))
+        );
+
+        // 元のメソッド名から `test` prefix を削除し, 1文字目を小文字に変更した文字列
+        $testName = \lcfirst(
+            (string) \preg_replace('/\Atest/', '', (string) $this->getName($node))
+        );
+        // `$testName` を新しいメソッド名として設定しなおす
+        $node->name = new Node\Identifier($testName);
+
        // change the node
        return $node;
    }
+
+    private function shouldRefactor(ClassMethod $node): bool
+    {
+        // クラスメソッドが実装されているクラス情報を取得
+        $class = $this->betterNodeFinder->findParentType($node, ClassLike::class);
+
+        return
+            // メソッドの可視性が public かどうか
+            $node->isPublic()
+            // メソッド名が `test` から始まっているかどうか
+            && $this->isName($node, 'test*')
+            // メソッドが実装されているクラスが `PHPUnit\Framework\TestCase` を継承しているかどうか
+            && $this->nodeTypeResolver->isObjectType(
+                $class,
+                new ObjectType(TestCase::class),
+            );
+    }
}

詳しい内容についてはあひるさんの元記事を参照ください。

作成したルールをテストする

以下のコマンドを実行します。

bin/phpunit rules-tests/

エラーが発生していなければOKです。

まとめ

rector-reciperector generateコマンドによっていくらか独自ルールの開発がしやすくなりました。
ハマりポイントとしてはディレクトリがrulesrules-testsになっていることです。
phpunit.xml.distcomposer.jsonautoloadの設定を間違えやすいので注意してください。

Discussion