コードをまとめてAIに読ませたり、複数ファイルに同じ変更を入れたりする作業で、grepや正規表現の限界を感じたことはないでしょうか。「改行の位置が変わっただけで一致しない」「コメントの中の文字列まで引っかかる」「複数行にまたがる関数呼び出しを1つの単位として置換したい」といった悩みです。この隙間を、正規表現ではなくASTパターン(構文木の形)でのマッチに置き換えて埋めるのが、オープンソースCLIの「ast-grep」です。
この記事は、ast-grepのnpmパッケージ公開情報(公式メタデータ)とCLI自身のヘルプ出力を一次情報としつつ、実際にこのクラウド環境へインストールし、LEXIAブログが動いているこのNext.jsリポジトリのコードや、Python・Go・Rustの簡単なサンプルコードに対してコマンドを走らせた結果をまとめたものです。なお、公式サイト(ast-grep.github.io)とGitHubリポジトリのページ自体は、本検証環境のネットワークポリシー上直接閲覧できませんでした。そのため本文中の数値やバージョン、コマンドの挙動は、npmレジストリが返す公式パッケージメタデータと、実際に手元で実行したCLIの出力に基づいています。
ast-grepとは
npmレジストリ上の公式パッケージ情報によると、ast-grepは「Search and Rewrite code at large scale using precise AST pattern」をうたうRust製のCLIツールで、ライセンスはMIT、公開元はherringtondarkholmeです。ホームページにはhttps://ast-grep.github.io、リポジトリにはgithub.com/ast-grep/ast-grepが登録されています。検証時点の最新バージョンは0.51.0で、npm上の公開日時は確認した3日前でした。過去の公開履歴も数週間おきに更新が続いており、活発に開発されているプロジェクトだとわかります。
配布形態はいくつか用意されています。
- npm:
npm install -g @ast-grep/cli(バイナリ名はast-grepとsgの2つ) - PyPI:
pip install ast-grep-cli(検証時点で0.51.0まで公開を確認) - Rust / cargo、Homebrewでの配布も案内されています。
この記事ではnpm install -g @ast-grep/cliで導入し、以降のコマンドをすべて実際に実行しています。
「sgは非推奨」という最初の発見
インストール直後にsg --versionを実行すると、次の警告が出ました。
========================================================================
WARNING: `sg` is deprecated. Use `ast-grep` instead.
========================================================================
ast-grep 0.51.0
CLI名の短縮形であるsgコマンドは、現行バージョンでは非推奨の扱いになっており、起動のたびにこの警告が標準エラーに出力されます。ネット上の解説記事やスキル集にはsg -p '...'という書き方のまま紹介しているものが少なくありませんが、これから使う場合は素直にast-grepコマンドを使うほうがよさそうです。以降のコマンドもすべてast-grepで統一しています。
サブコマンドの全体像
ast-grep --helpが示すサブコマンドは次の7つです。
run:パターンを指定してその場で検索・書き換えを行う(デフォルトコマンド)scan:YAMLで定義したルール集(sgconfig.yml)に基づいてプロジェクト全体をスキャン・書き換えtest:ルールのテストを実行new:ルールやテストなど、プロジェクトの雛形を生成lsp:Language Serverとして起動outline:シンボル・import・exportなど、コードの構造を一覧表示completions:シェル補完スクリプトを生成
この記事では、インストール直後でも使い始められるrunとoutlineを中心に、実際の挙動を確認しています。scan・testはYAMLルールやプロジェクト設定を事前に用意する必要があるため、ヘルプ出力の確認にとどめ、本記事では深く検証していません。
パターンの書き方:$VARと$$$ARGS
ast-grep run --helpには、パターン中で使えるメタ変数の説明があります。
$VARのように大文字で始まる$付きの識別子:任意の1ノードにマッチし、その内容をキャプチャする$$$ARGSのように$を3つ重ねた変数:0個以上の連続するノード(引数リストなど)にマッチする$_:任意の1ノードにマッチするが、キャプチャはしない
実際にこのリポジトリのcomponents配下に対して、ReactのuseState呼び出しを検索してみました。
ast-grep run -p 'useState($$$ARGS)' --lang tsx app components
components/site/copyright-year.tsx:24: const [year, setYear] = useState(jstYear)
components/site/contact-form.tsx:27: const [form, setForm] = useState({
components/site/contact-form.tsx:28: name: "",
components/site/contact-form.tsx:29: company: "",
...(object literalの続き)
components/blog/blog-explorer.tsx:57: const [query, setQuery] = useState(initialQuery)
components/site/site-header.tsx:10: const [isScrolled, setIsScrolled] = useState(false)
components/home/home-hero.tsx:10: const [isVisible, setIsVisible] = useState(false)
注目したいのはcontact-form.tsxの例です。useState({ ... })のように、引数がオブジェクトリテラルで複数行にまたがっていても、$$$ARGSが1つのまとまりとして正しく拾っています。正規表現で同じことをしようとすると、括弧の対応や改行をいちいち意識した書き方が必要になりますが、ast-grepはコードを構文木として読んでいるため、インデントや改行の位置を気にする必要がありませんでした。
複数言語でも書き方は同じ
ast-grepはTree-sitterの文法を使って言語ごとのパーサーを切り替える設計で、--langオプションで対象言語を指定します。試しに、TypeScript以外の言語でも同じ発想のパターンが通用するか確認しました。
# Python:def文の本体ごとマッチ
ast-grep run -p 'def $NAME($$$ARGS):
$$$BODY' --lang python sample.py
# Go:関数宣言
ast-grep run -p 'func $NAME($$$PARAMS) $RET { $$$BODY }' --lang go sample.go
# Rust:関数定義
ast-grep run -p 'fn $NAME($$$PARAMS) -> $RET { $$$BODY }' --lang rust sample.rs
3つとも、簡単な関数定義を含むサンプルファイルに対して一発でマッチし、関数名・引数・戻り値・本体をそれぞれ$NAME・$PARAMS・$RET・$BODYとしてキャプチャできることを確認できました。--langを変えるだけでTypeScript・Python・Go・Rustのいずれでも同じ書き方のパターンが通用する点は、複数言語が混在するリポジトリで地味に効いてくる部分だと思います。対応言語の正確な一覧は公式ドキュメントの言語ページ(本検証環境からは閲覧不可)で確認するのが確実ですが、存在しない言語名を指定するとerror: invalid value 'xxx' for '--lang <LANG>': xxx is not supported!という明確なエラーになることは確認済みです。
書き換え(rewrite)は既定でドライラン
--rewrite(-r)を付けると、マッチした部分を置き換えた差分(diff)を表示します。試しに、useState($A)を型アノテーション付きの呼び出しに書き換えるパターンを実行しました。
ast-grep run -p 'useState($A)' --rewrite 'useState<typeof $A>($A)' --lang tsx components/site/site-header.tsx
components/site/site-header.tsx
@@ -6,8 +6,8 @@
...
10 │- const [isScrolled, setIsScrolled] = useState(false)
11 │- const [isOpen, setIsOpen] = useState(false)
10│+ const [isScrolled, setIsScrolled] = useState<typeof false>(false)
11│+ const [isOpen, setIsOpen] = useState<typeof false>(false)
重要なのは、このコマンドを実行した直後にgit diff --statで確認しても、実ファイルには一切変更が入っていなかった点です。--rewriteだけを指定した場合はターミナルに差分を表示するプレビューにとどまり、ファイルへの書き込みは行われません。実際にファイルを変更するには、1件ずつ確認しながら適用する-i(--interactive)、またはすべて自動適用する-U(--update-all)を明示的に付ける必要があります。「検索コマンドだと思って打ったら書き換わっていた」という事故が起きにくい設計だと確認できました。
JSON出力でスクリプトやCIに渡す
--json(または--json=compact)を付けると、マッチ結果を構造化データとして出力できます。先ほどのPythonの例をJSON形式で実行すると、マッチした範囲(行・列・バイトオフセット)に加えて、$NAMEや$$$ARGSとしてキャプチャした内容がmetaVariablesというキーの下にそれぞれ個別の値として入っていました。この構造から、grepの出力をテキストとしてパースし直すのではなく、キャプチャした変数ごとに後続のスクリプトへ値を渡せる作りになっていることがわかります。CIでのコードレビュー自動化や、他のツールと組み合わせたコードモッド(機械的なコード変換)のパイプラインに組み込みやすい出力形式だと言えそうです。
outlineサブコマンドでコード構造を俯瞰する
ast-grep outlineは、ファイル中のシンボル・import・export・メンバーの一覧を表示するコマンドです。site-header.tsxに対して実行すると、ファイル内でエクスポートされている関数の宣言行が一覧として出力されました。grepでファイルの中身を検索するのではなく、「このファイルにはどんな関数・クラスがあるか」という目次を先に把握してから、必要な箇所だけ読みに行く、という使い方ができそうです。
向いている場面・向いていない場面
ここまで実際に動かした範囲で感じた、使いどころの整理です。
- 向いている:複数ファイル・複数言語にまたがる機械的なリネームやシグネチャ変更、コメントや文字列の中身を誤って拾いたくない検索、CIでの構造的なlintルールの適用
- 向いていない:1回限りの単純な文字列検索(素直に
grepやエディタの検索で十分)、パターンの構文自体を覚える手間に見合わないごく小規模な変更
また、scanやtest、YAMLルールによるfix定義といったプロジェクト単位の運用まわりは、sgconfig.ymlの用意が前提になっており、今回は--helpの記述を確認したのみで実際には検証していません。チームのlintルールとして定着させる場合は、公式ドキュメントのルール設定ページを別途確認することをおすすめします。
まとめ
ast-grepは、正規表現やテキスト一致ではなく構文木のパターンでコードを検索・書き換えるRust製CLIです。実際にインストールして動かした範囲では、改行やインデントの違いを気にせず複数行の呼び出しをまとめて拾えること、TypeScript・Python・Go・Rustで同じ書き方のパターンが通用すること、--rewriteが既定ではファイルを書き換えずプレビューにとどまることを確認できました。一方でsgコマンドは非推奨になっているため、これから使う場合はast-grepコマンドで統一するのが無難です。構造的な一括変換やCI組み込みのlintを検討しているときに、選択肢に入れる価値があるツールだと感じました。
参考にした一次情報
- npm:@ast-grep/cli(公式パッケージメタデータ、ライセンス・公開者・バージョン履歴)
- GitHub:ast-grep/ast-grep(npmパッケージ上のリポジトリ登録情報)
- 公式サイト:ast-grep.github.io(npmパッケージ上のホームページ登録情報。本検証環境からは直接閲覧不可)
- 本文中のコマンド出力はすべて、この記事の検証時点で実際にast-grep 0.51.0を実行して得たものです。
