iris

PHPStan 始めてみた


最近触り始めた PHPStan について学習記録になります!

docker で laravel 環境を作りそこへ phpstan を入れ、問題のあるコードがどのように検出されるのか試していきたいと思います

まず docker-compose で nginx と php の環境を作り、php の環境にて composer で laravel のプロジェクトを作成しました

Laravel を使う場合 PHPStanラッパーがあるということで larastan/larastanを入れます

問題のあるコードを書きます (laravel を使う必要は正直ない)

<?php

namespace App\Libraries;

class Hoge
{
    function Hoge()
    {
        $rows = [
            1 => self::a(-1),
            2 => self::a(1),
            3 => $this->a(1),
        ];

        $head = $rows{1};

        while (list($key, $value) = each($rows)) {
            $result[$key] = $value;
        }
    }

    private function a(int $b): int 
    {
        return $b + 1;
    }
}

設定ファイル(phpstan.neon)は一旦以下で

parameters:
    paths:
        - app/Libraries
        - app/Http
        - tests/
    level: 0

実行すると each は存在しないメソッドで検出できていますが、波括弧配列アクセスやE_DEPRECATED なものが検知されていませんでした

非静的メソッドを静的メソッドとして呼び出すのは 8.0 からはエラーになるのであれ…と思ったんですが、これについては後述します

/var/www/html/src # ./vendor/bin/phpstan analyse --memory-limit=1G
Note: Using configuration file /var/www/html/src/phpstan.neon.
 17/17 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ ------------------------------------------------------- 
  Line   app/Libraries/Hoge.php                                 
 ------ ------------------------------------------------------- 
  17     Function each not found.                               
         💡 Learn more at                                       
            https://phpstan.org/user-guide/discovering-symbols  
 ------ ------------------------------------------------------- 

練習がてらカスタムルールを作ってみようと思ったら、結構これが難しそうでした

カスタムルールを作るには、PHPStan\Rules\Rule インターフェースを実装する必要があるらしく、インターフェースには getNodeType と processNode という 2つのメソッドが定義されています

getNodeTypeメソッドは AST(抽象構文木)ノードのタイプを返し、解析する際に指定したノードタイプに遭遇する度、processNodeメソッドが呼び出され、その際の第一引数は ASTノードが、第二引数には現在のスコープが入るとのことです

AST は初見だったので調べた所 PHPのプログラムを実行する際に利用されるデータ構造のようです (PHP7かららしい)

コンパイルの流れを調べると、「字句解析 (トークンに分解)」→「構文解析 (AST 作成)」→ 「Opcode生成 (中間コード)」とあり

字句解析は意味を持つ最小単位に分解するらしいです これ?

この辺りはスライドの図を見ると分かりやすかったです

更にイメージを沸かせるためnikic/php-parserのライブラリ (PHPStan でも使われている) を使い、検証したいファイルはどうなってるかをざっくり見てみたりもしました

        $code = file_get_contents(__DIR__ . '/../../Libraries/Hoge.php');
        $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7);
        $stmts = $parser->parse($code);
        echo "<pre>";
        echo print_r($stmts, true);
        echo "</pre>";

これらを前提にドキュメント見つつ直ぐ書けそうなルールに絞り 2つ作ってみました

1つ目はクラス名と同じメソッド名を持ち __construct が未定義なクラスの検知です

<?php declare(strict_types=1);

namespace PHPStan\Rules;

use PhpParser\Node;
use PhpParser\Node\Stmt\ClassMethod;
use PHPStan\Analyser\Scope;
use PHPStan\Rules\Rule;

class SameNameMethodAsClassRule implements Rule
{
    public function getNodeType(): string
    {
        return ClassMethod::class;
    }

    /**
     * @param ClassMethod $node
     * @param Scope $scope
     * @return string[] errors
     */
    public function processNode(Node $node, Scope $scope): array
    {
        $methodName = $node->name->name;

        $classReflection = $scope->getClassReflection();
        $classShortName = end(explode('\\', $classReflection->getName()));

        if ($methodName === $classShortName) {
            if ($classReflection && !$classReflection->hasMethod('__construct')) {
                return ["Class $classShortName has a method with the same name '$methodName' and does not have a __construct method"];
            }
        }
        return []
    }
}

もう一つは非静的メソッドを静的メソッドとして呼び出してるメソッドの検知です

Calling static method non-staticallyを見ると self:: は静的コンテキストに限定されていない・言語的には非静的メソッドを self:: を使って呼び出すことができる・今後のサポートから外れる可能性があるとのことです

今後サポート外れたらエラー出て検出できるだろうけど折角なので作成してみました

<?php declare(strict_types=1);

namespace PHPStan\Rules;

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

class StaticCallToNonStaticMethodRule implements Rule
{
    public function getNodeType(): string
    {
        return StaticCall::class;
    }

    /**
     * @param ClassMethod $node
     * @param Scope $scope
     * @return string[] errors
     */
    public function processNode(Node $node, Scope $scope): array
    {
        $methodName = $node->name->name;

        $reflectionClass = $scope->getClassReflection();
        if (!$reflectionClass->hasMethod($methodName)) {
            return [];
        }

        $reflectionMethod = $reflectionClass->getMethod($methodName, $scope);
    
        if (!$reflectionMethod->isStatic()) {
            return ["Calling a non-static method as a static method. '{$methodName}'."];
        }

        return [];

    }
}

これを phpstan/Rules というディレクトリを作成して配置し、設定ファイルに追記します

parameters:
    paths:
        - app/Libraries
        - app/Http
        - tests/
    level: 0
    customRulesetUsed: true # 追加
rules:
    - PHPStan\Rules\SameNameMethodAsClassRule # 追加
    - PHPStan\Rules\StaticCallToNonStaticMethodRule # 追加

実行するとこんな感じで検知されるようになります

/var/www/html/src # ./vendor/bin/phpstan analyse --memory-limit=1G
Note: Using configuration file /var/www/html/src/phpstan.neon.
 17/17 [▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓] 100%

 ------ ------------------------------------------------------------------ 
  Line   app/Libraries/Hoge.php                                            
 ------ ------------------------------------------------------------------ 
  7      Class Hoge has a method with the same name 'Hoge' and does not have a __construct method.                                              
  10     Calling a non-static method as a static method. 'a'.              
  11     Calling a non-static method as a static method. 'a'.              
  17     Function each not found.                                          
         💡 Learn more at                                                  
            https://phpstan.org/user-guide/discovering-symbols             
 ------ ------------------------------------------------------------------ 

一応ルール置く場所は、autoload される場所でないといけなかったので、dev の方に追加してます

    "autoload-dev": {
        "psr-4": {
            "Tests\\": "tests/",
            "PHPStan\\": "phpstan/"
        }
    },

phpstan ではこのようにルールを追加することができます

phpstan が提供しているルールもいくつかあるようで phpstan/phpstan-phpunitphpstan/phpstan-strict-rules も今回は追加してみました

composer で追加して設定ファイルに include するだけです!

includes:
  - vendor/phpstan/phpstan-phpunit/extension.neon
  - vendor/phpstan/phpstan-strict-rules/rules.neon

コード品質を高めたり、PHP8 へのアップデートする際の既存コードの問題箇所への検知にとても役立つものなので、活用していきたい限りです!