コンテンツへスキップ

AST書き換え

AST書き換えは、置換テンプレートを使ったAST検索です。GrephはAST検索と同じマッチャーを実行し、書き換え後のテンプレートを一致したバイト範囲にソースへ挿入します。前後の空白、インデント、コメントは保たれ、一致したコード自体は再出力されます(マッチ範囲内を参照)。

すぐに試せる例

シェル
# Convert array() literals to []
./vendor/bin/greph -p 'array($$$ITEMS)' -r '[$$$ITEMS]' --dry-run src
./vendor/bin/greph -p 'array($$$ITEMS)' -r '[$$$ITEMS]' src

# isset ternary -> null coalesce
./vendor/bin/greph -p 'isset($X) ? $X : $Y' -r '$X ?? $Y' --dry-run src

# Rename a method call
./vendor/bin/greph -p '$obj->oldName($$$ARGS)' -r '$obj->newName($$$ARGS)' src

# Confirm each file before writing
./vendor/bin/greph -p 'array($$$ITEMS)' -r '[$$$ITEMS]' --interactive src

置換テンプレート

置換テンプレートでは検索パターンと同じメタ変数構文を使います。キャプチャしたノードは名前で挿入できます。

パターン内 テンプレート内 効果
$X $X キャプチャしたノードを挿入
$$$ARGS $$$ARGS キャプチャしたノードの並びを挿入
$NAME(識別子) $NAME キャプチャした識別子を挿入

パターンには現れてもテンプレートにはないメタ変数は、書き換え後のコードから削除されます。テンプレートにだけ現れるメタ変数は、現時点ではエラーにならず、そのまま文字列として出力されます。そのため、-r '$o->$m($$$A, $EXTRA)'はコードに$EXTRA変数を書き込みます(#4)。まず--dry-runでテンプレートを確認してください。

モード

フラグ 動作
(なし) マッチしたすべてのファイルに書き換えを適用
--dry-run 書き換え後のファイル内容を標準出力に表示し、書き込まない
--interactive 各ファイルを書き込む前にRewrite <file>? [y/N]に確認する

デフォルトでは、変更をすべて書き込みます。パターンを調整するときは、まず--dry-runを使い、差分に納得したらフラグを外してください。

書式の維持

Grephはファイルを再フォーマットしません。マッチャーは元のソース内にある各一致箇所のバイト範囲を記録し、リライターはその範囲にレンダリング済みテンプレートを直接挿入します。変更されないコードは、次の要素も含めてそのまま保たれます。

  • インデントと末尾の空白
  • インラインコメントと独立したコメント
  • 空行の配置
  • 文字列の引用符スタイルと連結
  • PHPの開始タグと終了タグのスタイル

これは完全な再フォーマットとの意図的なトレードオフです。Grephは一致しなかったコードを「整形」しませんが、無関係な行に不要な差分が生じることもありません。

マッチ範囲内

一致した範囲は、テンプレートをPHP-Parserの整形出力に通して置き換えます。キャプチャしたノードはソーステキストのコピーではなく、再出力されます。現時点での動作は次のとおりです(#2)。

  • マッチ範囲内で後続ノードに結び付いていないコメントは削除されます。たとえば、配列の最後の要素の後にある末尾コメントや、空の配列内のコメントが該当します。
  • キャプチャしたノードに結び付いているコメントは保持されますが、独立した行に移動するため、1行の式が複数行になることがあります。
  • マッチ範囲内の改行は保持されません。そのようなコメントを含まない複数行のarray(...)は、1行の[...]になります。
  • リテラルの表記は正規化されます。0x1Fは0x1fになり、1_000は1000になります。"\x41"などのエスケープシーケンスは文字("A")として出力され、柔軟なヒアドキュメントではインデントが失われます。
PHP
// before
$d = array(
    "a" => 1,  // trailing comment
);

// after -p 'array($$$ITEMS)' -r '[$$$ITEMS]'
$d = ["a" => 1];

コメントや手作業で書式設定されたリテラルを含むマッチについては、--dry-runの出力を確認してください。

複数行とネストされたマッチ

複数行パターンも、1行のパターンと同じように使えます。置換は、メタ変数を展開して整形出力されるため、置換後のコードのレイアウトは元のマッチではなくプリンターによって決まります。

Grephは各ファイルを書き換えごとに1回走査し、重なり合うマッチを同時には適用しません。ファイルの末尾から逆向きにマッチをたどり、すでに書き換えた範囲と重なるマッチはスキップします。異なる位置から始まるネストされたマッチでは、内側のマッチを書き換え、外側はそのままにします。

PHP
// before
$b = array(1, array(2));

// after one run of -p 'array($$$ITEMS)' -r '[$$$ITEMS]'
$b = array(1, [2]);

// after a second run
$b = [1, [2]];

同じ位置から始まる2つのマッチ(たとえば、$obj->$method($$$ARGS)にマッチするメソッドチェーン)では、外側のマッチを書き換えます。ネスト可能なパターンでは、--dry-runの出力が空になるまで書き換えを繰り返してください(#3)。ast-grepはネストされた2つのマッチのうち外側を書き換えるため、ここではvendor/bin/sgと上流のsgで結果が異なります。

プログラムからの利用

PHP
use Greph\Greph;
use Greph\Ast\AstSearchOptions;

$rewrites = Greph::rewriteAst(
    'array($$$ITEMS)',
    '[$$$ITEMS]',
    'src',
    new AstSearchOptions(jobs: 4),
);

foreach ($rewrites as $result) {
    if (!$result->changed()) {
        continue;
    }

    echo "{$result->file}: {$result->replacementCount} replacement(s)\n";
    file_put_contents($result->file, $result->rewrittenContents);
}

Greph::rewriteAst()自体はファイルに書き込みません。変更の適用(または--dry-runや辞退した--interactiveの場合にスキップする処理)はCLIが担います。そのため、差分を確認してから適用したい自動エージェントからも安全に呼び出せます。

RewriteResultが公開するもの:

  • file:絶対パス
  • originalContents:書き換え前のファイル内容
  • rewrittenContents:書き換え後のファイル内容
  • replacementCount:構造上の置換数
  • changed():originalContents !== rewrittenContentsの場合にtrueを返すヘルパー

rewriteを使う場面

Rewriteが適しているのは、次のような場合です。

  • 変換が局所的かつ構造的である(ファイル全体の再構成ではない)。
  • 決定的で再現可能なリファクタリングを、変更がなくなるまで繰り返し実行したい。
  • 適用前に変更をプレビューしたい。
  • 外部バイナリを呼び出さずに、スクリプトやCIジョブへリファクタリングを組み込みたい。

Rewriteが適していないのは、次のような場合です。

  • 変換が、PHP-Parserでは単一ファイルから解決できない型情報に依存する。
  • ファイル境界をまたいでインポートを追加または削除する必要がある。
  • リファクタリングではなくフォーマッターを使いたい(PHP-CS-Fixerを使用)。