はじめに
これは nikic/PHP-Parser に興味があるけどまだあんまり調べてないひと向けの記事です。
実を言うと半年以上前の自分のメモから内容を引っ張ってきているため、情報が古かったり間違っていたりするかもしれません。そんな感じでよろしくお願いします。
nikic/PHP-Parserって何
nikic/PHP-Parser は php で書かれた php パーサーです。php だけあれば動くので、かなり取り回ししやすいです。
個人的な感覚ですが、php をパースをしたいなんていう欲求は仕事のプロジェクト以外であんまり起らないような気がします。そういう状況下だと100パーセント php だけで動くというのは大きな強みです。php のパーサーは php 以外の言語で書かれたものも散見されるのですが、特に仕事の環境では、php のパースのために node.js や go といった環境を用意するといったことはできるなら避けたいところです。
また、php7 になって AST が導入されたことは色々な所で話題になっていますが、あれはピュアな php に AST を触るための API が追加されたとかいう話ではないです。一応。もちろん extension を書いて c で php のレイヤーから struct zend_ast
等を触る API を追加したりはできます。実際 nikic 先生自身 nikic/php-ast でそういうのを作っています。が、やはり extension 追加も仕事場では若干敷居が高かったりしますし、それ以前にパーサーを動かすのに php7 が要るのでバージョン追いつけてなかったら厳しいです。docker でそういう環境を用意しておくのはいいアイディアだと思いますが、パースは結構リソースを食う作業だと思うのでその辺が貧弱になると折角 nikic/php-ast が早いのにもったいないなという気はします。結局初めから nikic/PHP-Parser 使った方早かったみたいな話にもなりかねないのではないでしょうか。
というわけで nikic/PHP-Parser の存在意義は php7 登場後も変わらず大きいです。
使い方
使い方は公式docを読むのが一番ですが、ここではシンプルに <?php echo 'hi';
というコードを <?php echo 'hello';
というコードに書き換える例をとりあげてみます。
構文木(AST)をつくる
parser に string を渡すと PhpParser\Node クラスの配列が返ってきます。
$code = <<<CODE
<?php
echo 'hi';
CODE;
$parser = (new PhpParser\ParserFactory)->create(ParserFactory::PREFER_PHP7);
$stmts = $parser->parse($code);
array (
0 =>
PhpParser\Node\Stmt\Echo_::__set_state(array(
'exprs' =>
array (
0 =>
PhpParser\Node\Scalar\String_::__set_state(array(
'value' => 'hi',
'attributes' =>
......略
)
というわけで簡単にできるのですが少しそっけないので噛み砕くと、このコードは string という human readable な状態から、PhpParser\Node[] という machine readable な状態に変換しています。machine readable なので編集したり解析したりといったことがプログラムから簡単に行えるようになります。
構文木(AST)を書き換える
上記で得られた PhpParser\Node[] を走破し、書き換えます。
PHP-Parser には NodeVisitor
という interface があり、これを traverser に add すると所定のイベントで呼び出されて構文木を書き換えたり値を書き換えたりできます。実際には NodeVisitor を実装した NodeVisitorAbstract
を使うことが多かったりしますが、まあとりあえずコード見たほうが早いと思うので書きます。
Visitor の作り方はこんな感じです。
use PhpParser\Node;
use PhpParser\NodeVisitorAbstract;
class MyNodeVisitor extends NodeVisitorAbstract
{
public function leaveNode(Node $node)
{
if ($node instanceof Node\Scalar\String_) {
$node->value = 'hello';
}
}
}
これを以下のように add して、traverse(走破)すると新しい構文木を得ることができます。
$traverser = new NodeTraverser;
$traverser->addVisitor(new MyNodeVisitor);
$stmts = $traverser->traverse($stmts);
array (
0 =>
PhpParser\Node\Stmt\Echo_::__set_state(array(
'exprs' =>
array (
0 =>
PhpParser\Node\Scalar\String_::__set_state(array(
'value' => 'hello',
'attributes' =>
......略
)
ちょっとわかりづらいですが $stmts[0]->exprs の値が hello
に書き換わっています。
構文木(AST)を元に戻す
新しい構文木を再び human readable な状態に戻すためには PrettyPrinter を使います。
$prettyPrinter = new PhpParser\PrettyPrinter\Standard();
echo $prettyPrinter->prettyPrintFile($stmts);
prettyPrintFile で以下を出力できます。
<?php
echo 'hello';
サンプルコードまとめるとこんな感じです
どうやって動くのか
nikic/PHP-Parser がどうやって構文木を作っているのか軽く書いておきます。
まず文字列をパースするときの一般的なお作法として字句解析・構文解析というステップがありますが、nikic/PHP-Parser はこれらをいい意味ですごく普通なやり方で行います。
まず字句解析には token_get_all を使います。これは php に標準で入っている API です。普通ですね。
次に構文解析ですが、nikic/PHP-Parser はこれを行うためにまず php 本体からコピペしてきた yacc ファイル用意しています。以下を見比べてみるとほとんど一緒なことがわかります。
この yacc ファイルを元に、kmyacc というツールで php を生成しています。kmyacc は yacc ファイルから php とか perl とか色々な言語でパーサーを生成できるやつです。
そして完成したのがこういうパーサーです。
何かコンパイラとか作ったことがある人ならお馴染みの話だと思いますが、基本このあたり難しい話なのでこの位にさせて下さい。まあとにかく人類はずっと前から文字列をパースする方法を研究しており、nikic/PHP-Parser はその方法に則っています。というか php 本体も他のプログラミング言語もこんな感じのものが多いはずです。文法の定義ファイルそれ自体とパーサーの精製方法を php 本体と揃えいるため精度・速度ともに安心できます。
ちなみに冒頭で多言語で書かれた php パーサーがあるという話をしましたが、自分が見つけた中では割と我流で字句解析も構文解析も頑張ってしまっていてどうなんだろうというものも多かったです。(https://github.com/hnw/PhpParser/ 先生のとかは違いましたが)
ハマりどころ
さてこんな感じで php のパースができるわけですが、php のパースをしたい人のほとんどは単にパースするのが目的ではなく、その結果を元にコードを書き換えたいという人なのではないでしょうか。そのための方法として Visitor を使って構文木を書き換える方法を紹介しましたが、実はこのやり方には少し問題があります。
というのは "\n"
の値を持つ T_WHITESPACE トークンが読み捨てられてしまうというものです。空白読み捨ては lexer の大事な仕事なので当然といえば当然なのですが、これが読み捨てられた状態の構文木を再構築すると、元のコードと若干のずれが出てしまいます。
例えばこれが
class A
{
public sample()
{
$a = 1;
return new Klass;
}
}
こうなってしまいます。
class A
{
public sample()
{
$a = 1;
return new Klass;
}
}
気にしなければ気にしないでいいのですが、世の中には symfony のように「return の前には一行空行を入れろ」といった規約もあるので、気になる人は気になる話です。というか僕が実際に PrettyPrinter を生きているコードに試してみたときは、空行がなくなってグチャっとひと塊になってしまうのは予想以上に読みづらかったです。あんま神経質じゃないひとでも許容しずらいだろうなと思いました。
この問題は既に nikic/PHP-Parser の issue として議論されています。
解決策のひとつとして提示されているのは PrettyPrinter を使わずに(!)travarse している最中に変更箇所の filepos を保存しておいて、全て終わった後に string に変更を加えるというものです。
コメント中にありますが、こんな感じのクラスを用意して書き換えます。
手前味噌で恐縮ですが、上記の MutableString は insert,remove というIFに別れていて若干無駄&わかりづらい部分があるかと思ったので、自分がやった際は以下のようなクラスにしました。
使い方などは gong023/namae-space を見て頂ければと思います(単純に nikic/PHP-Parser の使い方サンプルとしてもお役に立てるかもしれません)
それでは nikic/PHP-Parser でよい静的解析ライフを
~追記~
今改めて見たら https://github.com/nikic/PHP-Parser/issues/41#issuecomment-269230733 でこの問題改善されてそうですね。少し整理したら最後の項目はアップデートするかもしれません。