Yappli Tech Blog

株式会社ヤプリの開発メンバーによるブログです。最新の技術情報からチーム・働き方に関するテーマまで、日々の熱い想いを持って発信していきます。

厳密モードの記述をチェックするPHPStanの独自拡張を作った話

はじめに

こんにちは。サーバーサイドエンジニアの佐野きよ(@Kiyo_Karl2)です。

PHPでは、declare(strict_types=1);をファイルの先頭に記述することで厳密な型チェックを有効にすることができます。これにより暗黙の型変換がされなくなり、関数やメソッドの引数および戻り値の型宣言に正確に対応する値のみ受け入れることができるようになるため、より堅牢なコードになります。しかし、この記述はファイルごとに記述しなければいけないため、うっかりこの記述が抜けてしまいコードレビューで同様の指摘が度々ありました。

そこで、コードレビューで人間が指摘するのではなく、PHPStanに指摘してもらうようにしようと考えたのですが、厳密モードをチェックするルールはデフォルトでPHPStanには無さそうだったので、Custom Ruleを用いてファイルの先頭に厳密モードの記述があるかを簡単にチェックするカスタムルール (Custom Rules)を作成しました。

今回はこちらについて紹介したいと思います。

実装

まずはコードを見た方がなんとなくイメージつくかと思いますので、ご覧下さい。

<?php

declare(strict_types=1);

namespace phpstan\Rules;

use PhpParser\Node;
use PhpParser\Node\Stmt\Namespace_;
use PHPStan\Analyser\Scope;

/**
 * @implements Rule<Namespace_>
 */
class RequireStrictTypesRule implements Rule
{
    public function getNodeType(): string
    {
        return Namespace_::class;
    }

    public function processNode(Node $node, Scope $scope): array
    {
        $path = $scope->getFile();
        $file_content = file_get_contents($path);

        $pattern = "/^<\?php\n\ndeclare\(strict_types=1\);\n\n/";
        if (preg_match($pattern, $file_content)) {
            return []; // パターンにマッチした場合は問題なし
        }

        // RuleErrorBuilderを使ってエラーメッセージを作成
        return [
            RuleErrorBuilder::message('ファイルの先頭は<?php\n\ndeclare(strict_types=1);\n\n"で始まるようにしてください.')
                ->build(),
        ];
    }
}

以下、上記コードについて順を追って解説していきます。

PHPStanの解析の仕組み

PHPStanはPHPの構文をチェックするLinterです。 PHPStanはPHPの構文を解析するためにPHP-Parserを利用しています。

PHP-Parserによって文字列のソースコードからAST(Abstract Syntax Tree - 構文抽象木)に変換されたオブジェクトをPHPStanで解析し、ルール違反していないかLintチェックを行なっています。

例えば以下のコードをPHP-Parserを利用して変換してみます。

<?php

namespace App\Http\Controllers;

use Illuminate\Http\Request;

class UserController extends Controller
{
    //
}

--var-dumpオプションを付与して対象のファイルをASTに変換します。( nick/PHP-Parserをインストール済)

vendor/bin/php-parse --var-dump app/Http/Controllers/UserController.php

出力結果に情報が多くわかりにくいため、わかりやすくまとめたものが以下です。

array(1)  
└─ [0]: PhpParser\Node\Stmt\Namespace_  
     ├─ name: PhpParser\Node\Name  
     │    └─ parts: array(3)  
     │         ├─ [0]: "App"  
     │         ├─ [1]: "Http"  
     │         └─ [2]: "Controllers"  
     └─ stmts: array(2)  
          ├─ [0]: PhpParser\Node\Stmt\Use_  
          │    └─ uses: array(1)  
          │         └─ [0]: PhpParser\Node\Stmt\UseUse  
          │              ├─ name: PhpParser\Node\Name  
          │              │    └─ parts: array(3)  
          │              │         ├─ [0]: "Illuminate"  
          │              │         ├─ [1]: "Http"  
          │              │         └─ [2]: "Request"  
          │              └─ alias: NULL  
          └─ [1]: PhpParser\Node\Stmt\Class_  
               ├─ name: PhpParser\Node\Identifier  
               │    └─ name: "UserController"  
               ├─ extends: PhpParser\Node\Name  
               │    └─ parts: array(1)  
               │         └─ [0]: "Controller"  
               └─ stmts: array(1)  
                    └─ [0]: PhpParser\Node\Stmt\Nop

このような形で文字列であるソースコードがツリー構造をもったノードに変換されます。これによってPHPStanなどのツールで解析しやすくなるため、必要な情報を抽出し、定められたルールに違反していないか判定するといったことが可能となります。

PHP-ParserによってASTに変換されたノードがどんな情報を持っているかは Class PhpParser\NodeAbstract | PHPStan API がとても役に立ちます。

例として、上記で例に挙げたネームスペースの宣言部分について見ていきます。

<?php

namespace App\Http\Controllers;

まず、これは値を返すものではないため式(Expression)ではなく文(Statement)です。 PHP-Parserでは文はPhpParser\Node\Stmtのネームスペースとして表現されます。

ここを見ていくと、PhpParser\Node\Stmt\Namespace_というのがあり、プロパティにnamestmts、定数でKIND_SEMICOLONKIND_BRACEDをもっていることがわかります。

KIND_SEMICOLONは今回のようなセミコロンで終わるものを表現しており、KIND_BRACEDは以下のように中括弧が利用されるコードを表現していると考えられます。

<?php

namespace MyNamespace {
    // code
}

そして、上述したASTを見ると、nameプロパティにはPhpParser\Node\Name型のオブジェクトが保持されており、このオブジェクトはpartsという配列をプロパティに持ちます。 このpartsには["App", "Http", "Controllers"]という配列が格納されています。

このような感じでnamespace App\Http\Controllers;というコードがPHP-ParserでASTとして表現されていることがわかります。

PHPStan拡張の種類

PHPStanには以下のような拡張種類があります。

  • カスタムルール (Custom Rules)
  • コレクター (Collectors)
  • エラーフォーマッター (Error Formatters)
  • クラスリフレクション拡張 (Class Reflection Extensions)
  • 動的戻り値型拡張 (Dynamic Return Type Extensions)
  • 動的スロー型拡張 (Dynamic Throw Type Extensions)
  • 型指定拡張 (Type-Specifying Extensions)
  • カスタムPHPDoc型 (Custom PHPDoc Types)
  • 常に読み書きされるプロパティ (Always-Read and Written Properties)
  • 常に使用されるクラス定数 (Always-Used Class Constants)
  • 許可されたサブタイプ (Allowed Subtypes)

この中でよく利用されるのは今回実装したカスタムルール動的戻り値型拡張です。

カスタムルールについてはimplements Rulephpstan-src/src at 1.11.x · phpstan/phpstan-src · GitHub をgrepすると2024/06/25時点で283個あり、PHPStanの標準拡張で最も実装されているものになるため、独自拡張するときもこのカスタムルールを利用するシーンが最も多くなるかと思います。

今回は動的戻り値型拡張については割愛させていただき、カスタムルールについて解説していきます。

厳密モードのカスタムルールの実装

ここまで理解できたらあとはPHPStanの拡張プラグインのインターフェースに沿って、解析するカスタムルールを書くだけです。

Ruleインターフェースを継承

カスタムルールは、 PHPStan\Rules\Rule interfaceを継承します。このインターフェースは以下の2つのメソッドを持ちます。

  • public function getNodeType(): string
  • public function processNode(PhpParser\Node $node, PHPStan\Analyser\Scope $scope): array

getNodeType()で解析したいASTのNodeの型を返すように実装します。今回のケースではファイル全体を対象としたかったため、PhpParser\Node\Stmt\Namespace_型を返すようにしました。

<?php

declare(strict_types=1);

namespace phpstan\Rules;

use PhpParser\Node;
use PhpParser\Node\Stmt\Namespace_;
use PHPStan\Analyser\Scope;

class RequireStrictTypesRule implements Rule
{
    public function getNodeType(): string
    {
        return Namespace_::class;
    }
// 省略

次に、processNode()を実装をします。

解析時にgetNodeType()で指定したNodeの型に遭遇するたびにprocessNode()が呼ばれます。processNode()の第一引数にはASTのNode、すなわちgetNodeType()で指定した型が入ります。第二引数の$scopeにはそのASTのスコープの情報が入ります。getNodeType()Namespace_型を返すようにしているため、スコープはファイル全体となります。

ロジックの実装

最後に独自ルールの実装をします。

実装概要としては以下のような感じです。

  • コード全体のASTを対象とする
  • $scope->getFile()で現在解析しているファイルのパスを取得
  • 表記揺れを無くすため、改行コードを含めてファイルの先頭が"<?php\n\ndeclare(strict_types=1);\n\n"の文字列で始まるかどうかチェック
 public function processNode(Node $node, Scope $scope): array
    {
        $path = $scope->getFile();
        $file_content = file_get_contents($path);

        $pattern = "/^<\?php\n\ndeclare\(strict_types=1\);\n\n/";
        if (preg_match($pattern, $file_content)) {
            return []; // パターンにマッチした場合は問題なし
        }

        // RuleErrorBuilderを使ってエラーメッセージを作成
        return [
            RuleErrorBuilder::message('ファイルの先頭は<?php\n\ndeclare(strict_types=1);\n\n"で始まるようにしてください.')
                ->build(),
        ];
    }

最後のRuleErrorBuilderについて補足します。

この戻り値はプレーンな配列の文字列でも良いのですが、PHPStan1.11.xからError identifierという仕組みが導入される予定です。これによって、コード中でエラーを識別し特定の種類に合致するときだけエラーをignoreするといった制御ができるようになります。

さらに、上記のError identifierにはPHPStan2.0からカスタムルールからプレーンな文字列を返す機能が最終的になくなるといった記載があったため、可能な限り今のうちからRuleErrorBuilder::message()を利用することをオススメします。

カスタムルールのテスト

カスタムルールはPHPStan\Testing\RuleTestCaseを継承することでテストを書くことができます。

今回はテストの実装まではしませんでしたが、カスタムルールの実装ロジックが間違っていると適切なコードでもCIに失敗するようになってしまいプロジェクト全体の開発に影響がでることもあるため、複雑なロジックの場合はテストを書くことを推奨します。

詳細は、Testing | PHPStan を参照下さい。

カスタムルールを有効化

最後に、phpstan.neonにカスタムルール定義ファイルのネームスペースを追加します。

rules:
    - phpstan\Rules\RequireStrictTypesRule

注意点として、カスタムルールの実装でコンストラクタを利用している場合はservicesセクションを利用しなければなりません。詳しくは Registering the rule in the configurationを参照してください。

まとめ

以上、独自のPHPStanの独自拡張を導入した話でした。独自拡張では結構柔軟なことができるので、例えば「ある値を含む配列を引数に渡すとバグってしまう関数があるが、現状すぐにコードには手を入れるわけにもいかず、コードレビューでの指摘に頼ってしまっている」みたいなプロジェクト特有の事情がある場合、カスタムルールや動的戻り値型拡張などの利用を検討してみてはいかがでしょうか。

参考

さいごに

ヤプリでは、こうした小さな改善をコツコツやることも歓迎する文化があります。 もし少しでも気になったら、是非カジュアル面談してみませんか?

open.talentio.com

最後まで読んでいただきありがとうございました!