🍣

大規模コードベース向けASTツールのast-grepについて

2023/07/06に公開1

大規模なコードベース向けの高速なASTツールとしてast-grepというものがある。これについての調査メモ。

主に以下の公式ドキュメントを読み進めて、利用方法、パターンやルールの詳細などについて理解を深める。

https://ast-grep.github.io/

ast-grepとは

公式ドキュメントでは、コードの検索からLint、codemodに至るまでASTツールとして網羅的にカバーできるツールであることが以下のように表現されている。

Think ast-grep as an hybrid of grep, eslint and codemod.

結果に精度が求められる場面では、高速であるものの精密さに欠けるテキストベースではなく、ASTベースでの正確な解析の方が望ましいだろうけど、ASTでの記述は非常に面倒になりがちという側面がある。
その点においてast-grepは、テキストベースでのgrepではなく、ASTベースでのgrepというように理解すると良さそう。
ASTベースでの正確さ(厳密には依存ライブラリであるtree-sitterによるCSTベースと考えるべきかもしれない)をもってgrepのような容易さでコードを検索したり、チェックにかけたり、置換処理などを行えるるツールと考えられる。

なお、CSTとはConcrete Syntax Treeのことで、ASTに比べてよりコードの詳細を含んだツリー表現となるもの[1]

公式ドキュメントにおいて、同様のツールと比較してもast-grepの特徴的な点としてあげられているのは以下の点。

  • パフォーマンス: Rust製でマルチコアを活用していて、非常に高速
  • 漸進性: 置換する簡易なワンライナーから始めて、YAMLで記述されたLintルールとしたり、コードを書き換えるツールを作ることにも利用できる
  • 実用性: インタラクティブなコード修正が可能なインタラクティブモード、Linter、言語サーバーなどをCLIに同梱している

ast-grep自体は多くの言語をサポートしているけど、この記事では主にJavaScriptやTypeScriptに対して利用するケースをフォーカスする。

インストール

ast-grepのCLIは、以下の通りnpmでインストールできる。cargohomebrewなどでもインストール可能。[2]

npm i @ast-grep/cli -g

インストールできたらsgast-grepでCLIが利用可能になる。--helpオプションをつけて出力して確認すると以下のようになる。

❯ ast-grep --help
Search and Rewrite code at large scale using AST pattern.
                    __
        ____ ______/ /_      ____ _________  ____
       / __ `/ ___/ __/_____/ __ `/ ___/ _ \/ __ \
      / /_/ (__  ) /_/_____/ /_/ / /  /  __/ /_/ /
      \__,_/____/\__/      \__, /_/   \___/ .___/
                          /____/         /_/


Usage: ast-grep <COMMAND>

Commands:
  run   Run one time search or rewrite in command line. (default command)
  scan  Scan and rewrite code by configuration
  test  Test ast-grep rule
  new   Create new ast-grep project or items like rules or tests
  lsp   Starts language server
  docs  Generate rule docs for current configuration
  help  Print this message or the help of the given subcommand(s)

Options:
  -h, --help
          Print help (see a summary with '-h')

  -V, --version
          Print version

ここで利用しているast-grepのバージョンは0.6.6

❯ ast-grep --version
ast-grep 0.6.6

試す

まず、簡易的な例で各機能を試すため、公式のクイックスタートを参考にして、TypeScriptで&&?.に書き換えることを試してみる。

ASTで扱う対象としてTypeScriptのリポジトリをクローンしておく。

git clone git@github.com:microsoft/TypeScript.git --depth 1

JavaScriptにおいては、someFunc && someFunc()のような論理積someFunc?()のようなオプショナルチェーンに書き換えることが可能だけども、これをast-grepで行うとどのようになるか。
ast-grepにおいては、--patternで検索して--rewriteで置換という構成になる。

検索

まず対象のsomeFunc && someFunc()のように論理積を用いたコードを検索するには以下のようにする。

❯ ast-grep --pattern '$PROP && $PROP()' --lang ts TypeScript/src

--patternオプションで対象のコードを指定して、--langオプションで対象の言語を指定している。なお末尾のTypeScript/srcはクローンしたTypeScriptのリポジトリのソースコードへのパスで、コマンド実行対象のコードベースのパスとなる。
実際に実行した結果は、以下のように該当したファイルパスおよび該当行が出力される。

❯ ast-grep --pattern '$PROP && $PROP()' --lang ts TypeScript/src
TypeScript/src/services/services.ts
1552│    documentRegistry: DocumentRegistry = createDocumentRegistry(host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames(), host.getCurrentDirectory()),
2204│        const customTransformers = host.getCustomTransformers && host.getCustomTransformers();
TypeScript/src/services/stringCompletions.ts
643│    const ignoreCase = !(host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames());
708│    const ignoreCase = !(host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames());
TypeScript/src/services/shims.ts
1381│                this.documentRegistry = createDocumentRegistry(host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames(), host.getCurrentDirectory());
TypeScript/src/compiler/moduleSpecifiers.ts
940│    const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation && host.getGlobalTypingsCacheLocation();
TypeScript/src/harness/harnessIO.ts
174│        tryEnableSourceMapsForHost: () => ts.sys.tryEnableSourceMapsForHost && ts.sys.tryEnableSourceMapsForHost(),
175│        getMemoryUsage: () => ts.sys.getMemoryUsage && ts.sys.getMemoryUsage(),
TypeScript/src/tsserver/nodeServer.ts
938(os.homedir && os.homedir()) ||
963│        const homePath = (os.homedir && os.homedir()) ||

留意するべき点としては、ASTベースでの検索なので以下のような改行や空白を含んだコード等でも上記のパターン指定にマッチするということ。

someFunc
  && someFunc();

置換

検索結果を置換するにはどうするかというと--rewriteオプションを追加する。

❯ ast-grep --pattern '$PROP && $PROP()' --rewrite '$PROP?.()' --lang ts TypeScript/src
実行結果
❯ ast-grep --pattern '$PROP && $PROP()' --rewrite '$PROP?.()' --lang ts TypeScript/src
TypeScript/src/compiler/moduleSpecifiers.ts
@@ -936,7 +936,7 @@
937 937return undefined;
938 938}
939 939940    │-    const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation && host.getGlobalTypingsCacheLocation();
    940│+    const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation?.();
941 941│     // Get a path that's relative to node_modules or the importing file's path
942 942│     // if node_modules folder is in this folder or any of its parent folders, no need to keep it.
943 943│     const pathToTopLevelNodeModules = getCanonicalFileName(moduleSpecifier.substring(0, parts.topLevelNodeModulesIndex));
TypeScript/src/services/services.ts
@@ -1548,7 +1548,7 @@
1549 1549];
1550 1550export function createLanguageService(
1551 1551│     host: LanguageServiceHost,
1552     │-    documentRegistry: DocumentRegistry = createDocumentRegistry(host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames(), host.getCurrentDirectory()),
     1552│+    documentRegistry: DocumentRegistry = createDocumentRegistry(host.useCaseSensitiveFileNames?.(), host.getCurrentDirectory()),
1553 1553│     syntaxOnlyOrLanguageServiceMode?: boolean | LanguageServiceMode,
1554 1554): LanguageService {
1555 1555let languageServiceMode: LanguageServiceMode;
@@ -2200,7 +2200,7 @@
2201 2201│         synchronizeHostData();
2202 22022203 2203│         const sourceFile = getValidSourceFile(fileName);
2204     │-        const customTransformers = host.getCustomTransformers && host.getCustomTransformers();
     2204│+        const customTransformers = host.getCustomTransformers?.();
2205 2205return getFileEmitOutput(program, sourceFile, !!emitOnlyDtsFiles, cancellationToken, customTransformers, forceDtsEmit);
2206 2206}
2207 2207│
TypeScript/src/services/stringCompletions.ts
@@ -639,7 +639,7 @@
640 640641 641function getCompletionEntriesForDirectoryFragmentWithRootDirs(rootDirs: string[], fragment: string, scriptDirectory: string, extensionOptions: ExtensionOptions, compilerOptions: CompilerOptions, host: LanguageServiceHost, exclude: string): readonly NameAndKind[] {
642 642│     const basePath = compilerOptions.project || host.getCurrentDirectory();
643    │-    const ignoreCase = !(host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames());
    643│+    const ignoreCase = !(host.useCaseSensitiveFileNames?.());
644 644│     const baseDirectories = getBaseDirectoriesFromRootDirs(rootDirs, basePath, scriptDirectory, ignoreCase);
645 645return flatMap(baseDirectories, baseDirectory => arrayFrom(getCompletionEntriesForDirectoryFragment(fragment, baseDirectory, extensionOptions, host, /*moduleSpecifierIsRelative*/ true, exclude).values()));
646 646}
@@ -704,7 +704,7 @@
705 705}
706 706}
707 707708    │-    const ignoreCase = !(host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames());
    708│+    const ignoreCase = !(host.useCaseSensitiveFileNames?.());
709 709if (!tryDirectoryExists(host, baseDirectory)) return result;
710 710711 711│     // Enumerate the available files if possible
TypeScript/src/services/shims.ts
@@ -1377,7 +1377,7 @@
1378 1378│     public createLanguageServiceShim(host: LanguageServiceShimHost): LanguageServiceShim {
1379 1379│         try {
1380 1380if (this.documentRegistry === undefined) {
1381     │-                this.documentRegistry = createDocumentRegistry(host.useCaseSensitiveFileNames && host.useCaseSensitiveFileNames(), host.getCurrentDirectory());
     1381│+                this.documentRegistry = createDocumentRegistry(host.useCaseSensitiveFileNames?.(), host.getCurrentDirectory());
1382 1382}
1383 1383│             const hostAdapter = new LanguageServiceShimHostAdapter(host);
1384 1384│             const languageService = createLanguageService(hostAdapter, this.documentRegistry, /*syntaxOnlyOrLanguageServiceMode*/ false);
TypeScript/src/harness/harnessIO.ts
@@ -170,8 +170,8 @@
171 171│         exit: exitCode => ts.sys.exit(exitCode),
172 172│         readDirectory: (path, extension, exclude, include, depth) => ts.sys.readDirectory(path, extension, exclude, include, depth),
173 173│         getAccessibleFileSystemEntries,
174    │-        tryEnableSourceMapsForHost: () => ts.sys.tryEnableSourceMapsForHost && ts.sys.tryEnableSourceMapsForHost(),
175    │-        getMemoryUsage: () => ts.sys.getMemoryUsage && ts.sys.getMemoryUsage(),
    174│+        tryEnableSourceMapsForHost: () => ts.sys.tryEnableSourceMapsForHost?.(),
    175│+        getMemoryUsage: () => ts.sys.getMemoryUsage?.(),
176 176│         getEnvironmentVariable: name => ts.sys.getEnvironmentVariable(name),
177 177│         joinPath
178 178};
TypeScript/src/tsserver/nodeServer.ts
@@ -934,7 +934,7 @@
935 935case "win32": {
936 936│                 const basePath = process.env.LOCALAPPDATA ||
937 937│                     process.env.APPDATA ||
938    │-                    (os.homedir && os.homedir()) ||
    938│+                    (os.homedir?.()) ||
939 939│                     process.env.USERPROFILE ||
940 940(process.env.HOMEDRIVE && process.env.HOMEPATH && normalizeSlashes(process.env.HOMEDRIVE + process.env.HOMEPATH)) ||
941 941│                     os.tmpdir();
@@ -959,7 +959,7 @@
960 960return process.env.XDG_CACHE_HOME;
961 961}
962 962│         const usersDir = platformIsDarwin ? "Users" : "home";
963    │-        const homePath = (os.homedir && os.homedir()) ||
    963│+        const homePath = (os.homedir?.()) ||
964 964│             process.env.HOME ||
965 965((process.env.LOGNAME || process.env.USER) && `/${usersDir}/${process.env.LOGNAME || process.env.USER}`) ||
966 966│             os.tmpdir();

--interactive

--intertactiveオプションを付与することで、以下のように変更を各箇所で適用するか選択しながら置換できる。

❯ ast-grep --pattern '$PROP && $PROP()' --rewrite '$PROP?.()' --interactive --lang ts TypeScript/src
TypeScript/src/compiler/moduleSpecifiers.ts
@@ -936,7 +936,7 @@
937 937return undefined;
938 938}
939 939940    │-    const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation && host.getGlobalTypingsCacheLocation();
    940│+    const globalTypingsCacheLocation = host.getGlobalTypingsCacheLocation?.();
941 941│     // Get a path that's relative to node_modules or the importing file's path
942 942│     // if node_modules folder is in this folder or any of its parent folders, no need to keep it.
943 943│     const pathToTopLevelNodeModules = getCanonicalFileName(moduleSpecifier.substring(0, parts.topLevelNodeModulesIndex));
Accept change? (Yes[y], No[n], Accept All[a], Quit[q], Edit[e])

パターン

上述の$PROP && $PROP()のような--patternオプションに指定するパターンはどのようなものがあるか。

メタ変数

動的なパターンマッチをさせたいとき、利用するのはメタ変数。メタ変数は、$で始まるA-Z_1-9との組み合わせからなる変数。具体的には$SOME_META_VAR$SOME_META_1のような形になる。
正規表現における.がテキストではなくASTノードに対して利用できるイメージになる。

例えばconsole.log($SOME_META)とすれば以下のようなTypeScriptのコードのconsole.logにはマッチすることになる。

match
function hello() {
  console.log("Hello.");
}
const log = console.log("log");

これは1つのASTノードとマッチさせるための変数で、複数のASTノードとマッチさせることに利用はできない。そのため以下のようなconsole.logにはマッチしない。

not-match
const logString = "console.log(\"ok\")";
console.log();

function hello(name: string) {
  console.log("hello", name);
}

以下のPlaygroundでマッチするケースとマッチしないケースとをそれぞれ確認できる。

https://ast-grep.github.io/playground.html#eyJtb2RlIjoiUGF0Y2giLCJsYW5nIjoidHlwZXNjcmlwdCIsInF1ZXJ5IjoiY29uc29sZS5sb2coJFNPTUVfTUVUQSkiLCJyZXdyaXRlIjoiIiwiY29uZmlnIjoiIyBDb25maWd1cmUgUnVsZSBpbiBZQU1MXG5ydWxlOlxuICBhbnk6XG4gICAgLSBwYXR0ZXJuOiBpZiAoZmFsc2UpIHsgJCQkIH1cbiAgICAtIHBhdHRlcm46IGlmICh0cnVlKSB7ICQkJCB9XG5jb25zdHJhaW50czpcbiAgIyBNRVRBX1ZBUjogcGF0dGVybiIsInNvdXJjZSI6ImZ1bmN0aW9uIGhlbGxvKCkge1xuICBjb25zb2xlLmxvZyhcIkhlbGxvLlwiKTtcbn1cbmNvbnN0IGxvZyA9IGNvbnNvbGUubG9nKFwibG9nXCIpO1xuXG5jb25zdCBsb2dTdHJpbmcgPSBcImNvbnNvbGUubG9nKFxcXCJva1xcXCIpXCI7XG5jb25zb2xlLmxvZygpO1xuXG5mdW5jdGlvbiBoZWxsb1RvKG5hbWU6IHN0cmluZykge1xuICBjb25zb2xlLmxvZyhcImhlbGxvXCIsIG5hbWUpO1xufSJ9

マルチメタ変数

0個以上のASTノードにマッチさせたい場合はマルチメタ変数を使う。関数の引数が0以上のケースを考慮したい場合などに有用なものになる。
$$$始まりであること以外はメタ変数の名前付けと同じルール。
console.log($$$)とすれば、以下のようなconsole.logの引数が1つではないもの全てにマッチすることとなる。

match
console.log();
console.log('Hello', name)
console.log(...args)

https://ast-grep.github.io/playground.html#eyJtb2RlIjoiUGF0Y2giLCJsYW5nIjoidHlwZXNjcmlwdCIsInF1ZXJ5IjoiY29uc29sZS5sb2coJCQkU09NRV9NRVRBKSIsInJld3JpdGUiOiIiLCJjb25maWciOiIjIENvbmZpZ3VyZSBSdWxlIGluIFlBTUxcbnJ1bGU6XG4gIGFueTpcbiAgICAtIHBhdHRlcm46IGlmIChmYWxzZSkgeyAkJCQgfVxuICAgIC0gcGF0dGVybjogaWYgKHRydWUpIHsgJCQkIH1cbmNvbnN0cmFpbnRzOlxuICAjIE1FVEFfVkFSOiBwYXR0ZXJuIiwic291cmNlIjoiZnVuY3Rpb24gaGVsbG8oKSB7XG4gIGNvbnNvbGUubG9nKFwiSGVsbG8uXCIpO1xufVxuY29uc3QgbG9nID0gY29uc29sZS5sb2coXCJsb2dcIik7XG5cbmNvbnNvbGUubG9nKCk7XG5cbmNvbnNvbGUubG9nKCdIZWxsbycsIG5hbWUpXG5cbmNvbnN0IGFyZ3MgPSBbXCJzb21lXCIsIFwiYXJnXCIsIFwidmFsdWVzXCJdXG5jb25zb2xlLmxvZyguLi5hcmdzKSJ9

const $VAR = ($$$) => $$$であれば以下のようなアロー関数にマッチする。

match
const foo = (bar) => bar;
const noop = () => {};
const add = (a, b, c) => {
  return a + b + c;
};

https://ast-grep.github.io/playground.html#eyJtb2RlIjoiUGF0Y2giLCJsYW5nIjoiamF2YXNjcmlwdCIsInF1ZXJ5IjoiY29uc3QgJFZBUiA9ICgkJCQpID0+ICQkJCIsInJld3JpdGUiOiIiLCJjb25maWciOiIjIENvbmZpZ3VyZSBSdWxlIGluIFlBTUxcbnJ1bGU6XG4gIGFueTpcbiAgICAtIHBhdHRlcm46IGlmIChmYWxzZSkgeyAkJCQgfVxuICAgIC0gcGF0dGVybjogaWYgKHRydWUpIHsgJCQkIH1cbmNvbnN0cmFpbnRzOlxuICAjIE1FVEFfVkFSOiBwYXR0ZXJuIiwic291cmNlIjoiY29uc3QgZm9vID0gKGJhcikgPT4gYmFyO1xuY29uc3Qgbm9vcCA9ICgpID0+IHt9O1xuY29uc3QgYWRkID0gKGEsIGIsIGMpID0+IHtcbiAgcmV0dXJuIGEgKyBiICsgYztcbn07In0=

メタ変数のキャプチャー

メタ変数は正規表現におけるキャプチャーと同じように、マッチしたASTノードを再利用して参照することができる。
例えば[$META, $META]であれば以下のような配列にマッチさせることができる。

const ary = [1, 1];

https://ast-grep.github.io/playground.html#eyJtb2RlIjoiUGF0Y2giLCJsYW5nIjoiamF2YXNjcmlwdCIsInF1ZXJ5IjoiWyRNRVRBLCAkTUVUQV0iLCJyZXdyaXRlIjoiIiwiY29uZmlnIjoiIyBDb25maWd1cmUgUnVsZSBpbiBZQU1MXG5ydWxlOlxuICBhbnk6XG4gICAgLSBwYXR0ZXJuOiBpZiAoZmFsc2UpIHsgJCQkIH1cbiAgICAtIHBhdHRlcm46IGlmICh0cnVlKSB7ICQkJCB9XG5jb25zdHJhaW50czpcbiAgIyBNRVRBX1ZBUjogcGF0dGVybiIsInNvdXJjZSI6ImNvbnN0IGFyeTEgPSBbMSwgMl1cbmNvbnN0IGFyeTIgPSBbMSwgMV0ifQ==

_から始まる名前のメタ変数(例えば$_META)にすると、逆にキャプチャーしないことができるとなっているが、パターンマッチ速度の最適化で利用すると便利なもののよう。

匿名ノード

メタ変数はデフォルトだと、匿名ノードと呼ばれるノードを含まず、名前付きノードを対象としてマッチングする。匿名ノードを含むには、$$SOME_METAの形で$を2つ先頭につける形とする必要がある。
匿名ノードとは、文法上どちらかといえば重要ではないものが該当するようで、以下の例における;がそれに該当する。

return 123
return;

名前付きノードとマッチするケースのPlayground匿名ノード含めてマッチするケースのPlaygroundで比較してみるとreturn;の部分が異なる結果であることを確認できる。

スキャン

コマンドライン引数を幾度も変更して実行していては手間だけども、ast-grep scanコマンドを使うと複数のルールをまとめて実行することができる。プロジェクトのLinterとして利用するようなイメージになると思う。

セットアップ

スキャン実施には事前のセットアップが必要になる。ast-grep newコマンドをプロジェクルートディレクトリで実行して、対話形式で質問に回答してセットアップする。

❯ ast-grep new
No sgconfig.yml found. Creating a new ast-grep project...
> Where do you want to have your rules? rules
> Do you want to create rule tests? Yes
> Where do you want to have your tests? rule-test
> Do you want to create folder for utility rules? Yes
> Where do you want to have your utilities? utils
Your new ast-grep project has been created!

各質問に答えたら、回答に応じてディレクトリが作られていることが確認できる。

❯ tree
.
├── rule-test
├── rules
├── sgconfig.yml
└── utils

4 directories, 1 file

sgconfig.ymlの中身は以下の通り。

sgconfig.yml
ruleDirs:
- ./rules
testConfigs:
- testDir: ./rule-test
utilDirs:
- ./utils

ルールの追加

スキャンする時に利用するLintのルールを追加するには、ast-grep new ruleコマンドを実行する。

ここではESLintにおけるno-new-symbolと同じルールを追加してみる。Symbolnewをつけて実行するとTypeErrorが発生するので、それを静的解析の段階で未然に防止するルールとなる。

❯ ast-grep new rule
> What is your rule's name? no-new-symbol
> Choose rule's language JavaScript
Created rules at ././rules/no-new-symbol.yml
> Do you also need to create a test for the rule? Yes
Created test at ./rule-test/no-new-symbol-test.yml

rulesディレクトリにルールのYAMLファイルno-new-symbole.ymlが作られるので、messagepatternの値をルールに合わせて書き換える。
TypeScriptのSymbolの型定義から参照するにdescriptionの値はマルチメタ変数で表現する方が適切と思われるので$$$にしておく。

rules/no-new-symbole.yml
id: no-new-symbol
message: Symbol cannot be called as a constructor.
severity: error
language: JavaScript
rule:
  pattern: new Symbol($$$)

ESLintでの同じルールに対するASTと比較すると、記述がかなり簡潔に済むことが分かる。

適当なJavaScriptのファイルを用意してast-grep scanコマンドを実行すると、追加したルールでnewをつけてSymbolをコンストラクタとして実行することをエラーとして検知できている。

❯ ast-grep scan
error[no-new-symbol]: Symbol cannot be called as a constructor.
  ┌─ ./index.js:1:13
  │
1 │ const sym = new Symbol('sym')
  │             ^^^^^^^^^^^^^^^^^

Error: 1 error(s) found in code.
Help: Scan succeeded and found error level diagnostics in the codebase.

ルール

Lintルールは、YAMLで記述するこものになっていて、sgconfig.ymlruleDirsでルールのファイルのディレクトリを指定する形となる。ruleDirsに指定されているディレクトリ配下の全てのYAMLファイルがルールのファイルとして扱われるようになる。

ruleDirs:
- ./rules

ルールのファイル

ルール自体のYAMLファイルは以下のような内容になる。

id: 一意なルールID
message: スキャンして問題が見つかったときに使われる説明文
severity: 重要度レベル
language: ASTの対象のプログラミング言語
rule:
  pattern: ASTのパターン
files:
- "ルールを適用する対象ファイルのglobパターン"
ignores:
- "ルールの適用外とするファイルのglobパターン"
fix: 自動で書き換えるコード
constraints: メタ変数のマッチに対する制約条件

ルールの詳細

ast-grepのルールは以下3つに分類される。いずれのルールもcomposeしてより複雑なルールを作ることができる。

  • Atomicルール: 構文ノードがルールにマッチするかどうかを決定する、最も基本的なルール
  • Relationalルール: 周辺のノードをベースにして対象のノードをフィルターできるルール
  • Compositeルール: 他のルールやルールのリストを再帰的に受け入れて、アトミックなルールをより複雑なものに合成するルール

以下にルール種別毎のkey一覧を列挙する。

ルールの種別 ルールのkey 概要
Atomic pattern パターン構文に応じてノードにマッチ
Atomic kind ノードの種別に応じてマッチ
Atomic regex ノードのテキストにRustの正規表現でマッチ
Relational inside サブルールにマッチするノードでフィルター
Relational has サブルールで指定されている子ノードを持つノードでフィルター
Relational follows サブルールで指定されたノードにつづくノードでフィルター
Relational precedes サブルールで指定されたノードの前にあるノードでフィルター
Composite all 全てのルールを満たすノードにマッチ
Composite any 1つでもルールを満たしたノードにマッチ
Composite not ルールを満たさないノードにマッチ
Composite matches ユーティリティルールを参照してそのルールにマッチ

これだけだと個々のルールの理解が難しいので、より詳細にast-grep/eslintにおけるルールやPlaygroundを例としてあわせて見つつ、各ルールについてながめてみる。

Atomicルール

Atomicルールは最も基本的なルールで、patternkindregexの3種類がある。

pattern

patternはパターン構文に応じてノードにマッチする。

pattern: console.log($META)

オブジェクトスタイルでコンテキストを絞り込んでselectorで該当のノードを指定することもできる。例えばTypeScriptでクラスのプロパティのASTノードをパターン指定する場合は以下のようになる。

pattern:
  selector: public_field_definition
  context: class { $F }

selectorに指定する値はtree-sitterのノード名だと思う。Playgroundで確認してみたりすると分かるけど、JavaScriptとTypeScriptでも微妙に異なるノード名だったりする。

kind

kindselectorに似ている感じがするけど、ASTノードの種別で検索できるルール。有効な構文をpatternだけで指定するには複雑度が高いとき、ASTノード種別を指定できるものとなる。

kind: public_field_definition

https://ast-grep.github.io/playground.html#eyJtb2RlIjoiQ29uZmlnIiwibGFuZyI6InR5cGVzY3JpcHQiLCJxdWVyeSI6IiIsInJld3JpdGUiOiJsb2dnZXIubG9nKCRNQVRDSCkiLCJjb25maWciOiIjIENvbmZpZ3VyZSBSdWxlIGluIFlBTUxcbnJ1bGU6XG4gIGtpbmQ6IHB1YmxpY19maWVsZF9kZWZpbml0aW9uIiwic291cmNlIjoiY29uc3QgZm9vID0gXCJva1wiO1xuXG5jbGFzcyBIb2dlIHtcbiAgZm9vID0gMTtcbn0ifQ==

regex

Rustの正規表現でASTノードのテキストにマッチさせるもの。

例えば、ast-grep/eslintarray-callback-returnルールにおいては、returnを忘れないようArrayのメソッドとマッチさせるのに利用している。

https://github.com/ast-grep/eslint/blob/d353dca03e8e0ad94607fa208492b78fa615e521/rules/array-callback-return.yml#L28

大抵の場合、regexはパフォーマンスが出ないので単独ではなく他のAtomicルールと併用するべきものとされている。

Relationalルール

周辺のノードの状況をノードの絞り込み条件として加えられる強力なルールがRelationalルール。例えば以下のようなルールだとfor inforのループの中でawaitのパターンにマッチするノードを探す。

all:
  - pattern: await $PROMISE
  - inside:
    any:
      - kind: for_in_statement
      - kind: for_statement

Relationalルールでサポートされているのは以下の4つ。

inside

サブルールにマッチするノードの中を対象のノードとする。
ast-grep/eslintno-dupe-argsにおいては、以下のようなinsideでのマッチによってパラメーターの重複を検知している。

https://github.com/ast-grep/eslint/blob/d353dca03e8e0ad94607fa208492b78fa615e521/rules/no-dupe-args.yml#L8-L13

has

サブルールで指定されている子ノードを持つノードを対象とする。上述の例ではメタ変数$NAMEとマッチする子ノードを持っているノードと該当するようになる。

has:
  pattern: $NAME
follows

サブルールで指定されたノードにつづくノードを対象とする。例としては以下のように// commentのようなコメントのノードの後のconsole.log('foo');のノードにのみマッチさせたいというケースで、

console.log("foo");
console.log("foo");
// comment
console.log("foo");
console.log("foo");

followsでコメントの後にあるノードを範囲として指定するルールを記述できる。

rule:
  all:
    - pattern: console.log('foo');
    - follows:
        pattern: // comment

https://ast-grep.github.io/playground.html#eyJtb2RlIjoiQ29uZmlnIiwibGFuZyI6InR5cGVzY3JpcHQiLCJxdWVyeSI6IiIsInJld3JpdGUiOiJsb2dnZXIubG9nKCRNQVRDSCkiLCJjb25maWciOiJydWxlOlxuICBhbGw6XG4gICAgLSBwYXR0ZXJuOiBjb25zb2xlLmxvZygnZm9vJyk7XG4gICAgLSBmb2xsb3dzOlxuICAgICAgICBwYXR0ZXJuOiAvLyBjb21tZW50Iiwic291cmNlIjoiY29uc29sZS5sb2coJ2ZvbycpO1xuY29uc29sZS5sb2coJ2ZvbycpO1xuLy8gY29tbWVudFxuY29uc29sZS5sb2coJ2ZvbycpO1xuY29uc29sZS5sb2coJ2ZvbycpO1xuIn0=

precedes

サブルールで指定されたノードの前にあるノードを対象とする。followsの例とは逆に、// commentのようなコメントのノードの前のconsole.log('foo');のノードにのみマッチさせたいというケースで、

console.log("foo");
console.log("foo");
// comment
console.log("foo");
console.log("foo");

precedesでコメントの前にあるノードを範囲として指定するルールを記述できる。

rule:
  all:
    - pattern: console.log('foo');
    - precedes:
        pattern: // comment

https://ast-grep.github.io/playground.html#eyJtb2RlIjoiQ29uZmlnIiwibGFuZyI6InR5cGVzY3JpcHQiLCJxdWVyeSI6IiIsInJld3JpdGUiOiJsb2dnZXIubG9nKCRNQVRDSCkiLCJjb25maWciOiJydWxlOlxuICBhbGw6XG4gICAgLSBwYXR0ZXJuOiBjb25zb2xlLmxvZygnZm9vJyk7XG4gICAgLSBwcmVjZWRlczpcbiAgICAgICAgcGF0dGVybjogLy8gY29tbWVudCIsInNvdXJjZSI6ImNvbnNvbGUubG9nKCdmb28nKTtcbmNvbnNvbGUubG9nKCdmb28nKTtcbi8vIGNvbW1lbnRcbmNvbnNvbGUubG9nKCdmb28nKTtcbmNvbnNvbGUubG9nKCdmb28nKTtcbiJ9

オプション

また、Relationalルールのオプションとして以下のものがある。

  • stopBy: デフォルトだとRelationalルールは1階層だけノードを辿ってマッチさせるけど、endstopByに指定すると周囲のノード(何を周囲とするかはRelationalルール次第だとは思う)を最後まで辿って探す
  • field: 特定のフィールドによってノードを指定したい場合はfieldを利用する

なお、followsprecedesにはfieldオプションがない。

Compositeルール

個別のルールを組み合わせてより複雑なルールを構成できるのがCompositeルール。

all

リストで受け取ったすべてのルールとマッチするときに該当する。
例えば以下のpatterninsideとの組み合わせだと関数宣言とアロー関数とのいずれかにおけるreturn文のノードにマッチするケースが該当する。patterninsideとのいずれのルールも満たすものだけが該当する形になる。

all:
  - pattern: return $
  - inside:
      stopBy: end
      any:
        - kind: function_declaration
        - kind: arrow_function

https://ast-grep.github.io/playground.html#eyJtb2RlIjoiQ29uZmlnIiwibGFuZyI6ImphdmFzY3JpcHQiLCJxdWVyeSI6IiIsInJld3JpdGUiOiJsb2dnZXIubG9nKCRNQVRDSCkiLCJjb25maWciOiJydWxlOlxuICBhbGw6XG4gICAgLSBwYXR0ZXJuOiByZXR1cm4gJFxuICAgIC0gaW5zaWRlOlxuICAgICAgICBzdG9wQnk6IGVuZFxuICAgICAgICBhbnk6XG4gICAgICAgICAgLSBraW5kOiBmdW5jdGlvbl9kZWNsYXJhdGlvblxuICAgICAgICAgIC0ga2luZDogYXJyb3dfZnVuY3Rpb25cbiIsInNvdXJjZSI6ImZ1bmN0aW9uIGZvbyAoKSB7XG4gIHJldHVybiAxO1xufVxuY29uc3QgYmFyID0gKCkgPT4ge1xuICByZXR1cm4gMjtcbn0ifQ==

any

リストで受け取ったいずれかのルールとマッチさせる。上述のallでの例にあるように、関数宣言function_declarationとアロー関数arrow_function、いずれかのkindであれば該当する形となる。

not

受け取ったルールにマッチさせない。受け取るルールは単一のもの。例えば以下のようなnotのルールを含むルールであれば、マルチメタ変数を利用した引数の数を問わず全てのconsole.logにマッチするパターンから引数が1つのconsole.lognotで除外することになる。

rule:
  all:
    - pattern: console.log($$$)
    - not:
        pattern: console.log($)

https://ast-grep.github.io/playground.html#eyJtb2RlIjoiQ29uZmlnIiwibGFuZyI6ImphdmFzY3JpcHQiLCJxdWVyeSI6IiIsInJld3JpdGUiOiJsb2dnZXIubG9nKCRNQVRDSCkiLCJjb25maWciOiJydWxlOlxuICBhbGw6XG4gICAgLSBwYXR0ZXJuOiBjb25zb2xlLmxvZygkJCQpXG4gICAgLSBub3Q6XG4gICAgICAgIHBhdHRlcm46IGNvbnNvbGUubG9nKCQpXG4iLCJzb3VyY2UiOiJjb25zb2xlLmxvZygxKVxuY29uc29sZS5sb2coMSwgMilcbmNvbnNvbGUubG9nKDEsIDIsIDMpXG5jb25zb2xlLmxvZyh7fSlcbmNvbnNvbGUubG9nKFsxLCAyLCAzXSlcbmNvbnNvbGUubG9nKC4uLmFyZ3MpIn0=

matches

参照するユーティリティルールのidをもとにルールとマッチさせる。従って以下のように該当のユーティリティルールのidをmatchesへ指定する。

matches: util-unique-id

ユーティリティルール

ユーティリティルールとは、他のルールから再利用可能な形にしておくルールのこと。ユーティリティルールには、ローカルルールとグローバルルールとが存在する。

ローカルルール

utilsというセクションで識別子を持ってローカルのルールオブジェクトを用意することで、matchesからその識別子を指定してルールを再利用することができる。
例えば、以下のようにfooという識別子のローカルルールを用意してmatchesによって裏要することが可能になっている。

utils:
  foo:
    any:
      - pattern: console.log($$$)
rule:
  matches: foo

https://ast-grep.github.io/playground.html#eyJtb2RlIjoiQ29uZmlnIiwibGFuZyI6ImphdmFzY3JpcHQiLCJxdWVyeSI6IiIsInJld3JpdGUiOiJsb2dnZXIubG9nKCRNQVRDSCkiLCJjb25maWciOiJ1dGlsczpcbiAgZm9vOlxuICAgIGFueTpcbiAgICAgIC0gcGF0dGVybjogY29uc29sZS5sb2coJCQkKVxucnVsZTpcbiAgbWF0Y2hlczogZm9vIiwic291cmNlIjoiY29uc29sZS5sb2coMSlcbmNvbnNvbGUubG9nKDEsIDIpXG5jb25zb2xlLmxvZygxLCAyLCAzKVxuIn0=

なお、ローカルルールの識別子の重複はできないけどもグローバルルールが同名であった場合ローカルルールで上書きされる動きになるらしい。

グローバルルール

グローバルルールはsgconfig.ymlutilDirsのディレクトリに配置されるルールで、プロジェクト内でグローバルに再利用できるルールとなる。
例えば以下のようなグローバルルールをutils/foo.ymlとして配置すると、

id: foo
language: JavaScript
rule:
  any:
    - pattern: console.log($$$)

ルールのファイルでmatchesを利用してグローバルルールを再利用することが可能となる。

rule:
  matches: foo

まとめ

@ast-grep/cliで検索、置換、スキャンなどを試しながら、パターンやルールについて確認してみた。@ast-grep/napiの方は今回試していないけども、プログラムから利用する形であればよりさまざまな目的に利用できそうな感じがするので、そういうことも試してみれると良さそう。

参考

脚注
  1. ASTとCSTの比較についてはAST vs CSTのセクションで言及されている。 ↩︎

  2. プログラムから利用できるAPIを提供する@ast-grep/napiもあるけど、この記事では扱わない。 ↩︎

GitHubで編集を提案

Discussion