2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Lancers(ランサーズ)Advent Calendar 2024

Day 24

PHP-Parserで始めるメタプログラミング

Posted at

メタプログラミングとは、あるプログラムのコードを別のプログラムから作成あるいは変更するようなプログラミングのことを指します。grep検索を併用した一斉置換ではカバーが難しい一括変更を行えたり、パターンに沿ったコードの自動生成を行えて便利です。
PHPには PHP-Parser というライブラリがあり、PHPStan やさらにそれに依存する Rector などでも利用されています。
PHPStan や Rector の仕組みを理解したり、単純にメタプログラミングに触れるという意味でも、直接 PHP-Parser を使ってみる経験は有用です。
そこで、

  • PHP-Parser を使ったパースとコード変更の基本
  • PHP-Parser に用意された補助的なクラスや機能

を大まかに解説します。

PHP-Parser

PHPコードの字句解析・構文解析を行い、抽象構文木(Abstract Syntax Tree, 以下AST)の作成を行なえるライブラリです。

ASTは簡単に述べるとプログラムの本質的な構造を示すものです。一般的な木構造と同様、ノード(node: 節) とエッジ(edge: 枝)で図示することも可能ですが、PHP-Parserを扱う上では基本的に多数の Node が階層構造を作っているという点を認識しておけばあまり問題ないと思います。

Composer でのインストールコマンドは以下です。

composer require nikic/php-parser

パース処理の基本

ParserFactory から Parser インスタンスを生成、コードとなる文字列を渡すことで AST を作成できます。

<?php

// ...

use PhpParser\NodeDumper;
use PhpParser\ParserFactory;
use PhpParser\PhpVersion;

// Parser の作成
$parser = (new ParserFactory())->createForVersion(PhpVersion::fromString("8.4"));

$code = <<<'CODE'
<?php

namespace Math;

class Calculator
{
    private const PI = 3.14;

    public function circleArea(int $radius): float
    {
        return pow($radius) * PI;
    }
}
CODE;

// パースしてASTを作成
$ast = $parser->parse($code);

// AST を表示
$dumper = new NodeDumper();
echo $dumper->dump($ast);

AST は array<PhpParser\Node> 型、つまりノードの配列です。ほとんどのノードは子ノードを持ち、階層構造を形成しています。

上記のように NodeDumper を使うと以下のようにASTが表示できます。

出力結果
array(
    0: Stmt_Namespace(
        name: Name(
            name: Math
        )
        stmts: array(
            0: Stmt_Class(
                attrGroups: array(
                )
                flags: 0
                name: Identifier(
                    name: Calculator
                )
                extends: null
                implements: array(
                )
                stmts: array(
                    0: Stmt_ClassConst(
                        attrGroups: array(
                        )
                        flags: PRIVATE (4)
                        type: null
                        consts: array(
                            0: Const(
                                name: Identifier(
                                    name: PI
                                )
                                value: Scalar_Float(
                                    value: 3.14
                                )
                            )
                        )
                    )
                    1: Stmt_ClassMethod(
                        attrGroups: array(
                        )
                        flags: PUBLIC (1)
                        byRef: false
                        name: Identifier(
                            name: circleArea
                        )
                        params: array(
                            0: Param(
                                attrGroups: array(
                                )
                                flags: 0
                                type: Identifier(
                                    name: int
                                )
                                byRef: false
                                variadic: false
                                var: Expr_Variable(
                                    name: radius
                                )
                                default: null
                                hooks: array(
                                )
                            )
                        )
                        returnType: Identifier(
                            name: float
                        )
                        stmts: array(
                            0: Stmt_Return(
                                expr: Expr_BinaryOp_Mul(
                                    left: Expr_FuncCall(
                                        name: Name(
                                            name: pow
                                        )
                                        args: array(
                                            0: Arg(
                                                name: null
                                                value: Expr_Variable(
                                                    name: radius
                                                )
                                                byRef: false
                                                unpack: false
                                            )
                                        )
                                    )
                                    right: Expr_ConstFetch(
                                        name: Name(
                                            name: PI
                                        )
                                    )
                                )
                            )
                        )
                    )
                )
            )
        )
    )
)

stmts というプロパティを見ると、 Namespace ノードが Class ノードを持ち、さらに Class ノードは ClassConst ノードと ClassMethod ノードをもつ… とコードが階層構造で表現されているのがわかると思います。

vendor/bin/php-parse を実行することでもASTの確認ができます。PHP-Parserを利用したコードを書く時、対象のコードがASTでどのように表現されるかを確かめたくなることはよくあるので、知っておくと便利です。

vendor/bin/php-parse path/to/source.php

コードの書き換え

コードを書き換える場合は NodeTraverser と Node Visitor 、PrettyPrinterを使います。

<?php

// ...

$code = <<<'CODE'
<?php

class Sample
{
    public function greet(): string
    {
        return "Hello";
    }
}
CODE;

$ast = $parser->parse($code);

// ASTの変更
$traverser = new NodeTraverser();
$traverser->addVisitor(
    new class extends NodeVisitorAbstract {
        public function enterNode(Node $node){
            if ($node instanceof PhpParser\Node\Scalar\String_ 
                && $node->value === 'Hello') {
                    $node->value = 'Bye';
            }
        }
    }
);
$traverser->traverse($ast);

$printer = new PrettyPrinter\Standard();
echo $printer->prettyPrintFile($ast);

NodeTraverser は AST を探索しながら通過した全てのノードを、自身が保持する Visitor インスタンスに渡していきます。Visitor 内でノードの状態を変更した後に、PrettyPrinterを使用して AST をファイルに書き出せば変更後のコードが得られるというわけです。

Visitor は NodeVisitorAbstract クラスを継承して作成します。ノードが見つかったタイミングで実行される enterNode と 、ノードを離れるタイミングで実行される leaveNode がありますが、両方実装することはあまりなく、どちらか一方で事足ります。

Visitor にノードが渡される時、型としては PhpParser\Node 型になってしまうことと、あらゆるノードに変更を加えるわけではないので、条件式によってノードの型をより具体的な型(ここでは文字列リテラルを表す PhpParser\Node\Scalar\String_ )に絞りつつ、無関係なノードはスルーするように実装することが多いです。

上のコードは、単純な例ではありますが、 “Hello” という文字列があったら “Bye” という文字列に書き換えるよう指示しています。

出力結果
<?php

class Sample
{
    public function greet(): string
    {
        return "Bye";
    }
}
PrettyPrinter についての補足

PrettyPrinter は、デフォルトでは元のコードの開始行数などを考慮してくれないという問題があります。よく困るケースとしては、配列のデフォルト値付きのプロパティを持つクラスに変更をかける場合です。

<?php

// ... 

$parser = (new ParserFactory())->createForVersion(PhpVersion::fromString("8.4"));

$code = <<<'CODE'
<?php

class Sample
{
    private array $columns = [
        "foo",
        "bar",
        "baz",
    ];
    
    public function greet(): string
    {
        return "Hello";
    }
}
CODE;

$ast = $parser->parse($code);

$traverser = new NodeTraverser();
$traverser->addVisitor(
    new class extends NodeVisitorAbstract {
        public function enterNode(Node $node){
            if ($node instanceof PhpParser\Node\Scalar\String_ 
                && $node->value === 'Hello') {
                    $node->value = 'Bye';
            }
        }
    }
);

$printer = new PrettyPrinter\Standard();
echo $printer->prettyPrintFile($ast);
<?php

class Sample
{
    private array $columns = ["foo", "bar", "baz"];
    public function greet(): string
    {
        return "Bye";
    }
}

コードの意味としては期待通りに変更されていますが、それだけでなく元の配列が改行で区切られていたのに対し、一行で書く形に変わっています。また、プロパティとメソッドの間の空行が削除されています。フォーマットが勝手に変わってしまうのは避けたいところです。

このような元のコードのメタ情報を保持してほしい場合、 CloningVisitor を使いASTを複製して、変更前後の情報を比較した上で出力する printFormatPreserving を使います。

<?php

// ...

// 変更前のASTとトークンを保持
$originalAst = $parser->parse($code);
$originalTokens = $parser->getTokens();

// CloningVisitorを使用して、ASTを複製
$traverser = new NodeTraverser();
$traverser->addVisitor(new CloningVisitor());
$newAst = $traverser->traverse($originalAst);

// 必要な変更を複製後のASTに加える
$traverser = new NodeTraverser();
$traverser->addVisitor(
    new class extends NodeVisitorAbstract {
        public function enterNode(Node $node){
            if ($node instanceof PhpParser\Node\Scalar\String_ 
                && $node->value === 'Hello') {
                    $node->value = 'Bye';
            }
        }
    }
);
$traverser->traverse($newAst);

$printer = new PrettyPrinter\Standard();
echo $printer->printFormatPreserving($newAst, $originalAst, $originalTokens);

その他の機能

PHP-Parser にあらかじめバンドルされている補助的なクラスや機能について、軽く触れておきます。

Nodeを取り除く

enterNodeleaveNode は返す値によっては特殊な結果が得られます。対象のノードを変更するのではなく単純に取り除きたい場合、 NodeVisitor::REMOVE_NODE を返すことで実現できます。

<?php

// ...

$code = <<<'CODE'
<?php

class Sample
{
    public function greet(): string
    {
        return "Hello";
    }
}
CODE;

$ast = $parser->parse($code);

// メソッドを取り除く
$traverser = new NodeTraverser();
$traverser->addVisitor(
    new class extends NodeVisitorAbstract {
        public function enterNode(Node $node){
            if ($node instanceof Node\Stmt\ClassMethod) {
                return NodeVisitor::REMOVE_NODE;
            }
        }
    }
);
$traverser->traverse($ast);

$printer = new PrettyPrinter\Standard();
echo $printer->prettyPrintFile($ast);
出力結果
<?php

class Sample
{
}

Node Builder

各 Node クラスを用いてコードを組み立てるのは、直接コードを書くよりも少々大変です。
それを補助するため、PHP-Parser にはあらかじめ、複雑なノードを組み立てるための Builder クラス群が用意されています。

<?php

// ...

use PhpParser\Builder;
use PhpParser\Node;

$method = (new Builder\Method("greet"))
    ->makePublic()
    ->setReturnType("string")
    ->addStmt(new Node\Stmt\Return_(
        new Node\Scalar\String_("Hello")
    ))
    ->getNode();

$class = (new Builder\Class_("Sample"))
    ->addStmt($method)
    ->getNode();

$printer = new PrettyPrinter\Standard();
echo $printer->prettyPrintFile([$class]);
出力結果
<?php

class Sample
{
    public function greet(): string
    {
        return 'Hello';
    }
}

ただし、上のように複数のノードに分けて作成しつつあとで組み合わせるというのは実際のコードの順序とは異なるし、いちいち変数で受ける必要があるのも少々面倒です。

BuilderFactory を使うと、より自然に表現できます。

<?php

// ...

use PhpParser\Node;
use PhpParser\BuilderFactory;

$factory = new BuilderFactory();
$class = $factory->class("Sample")
    ->addStmt(
        $factory->method("greet")
            ->makePublic()
            ->setReturnType("string")
            ->addStmt(new Node\Stmt\Return_(
                new Node\Scalar\String_("Hello")
            ))
    )
    ->getNode();

$printer = new PrettyPrinter\Standard();
echo $printer->prettyPrintFile([$class]);

NameResolver

名前空間をもつクラスなどを利用する場合、そのままパースしても名前解決は行われません。

<?php

// ...

$parser = (new ParserFactory())->createForVersion(PhpVersion::fromString("8.4"));

$code = <<<'CODE'
<?php

use PHPUnit\Framework\TestCase;

class SampleTest extends TestCase
{
}
CODE;

$ast = $parser->parse($code);
出力結果
array(
    0: Stmt_Use(
        type: TYPE_NORMAL (1)
        uses: array(
            0: UseItem(
                type: TYPE_UNKNOWN (0)
                name: Name(
                    name: PHPUnit\Framework\TestCase
                )
                alias: null
            )
        )
    )
    1: Stmt_Class(
        attrGroups: array(
        )
        flags: 0
        name: Identifier(
            name: SampleTest
        )
        extends: Name(
            name: TestCase
        )
        implements: array(
        )
        stmts: array(
        )
    )
)

Class の継承元の名前が単なる TestCase のままで、 PHPUnit\Framework\TestCase クラスであるということが、このノードの情報からのみでは判断できません。
これは特に型の判定を行いたい時などに非常に困ります。クラス名が同じでも属する名前空間が異なれば当然別のクラスですが、その2つを区別することができません。
このような場合のために、 NameResolver が用意されています。

<?php
// ...

$ast = $parser->parse($code);

$traverser = new NodeTraverser();
$traverser->addVisitor(new NameResolver());
$ast = $traverser->traverse($ast);

$dumper = new NodeDumper();
echo $dumper->dump($ast);
出力結果
array(
    0: ...
    1: Stmt_Class(
        attrGroups: array(
        )
        flags: 0
        name: Identifier(
            name: SampleTest
        )
        extends: Name_FullyQualified(
            name: PHPUnit\Framework\TestCase
        )
        implements: array(
        )
        stmts: array(
        )
    )
)

先ほどのコードと異なり、 PhpParser\Node\Name\FullyQualified 型に置き換えられ、中身の名前も完全識別名に変わっていることがわかります。
名前解決のロジックを自分で書かなくて良いのは、非常に有難いことです。

FindingVisitor, FirstFindingVisitor

単純に何らかの条件を満たすノードを発見したいという場合、自前でロジックを書く必要はありません。
FindingVisitor は渡されたコールバックを Node に対して実行し、条件を満たした Node のみを全て保持する Visitor です。

<?php declare(strict_types=1);

namespace PhpParser\NodeVisitor;

// ... 

class FindingVisitor extends NodeVisitorAbstract {
    
    // ...
    
    public function getFoundNodes(): array {
        return $this->foundNodes;
    }
    
    // ...
    
    public function enterNode(Node $node) {
        $filterCallback = $this->filterCallback;
        if ($filterCallback($node)) {
            $this->foundNodes[] = $node;
        }

        return null;
    }
}

FirstFindingVisitor は最初に条件を満たした Node だけを1つ保持し、その時点で探索をストップさせます。

<?php declare(strict_types=1);

namespace PhpParser\NodeVisitor;

// ... 

class FirstFindingVisitor extends NodeVisitorAbstract {
    
    // ... 
    
    public function getFoundNode(): ?Node {
        return $this->foundNode;
    }

    // ... 

    public function enterNode(Node $node) {
        $filterCallback = $this->filterCallback;
        if ($filterCallback($node)) {
            $this->foundNode = $node;
            return NodeVisitor::STOP_TRAVERSAL;
        }

        return null;
    }
}

どちらの場合もコールバック関数は引数に PhpParser\Node をとり bool を返す関数にするのが基本です。


是非、実際にParserを触って色んなコードを操作してみてください。

2
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?