Date

PHP-ParserでPHPコードを拡張・作成するメタプログラミング入門

  • Share on Hatena
  • Share on Twitter
  • Share on Facebook
Go back to top
目次背景 要約 nikic/PHP-Parser とは インストール 基本編 PHP コードの構文木への変換 構文木の変更 PHP コードへの出力 改行を維持した出力 コード全文 改行読み捨て 改行保持 応用編 細かい Node の歩き方 名前解決 ノードの除去 トラバースの中断、パフォーマンス改善 1. 子ノードのトラバース回避 2. トラバースの中止 まとめ 参考

背景

仕事で、レガシーなソースコードに名前空間や PHPDoc を機械的に付与するために調べました。

基本的に、nikic/PHP-Parser の公式 doc の和訳まとめです。 英語に抵抗がなく、急いでない方は公式を読まれると良いと思います。

要約

  • PHP-Parser で既存のファイルを最小限の変更で拡張できる
  • 名前空間を付与したり、特定の関数の書き換え、PHPDoc の追加なども可能
  • かなり自由な PHP のメタプログラミングができる

nikic/PHP-Parser とは

PHP-Parserは、PHP で書かれた PHP パーサーです。PHP だけで動くので、使いやすいです。

PHP 5.2 から PHP 7.4 のコードを解析でき、ヒューマンリーダブルな PHP ファイルに出力できます。安定版の v4.0 ~ の実行環境は、PHP7.1 以上です。

静的解析ライブラリのPHPStanの、ベースの PHP の解釈にも採用されていたり、安心感があります。

インストール

composer でインストールできます。

composer require nikic/php-parser

基本編

構文木に変換、ヒューマンリーダブルなコードに戻す流れを追います。

PHP コードの構文木への変換

パーサー にコード文字列を渡すと、変換してくれます。また細かくカスタマイズもできます。

<?php use PhpParser\Error; use PhpParser\NodeDumper; use PhpParser\ParserFactory; $code = <<<'CODE' <?php function test($foo) { var_dump($foo); } CODE; $parser = (new ParserFactory)->create(ParserFactory::PREFER_PHP7); try { $ast = $parser->parse($code); } catch (Error $error) { echo "Parse error: {$error->getMessage()}\n"; return; } $dumper = new NodeDumper; echo $dumper->dump($ast) . "\n";

パースした結果、下記のようなオブジェクトの配列構造になっています。 この配列の中身をチェックし、任意のものに書き換えたり、新しいオブジェクトを追加することで、新しい PHP を作成します。

array( 0: Stmt_Function( byRef: false name: Identifier( name: test ) params: array( 0: Param( type: null byRef: false variadic: false var: Expr_Variable( name: foo ) default: null ) ) returnType: null stmts: array( 0: Stmt_Expression( expr: Expr_FuncCall( name: Name( parts: array( 0: var_dump ) ) args: array( 0: Arg( value: Expr_Variable( name: foo ) byRef: false unpack: false ) ) ) ) ) ) )

構文木の変更

PHP-Parser にはNodeVisitorという interface があり、実装した class をNodeTraverserに追加すると、所定のイベントで呼び出されて構文木を書き換えたり値を書き換えられます。

interface NodeVisitor { public function beforeTraverse(array $nodes); public function enterNode(Node $node); public function leaveNode(Node $node); public function afterTraverse(array $nodes); }

beforeTraverse()およびafterTraverse()は、トラバーサルの前と後に呼ばれ、全体の AST を渡されます。これらを使用して、必要な状態のセットアップまたはクリーンアップを実行できます。

enterNode()メソッドは、ノードが最初に検出されたときに、その子が処理される前に呼び出されます。一方で、leaveNode()メソッドは、すべての子が訪問された後に呼び出されます。

実際には、これを実装したNodeVisitorAbstractを継承して、使うことが多いと思います。 今回は、var_dumpprintに書き換えます。

use PhpParser\Node; use PhpParser\NodeVisitorAbstract; class VarDumpConvertPrintVisitor extends NodeVisitorAbstract { public function leaveNode(Node $node) { if ($node instanceof Node\Expr\FuncCall && $node->name->parts->getLast() == 'var_dump') { $node->name->parts = ['print']; } } } $traverser = new NodeTraverser; $traverser->addVisitor(new VarDumpConvertPrintVisitor); $stmts = $traverser->traverse($stmts);

わかりづらいですが、下記のようにExpr_FuncCallの中身のname->partsprintに変更されると思います。

0: Stmt_Expression( expr: Expr_FuncCall( name: Name( parts: array( 0: print ) ) args: array( 0: Arg( value: Expr_Variable( name: foo ) byRef: false unpack: false ) ) )

PHP コードへの出力

一番シンプルな出力方法は、下記です。

$prettyPrinter = new PhpParser\PrettyPrinter\Standard(); $newCode = $prettyPrinter->prettyPrintFile($stmts);
<?php function test($foo) { print($foo); }

しかしこの方法だと、「既存のコードのリファクタリングでは、改行などに差分がでる」という問題があります。 構文木に解体する際に、改行コードが読み捨てられてしまうためです。

改行を維持した出力

PHP-Parser v4.0 以降、コードのフォーマット(変更されていない AST ノード)を保持し、変更または新しく挿入されたコードのみをフォーマットするモードが利用できます。

ちょっと記述が増えますが、必要最低限の変更に抑え上記の問題を回避できます。

※まだ実験段階の機能なので、変更があるかもしれません。

https://github.com/nikic/PHP-Parser/blob/master/doc/component/Pretty_printing.markdown

use PhpParser\{Lexer, NodeTraverser, NodeVisitor, Parser, PrettyPrinter}; $lexer = new Lexer\Emulative([ 'usedAttributes' => [ 'comments', 'startLine', 'endLine', 'startTokenPos', 'endTokenPos', ], ]); $parser = new Parser\Php7($lexer); $traverser = new NodeTraverser(); $traverser->addVisitor(new NodeVisitor\CloningVisitor()); $printer = new PrettyPrinter\Standard(); $oldStmts = $parser->parse($code); $oldTokens = $lexer->getTokens(); $newStmts = $traverser->traverse($oldStmts); // Nodeを組み替える $newCode = $printer->printFormatPreserving($newStmts, $oldStmts, $oldTokens);

コード全文

改行読み捨て

https://gist.github.com/komtaki/514f13fa07f4e8bdd9bd0d4fa61e0719

改行保持

https://gist.github.com/komtaki/7e2163a958440e99b630bbbe1512d368

応用編 細かい Node の歩き方

Visitor にわたってくるNodeオブジェクトはとても多様です。 NodeDumperを使って、「自分が拡張したいオブジェクトは、どんな形で渡ってくるのか」確認することから始めるのがよいと思います。

ここでは、一部特殊な機能について紹介します。

名前解決

NameResolverを使用することで、基本的な class の名前解決ができます。

しかし、名前空間内の修飾されていない関数と定数名は解決できません。

例えば、Foo名前空間内のstoren()は、名前空間\Foo\strlen()またはグローバル\strlen()のいずれかを参照できます。しかし、PHP-Parser にはこれを決定するために必要な情報がないためです。

https://github.com/nikic/PHP-Parser/blob/master/doc/component/Name_resolution.markdown

$nameResolver = new PhpParser\NodeVisitor\NameResolver; $nodeTraverser = new PhpParser\NodeTraverser; $nodeTraverser->addVisitor($nameResolver); // Resolve names $stmts = $nodeTraverser->traverse($stmts);

ノードの除去

トラバース中に、特定のタイプを返却すれば、ノードを除去できます。

public function leaveNode(Node $node) { if ($node instanceof Node\Stmt\Return_) { // すべてのreturnを削除します。 return NodeTraverser::REMOVE_NODE; } }

トラバースの中断、パフォーマンス改善

複数の Visitor を設定している場合、Node 数の増加によって速度がおそくなるケースがあります。

特定の Node を探している時などは、下記のようにトラバースを終了できます。

1. 子ノードのトラバース回避

private $classes = []; public function enterNode(Node $node) { if ($node instanceof Node\Stmt\Class_) { $this->classes[] = $node; return NodeTraverser::DONT_TRAVERSE_CHILDREN; } }

2. トラバースの中止

private $class = null; public function enterNode(Node $node) { if ($node instanceof Node\Stmt\Class_) { $this->class = $node; return NodeTraverser::STOP_TRAVERSAL; } }

まとめ

PHP-Parser で、名前空間を付与したり、変数を書き変えたり、PHPDoc を追加したり動的に PHP コードを拡張できます。

ただ限界はあり、コードの整形などは出来ないので、他のツールも組み合わせて柔軟に対応していくのがよいと思います。

参考

下記、参考にさせて頂きました。ありがとうございます。