nikic/PHP-Parser を使ってPHPコードをパースして出来る事とサンプル

# nikic/PHP-Parser
https://github.com/nikic/PHP-Parser
PHPをASTとしてパースし、サクッとコード変換ツールを作成するのに便利なライブラリの紹介とサンプルです

# 対象読者
* PHPのコードをツールで機械的に安全に一斉変換したい
* preg_match/preg_replaceだと、不安が残る
* Rectorが動かなかった
* PHP-Parserで変換処理を書こうと思ってる矢先の人

といういるのかいないのか分からない超限定的な状況の方 (自分)

# 書くこと
* 使おうと思った経緯
* 変換したい内容と、実現するコードのサンプル

# 書かないこと
* 導入方法や、機能の紹介
    * 懇切丁寧なドキュメントがあるので、そちらを参考にして下さい。

# 経緯
CakePHP1.3からCakePHP3系にバージョンアップをしたい中で、[Rector](https://github.com/rectorphp/rector)という便利なツールを使いたかったのですが、そちらが変換対象コードの名前空間によるautoloadが前提になっていたので、CakePHP1.3のrequire全盛のコードをすべてuseに書き直してしまおうという所が発端です。ついでに、実験的にいくつかの変換を行ってみました。

なお、記載のコードはプロダクションコードではなく、私が業務の合間に裏でコソコソ実験しているコードなので、改善の余地あり&改善提案求むなものですのでご了承下さい。

# 実行
* 特に、実行コマンドがあるわけではなく、自分でphp書いてそれを実行するだけです
* サンプルに `run.php` として載せます

# サンプル
## 変換要件
* requireをuseに変換する
  * requireのパスを解析して、useにする
  * `c_**.php` は `C**Component` に書き換える
  * DSという定数は/と解釈し、\に変換する
  * APP/CAKEをApp/Cakeに変える
* $this->request->dataへの代入をやめ、withDataメソッドを使うように変更する
  * 配列のキーが変数の場合も、いい感じにする
* 特定のディレクトリは変換対象外とする

言葉では説明しにくいのでdiffです。
対象コードでは以下の様な変換をかける必要があったので、それを満たす様に実装しました。

```diff
 <?php

-require_once APP . 'path/to/app.php';
-require_once APP . 'path' . DS . 'to' . DS . 'func.php';
-require_once APP . 'controllers' . DS . 'components' . DS . 'c_app.php';
-require_once CAKE . 'tests' . DS . 'lib' . DS . 'test_manager.php';
+use App\Path\To\App;
+use App\Path\To\Func;
+use App\Controller\Component\CAppComponent;
+use Cake\Test\Lib\TestManager;

 class TestController
 {
     public function __construct()
     {
-        $this->request->data['url'] = array('data' => 'value');
+        $this->request = $this->request->withData("url", array('data' => 'value'));
         $pass = 'pass';
-        $this->request->data[$pass]['sub'] = array('data' => 'value');
+        $this->request = $this->request->withData("{$pass}.sub", array('data' => 'value'));
     }
 }
```

## ディレクトリ構成
```
.
├── PHP-Parser
│   ├── その他のコード(git cloneなりcomposer installなり)
│   ├── composer.json
│   ├── scripts
│   │   └── run.php
│   └── visitors
│       ├── Request.php
│       └── RequireToUses.php
└── test
    └── app
        └── contrllers
            └── test_controllers.php

```

## 実行するPHPを作る
実行は、`php run.php`とするだけなので、まずそれを作ります。

```php:run.php
<?php
$dir = dirname(__FILE__);
require $dir . '/../vendor/autoload.php';

use PhpParser\{
    ParserFactory,
    Parser,
    PrettyPrinter,
    NodeTraverser,
    NodeVisitor,
    NodeDumper,
    NodeVisitor\NameResolver,
    Lexer
};

// 変換の定義。これをforeachする方がコード重複がなくて済むので適当に実装した
$configs = [
   [
       'target' => 'app/controllers',
       'exclude' => ['.git', 'vendors', 'plugins'],
       'visitors' => [
           'Visitor\RequireToUses',
           'Visitor\Request',
       ]
    ],
    // [
    //     'target' => 'app/controllers/components',
    //     'exclude' => ['.git', 'vendors', 'plugins'],
    //     'visitors' => [
    //         'Visitor\SomeVisitor'
    //     ]
    // ]
];

try {
    $dir = dirname(__FILE__) . '/../../test/';

    $printer = new PrettyPrinter\Standard;

    $nodeDumper = new NodeDumper;

    // READMEのPrettyPrinterを使うと、空行の削除など余計な整形がかかってしまうので、対応策が提示されており
    // そちら(formatting-preserving-pretty-printing)の書き方を参考にしています
    // @see https://github.com/nikic/PHP-Parser/blob/master/doc/component/Pretty_printing.markdown#formatting-preserving-pretty-printing
    $lexer = new Lexer\Emulative([
        'usedAttributes' => [
            'comments',
            'startLine', 'endLine',
            'startTokenPos', 'endTokenPos',
        ],
    ]);

    $parser = (new ParserFactory)->create(
        ParserFactory::PREFER_PHP7,
        $lexer
    );

    $traverser = new NodeTraverser;
    $traverser->addVisitor(new NodeVisitor\CloningVisitor());

    foreach ($configs as $config) {
        $target = $dir . $config['target'];
        $exclude = $config['exclude'] ?? [];

        // $config['exclude']に記載したディレクトリを変換対象外にするフィルタを定義します
        $filter = function ($file, $key, $iterator) use ($exclude) {
            if ($iterator->hasChildren() && !in_array($file->getFilename(), $exclude)) {
                return true;
            }
            return $file->isFile();
        };
        $files = new \RecursiveIteratorIterator(
            new RecursiveCallbackFilterIterator(
                new RecursiveDirectoryIterator(
                    $target,
                    RecursiveDirectoryIterator::SKIP_DOTS),
                $filter
            )
        );
        // .php なファイルだけすべて取得します
        $files = new \RegexIterator($files, '/.php$/');

        // $config['visitors']に記載したVisitorを追加します
        foreach ($config['visitors'] as $visitor) {
            $traverser->addVisitor(new $visitor());
        }

        // 先程取得したファイルをぐるぐるします
        foreach ($files as $file) {
            try {
                echo $file . PHP_EOL;

                // 変換元コードの取得
                $code = file_get_contents($file->getPathName());

                // こちらもformatting-preserving-pretty-printingの書き方です
                $oldStmts = $parser->parse($code);
                $oldTokens = $lexer->getTokens();

                // パース済みの元ASTをVisitorが走査して変換したASTを生成します
                $newStmts = $traverser->traverse($oldStmts);
                // AST → PHPのコード
                $newCode = $printer->printFormatPreserving($newStmts, $oldStmts, $oldTokens);

                ##### Debug #####
                //echo $nodeDumper->dump($oldStmts);
                //echo $nodeDumper->dump($newStmts);
                // pretty print
                //echo $newCode;
                // write the converted file to the target directory
                #################

                // 変換した内容で、ファイルの上書き
                file_put_contents(
                    $file->getPathname(),
                    // RequireToUseで末尾に use〜;;とコロンが2つついてしまうので、修正。(不本意)
                    str_replace(';;', ';', $newCode)
                );
            } catch (PhpParser\Error $e) {
                echo 'Parse Error: ', $e->getMessage();
            }
        }
    }
} catch (Exception $e) {
    echo $e->getMessage();
    echo $e->getTraceAsString();
}
```

# Visitor
肝心なのはこちらで、ASTをいい感じに書き換えて行く必要があります。

## requireをuseに変えるVisitor

```php:RequireToUses.php
<?php
namespace Visitor;

use PhpParser\{
    Node,
    NodeFinder,
    NodeDumper,
    NodeVisitorAbstract,
    BuilderFactory
};
use Doctrine\Inflector\InflectorFactory;

class RequireToUses extends NodeVisitorAbstract
{
    public function leaveNode(Node $node)
    {
        $factory = new BuilderFactory;
        $inflector = InflectorFactory::create()->build();

        if ($node instanceof Node\Expr\Include_) {
            $nodeDumper = new NodeDumper;

            $target = [];
            // require_once 'path/to/file.php'
            if ($node->type === Node\Expr\Include_::TYPE_REQUIRE_ONCE || $node->type === Node\Expr\Include_::TYPE_REQUIRE) {
                if ($node->expr instanceof Node\Scalar\String_) {
                    $target[] =  $node->expr->value;
                } else {
                    $target = $this->getTargetRecursive($node->expr);
                }
            }

            $target = $this->formatTarget($target);

            $namespaces = [];
            foreach ($target as $t) {
                $t = $this->handleComponentClass($t);
                $t = $this->removeExtension($t);
                $t = $inflector->singularize($t);
                $t = $inflector->classify($t);
                $namespaces[] = $t;
            }
            $use = implode('\\', $namespaces);

            $newNode = new Node\Stmt\Use_([
                    new Node\Stmt\UseUse(new Node\Name($use))
                ],
                Node\Stmt\Use_::TYPE_UNKNOWN
            );
            // 頻繁に確認するので、残す
            // echo $nodeDumper->dump($node);
            // echo $nodeDumper->dump($newNode);

            return $newNode;
        }

        return $node;
    }

    public function getTargetRecursive($expr, $target = [])
    {
        if ($expr instanceof Node\Expr\BinaryOp\Concat) {
            if ($expr->left instanceof  Node\Expr\BinaryOp\Concat) {
                if ($expr->right instanceof  Node\Expr\ConstFetch) {
                    $target[] = $expr->right->name->toString();
                } else if ($expr->right instanceof  Node\Scalar\String_) {
                    $target[] =  $expr->right->value;
                }
                return $this->getTargetRecursive($expr->left, $target);
            }
            $target[] = $expr->right->value;
            $target[] = $expr->left->name->toString();
            return $target;
        }

        return $target;
    }

    private function formatTarget($targetArray){
        $targetArray = array_filter($targetArray, function($var){
            return $var !== 'DS';
        });

        $targetArray = array_reverse($targetArray);

        $newArray = [];
        foreach ($targetArray as $var) {
            $parsed = explode('/', $var);
            if (count($parsed) >= 2) {
                foreach ($parsed as $p) {
                    $newArray[] = $p;
                }
            } else {
                $newArray[] = $var;
            }
        }

        $newArray = array_map(function ($var) {
            if ($var == 'APP') {
                return 'App';
            } elseif ($var == 'CAKE') {
                return 'Cake';
            } else {
                return $var;
            }
        }, $newArray);

        return $newArray;
    }

    private function removeExtension($str)
    {
        return (str_replace('.php', '', $str));
    }

    private function handleComponentClass($str)
    {
        if (preg_match('/(?<filename>c_[a-z]+)\.php/', $str, $m)){
            if (isset($m['filename'])) {
                $str = $m['filename'] . '_components';
            }
        }
        return $str;
    }
}
```
## $this->request->dataを処理するVisitor

```php:Request.php

<?php
namespace Visitor;

use PhpParser\{
    PrettyPrinter,
    Node,
    NodeFinder,
    NodeDumper,
    NodeVisitorAbstract,
    BuilderFactory
};
use Doctrine\Inflector\InflectorFactory;

/**
 * Class RequireToUses
 * @package Visitor
 */
class Request extends NodeVisitorAbstract
{
    public function leaveNode(Node $node)
    {
        $factory = new BuilderFactory;
        $nodeFinder = new NodeFinder;
        $nodeDumper = new NodeDumper;
        $inflector = InflectorFactory::create()->build();
        $newNode = $node;
        if ($node instanceof Node\Expr\Assign) {
            $leftNode = $node->var;
            $subNode = $nodeFinder->findFirstInstanceOf($leftNode, 'PhpParser\Node\Expr\PropertyFetch');

            $_this = $subNode->var->var->name ?? '';
            $_request = $subNode->var->name->name ?? '';
            $_data = $subNode->name->name ?? '';
            if ($_this === 'this' && $_request === 'request' && $_data === 'data') {

                // $this->request->data['Baz']['bar'] → 'Baz.bar'に。
                $dimNodes = $nodeFinder->findInstanceOf($leftNode, 'PhpParser\Node\Expr\ArrayDimFetch');
                if (!empty($dimNodes)) {
                    $dims = [];
                    foreach ($dimNodes as $dimNode) {
                        $dims[] = $dimNode->dim;
                    }
                    $dims = array_reverse($dims);

                    $dimConcat = '';
                    foreach ($dims as $dim) {
                        if ($dim instanceof Node\Scalar\String_) {
                            $dimConcat .= $dim->value;
                            $dimConcat .= '.';
                        } elseif ($dim instanceof Node\Expr\Variable) {
                            // $this->request->data['Baz'][$bar] のケースは "Baz.{$bar}" とする
                            $dimConcat .= '{$' . $dim->name . '}';
                            $dimConcat .= '.';
                        }
                    }
                    $dimConcat = rtrim($dimConcat, '.');
                    $dimConcatNode = new Node\Scalar\String_(
                        $dimConcat,
                        [
                            'kind' => Node\Scalar\String_::KIND_DOUBLE_QUOTED
                        ]
                    );

                    $methodCall = $factory->methodCall(
                        $factory->propertyFetch(new Node\Expr\Variable('this'), 'request'),
                        'withData',
                        [$dimConcatNode,
                            $node->expr
                        ]
                    );

                }

                $newNode =  new Node\Expr\Assign(
                   $factory->propertyFetch(new Node\Expr\Variable('this'), 'request'),
                   $methodCall
                );
            }
        }

        return $newNode;
    }
}
```
# 実行前準備
## 変換対象のコードを置く
```php:test_controller.php
<?php

require_once APP . 'path/to/app.php';
require_once APP . 'path' . DS . 'to' . DS . 'func.php';
require_once APP . 'controllers' . DS . 'components' . DS . 'c_app.php';
require_once CAKE . 'tests' . DS . 'lib' . DS . 'test_manager.php';

class TestController
{
    public function __construct()
    {
        $this->request->data['url'] = array('data' => 'value');
        $pass = 'pass';
        $this->request->data[$pass]['sub'] = array('data' => 'value');
    }

    public function dummy()
    {
        echo "__constructとの間の空行が削除されないこのと確認";
    }
}
```

## composerに名前空間を追加する
```diff

     },
     "autoload-dev": {
         "psr-4": {
-            "PhpParser\\": "test/PhpParser/"
+            "PhpParser\\": "test/PhpParser/",
+            "Visitor\\": "visitors/"
         }
     },
```
```
// 忘れずに実施
$ composer dump-autoload
```

## 大文字小文字/複数単数変換できるライブラリを追加

```
$ composer require doctrine/inflector
```

## 禁断のライブラリのコアコード修正
1. ExpressionをStatementに変換は、本来は駄目らしいですがなんとしてもやりたいので書き換えます(自己責任)
    * `Trying to replace expression (Expr_Include) with statement (Stmt_Use)` がでました
    * 以下の修正を加えました

```diff:lib/PhpParser/NodeTraverser.php
         }

         if ($old instanceof Node\Expr && $new instanceof Node\Stmt) {
+            if ($old instanceof Node\Expr\Include_ && $new instanceof Node\Stmt\Use_) {
+                return;
+            }
             throw new \LogicException(
                 "Trying to replace expression ({$old->getType()}) " .
                 "with statement ({$new->getType()})"
```

2. `"{$pass}.sub"` がエスケープされて `"{\$pass}.sub"` になるので書き換えます(自己責任)

```diff:lib/PhpParser/PrettyPrinter/Standard.php
     protected function escapeString($string, $quote) {
         if (null === $quote) {
             // For doc strings, don't escape newlines
-            $escaped = addcslashes($string, "\t\f\v$\\");
+            $escaped = addcslashes($string, "\t\f\v\\");
         } else {
-            $escaped = addcslashes($string, "\n\r\t\f\v$" . $quote . "\\");
+            $escaped = addcslashes($string, "\n\r\t\f\v" . $quote . "\\");
         }

         // Escape other control characters
```

# コツ
* dumpしたASTの読み方
    * ASTを実装したクラスを見て、どの様なプロパティやメソッドが定義されているかを観察する
    * [Gihubのfile検索](https://github.com/nikic/PHP-Parser/find/master)で、`Expr_Assign` なら `ExprAssign` って検索すると、ヒットする
* 使える機能の知り方
    * やはり、静的解析と候補を自動で提示してくれるIDEなどでないとかなり辛い
    * 私はIntelliJ IDEA(phpstorm)だが、VSCodeのIntelliSenseでは力不足を感じた...(使い方知らないだけ?)
    ![スクリーンショット 2020-06-30 23.13.26.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/87716/afdf3b29-f2b1-0254-d370-2fb10671edaf.png)
 
# 感想
ツールは便利だけど、泥臭い変換はどのみち大変
Requestの処理はおそらくRector(PHP-Parserの上位互換)で実装したほうが楽だと思います。
Rectorの実装例もいつか書きます。