2024-04-11

PHPStanの拡張機能の作り方

PHPStanの拡張機能の作り方をざっと調べてみたので備忘録。 拡張機能といっても色々あるが、ライブラリ以外のプロダクションコードでよく使いそうな Custom Rules Class Reflection Extensions Dynamic Return Type Extensions の3つについて書いていく。

詳細は公式ドキュメントを参照。

Custom Rules

検証ルールを独自に作成できる拡張機能。PHPStan\Rules\Rule インターフェースをimplementしたクラスを実装すればOKで、どのNodeに対して(getNodeType())、どういう検査を行うのか(processNode)を記述していく。検査時にエラーがあれば processNode() でエラー文字列の配列を返せば良い。

例えばメソッド呼び出しに対して検査したい場合は以下のような感じで実装する。

<?php declare(strict_types = 1);

namespace App;

use PhpParser\Node;
use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;

class XXXRule implements Rule
{
    public function getNodeType(): string
    {
        // ここに検査対象のノードのクラス名を記述する
        return MethodCall::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        // エラーがあれば return ['エラーメッセージ']; という感じで文字列配列を返す。
        // エラーがなければ return []; を返す。
        return [];
    }
}

$node は対象のASTノードで getNodeType() で返したクラスのオブジェクトが入る。上記例だと$nodeに入る MethodCall クラスはメソッド名を表す $name、レシーバーを表す $var、引数を表す $args の3つのノードを持っているので、これらのプロパティを使って検査をしていくことになる。

$scope はそのノードに対するスコープ/状態を表している。例えばメソッドAで $var = 'hoge'; メソッドBで $var = 1; といった形で同名の変数が異なる型、コンテキストで利用されている場合、メソッドA内では文字列型、メソッドB内では数値型として扱われるべきだが、これは変数名だけでは確定できない。そのため、そのスコープ/状態に応じて型を検査する必要があるが、それを表すオブジェクトが Scope インターフェースの$scope変数である。例えば $scope->getVariableType('変数名') という形で呼び出すと、そのスコープに応じた変数名の型を抽出できる。他にも $scope->getFile() でそのノードの元ファイル名、 $scope->getNamespace() で名前空間を取得できる。

実際のルールの書き方に関しては実コードを読むと良い。参考↓

Custom Rulesのテスト

テストは PHPStan\Testing\RuleTestCase クラスを継承して実装する。 getRule() にはテスト対象のルールのインスタンスを返すようにして、検証は $this->analyse() で行う。$this->analyse() の第1引数には解析対象のファイル、第2引数に想定されるエラー文字列と行番号の配列を設定すれば良い。

<?php declare(strict_types = 1);

namespace App\Tests\Rules;

use PHPStan\Rules\Rule;
use PHPStan\Testing\RuleTestCase;
use App\XXXRule;

final class XXXRuleTest extends RuleTestCase
{
    protected function getRule(): Rule
    {
        return new XXXRule();
    }

    public function test_it_succeeds(): void
    {
        // 何もエラーが発生しない場合は第二引数は空配列を入れる。
        $this->analyse(['/path/to/success.php'], []);
    }

    public function test_it_fails(): void
    {
        // エラーが発生する場合は第二引数にエラー文字列と行番号の配列を入れる。
        $this->analyse(['/path/to/failure.php'], [
            [
                'Error ....', // エラーメッセージ
                14, // 行番号
            ],
        ]);
    }
}

Class Reflection Extensions

__get() __set() __call() などのマジックメソッドを使っている場合、静的解析ではどのプロパティ、メソッドが呼ばれうるのかを判断できないので、それらをPHPStanに伝えるための拡張機能。PHPDocの @method @property タグなどを使えばプロパティやメソッドをPHPStanに伝えることができるが、これも当拡張が使われている。

LarastanではClass Reflection Extensionsを使って、EloquentモデルのDBカラムのプロパティやクエリ系メソッドをPHPStanで解釈できるようにしている。

拡張機能の書き方は以下のような感じで。以下の例ではHogeクラスのfugaメソッドが定義されてなくてもPHPStanが解釈してくれるようになる。

<?php declare(strict_types = 1);

namespace App;

use PHPStan\Reflection\ClassReflection;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Reflection\MethodsClassReflectionExtension;
use PHPStan\Reflection\ReflectionProviderStaticAccessor;
use PHPStan\Reflection\TrivialParametersAcceptor;
use PHPStan\TrinaryLogic;
use PHPStan\Type\Type;

class CustomMethodsClassReflectionExtension implements MethodsClassReflectionExtension
{
    public function hasMethod(ClassReflection $classReflection, string $methodName): bool
    {
        // 対象のクラス・メソッドかどうかを判定
        return $classReflection->getName() === \App\Hoge::class && $methodName === 'fuga';
    }

    public function getMethod(ClassReflection $classReflection, string $methodName): \PHPStan\Reflection\MethodReflection
    {
        // メソッド定義(可視性やメソッド名、戻り値など)を返す
        return new class implements MethodReflection
        {
            // ...
        };
    }
}

テストは PHPStan\Testing\TypeInferenceTestCase クラスを継承して以下のように記述すればOK。

<?php declare(strict_types = 1);

namespace Tests;

use PHPStan\Testing\TypeInferenceTestCase;

class DynamicTypeTest extends TypeInferenceTestCase
{
    /** @return iterable<mixed> */
    public static function dataFileAsserts(): iterable
    {
        yield from self::gatherAssertTypes(__DIR__ . '/data/model.php');
    }

    /** @dataProvider dataFileAsserts */
    public function testFileAsserts(
        string $assertType,
        string $file,
        mixed ...$args,
    ): void {
        $this->assertFileAsserts($assertType, $file, ...$args);
    }

    /** @return string[] */
    public static function getAdditionalConfigFiles(): array
    {
        return [__DIR__ . '/data/config.neon'];
    }
}

getAdditionalConfigFiles() で追加のneonファイルを指定する。追加のneonファイルにはservicesプロパティ内でタグ付きで定義しておく。

services:
	-
		class: App\CustomMethodsClassReflectionExtension
		tags:
			- phpstan.broker.methodsClassReflectionExtension

dataFileAsserts() でテスト対象のファイルを指定する。テストファイルは以下のような感じでassertType関数で型を検証する。

<?php

use function PHPStan\Testing\assertType;

function test()
{
    $hoge = new \App\Hoge();
    assertType('string', $hoge->fuga());
}

Dynamic Return Type Extensions

レシーバーや引数の型によってメソッドの戻り値の型が変わる場合、PHPStanで戻り値の型を正確に解釈できるようにする拡張機能。例えばLarastanだとこの拡張機能を使って、Model::query() の戻り値として Illuminate\Database\Eloquent\Builder<Model> といった形のジェネリクスのEloquentBuilderを返すようにしている。

書き方はこんな感じで DynamicMethodReturnTypeExtension を継承して getClass() isMethodSupported() getTypeFromMethodCall() を実装していく。

<?php declare(strict_types = 1);

namespace App;

use PhpParser\Node\Expr\MethodCall;
use PHPStan\Analyser\Scope;
use PHPStan\Reflection\MethodReflection;
use PHPStan\Type\DynamicMethodReturnTypeExtension;
use PHPStan\Type\StringType;
use PHPStan\Type\Type;

class DynamicType implements DynamicMethodReturnTypeExtension
{
    public function getClass(): string
    {
        // 対象のクラス名を指定
        return \App\Hoge::class;
    }

    public function isMethodSupported(MethodReflection $methodReflection): bool
    {
        // 対象のメソッドかどうかを判定
        return $methodReflection->getName() === 'fuga';
    }

    public function getTypeFromMethodCall(
        MethodReflection $methodReflection,
        MethodCall $methodCall,
        Scope $scope
    ): ?Type
    {
        // メソッド呼び出しに応じて適切に型を返す
        return new StringType();
    }
}

テストの書き方はClass Reflection Extensionsと同じ。neonファイルのservicesのtagには phpstan.broker.dynamicMethodReturnTypeExtension を指定する。

services:
	-
		class: App\DynamicType
		tags:
			- phpstan.broker.dynamicMethodReturnTypeExtension
このエントリーをはてなブックマークに追加