AST を直接たどる

Ast::parse は、パース済みの tree_sitter::Tree を、そのパース元となったソースのバイト列とともに提供します。Ast::as_tree_sitter は、そのツリーを借用参照として渡します。本章では、これを使って独自の構文木解析(ノード種別のカウント、名前による構文要素の検索、パースエラーの検出、シンボルテーブルの抽出など)を、2 回目のパースのコストを払わずに行う方法を示します。

これを使うべきとき

次のような場合には、直接の AST 走査を選んでください。

  • プロセス内で構文要素をカウントまたは検索したい場合。CLI での同等機能(bca count -t <kind>bca find -t <kind>レシピ)はファイルごとに外部プロセスを起動しますが、ライブラリ経由なら 1 回のパースと 1 つの Rust ループで済みます。
  • パースエラーをプログラムから検出したい場合。tree-sitter は、文法がマッチできなかった箇所に合成の ERROR ノードを出力します。Node::has_error は O(1) であり(tree-sitter はすべてのノードにエラービットをキャッシュします)、数 MB のソースファイルでもチェックのコストは実質ゼロです。
  • 1 回のパースでメトリクスとカスタム解析を組み合わせたい場合。たとえば、カバレッジマッピング、IDE のアウトライン、コードオーナーレポートのために、メトリクス値 関数名のリストを同時に取得するケースです。

標準のメトリクスだけが必要なら、analyze または Ast::metrics を使い続けてください。これらはツリーの走査を代行してくれます。直接走査は、メトリクスウォーカーがまだ計算していないものを求める場合のための手段です。

再エクスポートされた tree_sitter を使う

別途 tree-sitter 依存を追加するのではなく、big_code_analysis::tree_sitter から tree_sitter をインポートしてください。この再エクスポートはメトリクスウォーカーのビルドに使われた正確なバージョンに固定されているため、Tree 型は定義上一致します。この再エクスポートが持つ「値は安定ではない」という姿勢については、既存の tree-sitter Tree を再利用する安定性とバージョニング を参照してください。

再利用可能な DFS ウォーカー

以下の例の多くは、すべての子孫を深さ優先で走査する必要があります。tree-sitter には、これを 1 ステップあたり O(1) で行う TreeCursor が付属しています(カーソル自体以外のアロケーションはありません)。標準的な走査はインラインで書けるほど短いものです。

#![allow(unused)]
fn main() {
use big_code_analysis::tree_sitter;

/// `tree` のすべてのノードをルートから先行順(pre-order)で訪問し、
/// 各ノードを `visit` に渡します。カーソル自体を除きアロケーションはありません。
fn walk_preorder<F: FnMut(tree_sitter::Node<'_>)>(
    tree: &tree_sitter::Tree,
    mut visit: F,
) {
    let mut cursor = tree.walk();
    'walk: loop {
        visit(cursor.node());
        if cursor.goto_first_child() {
            continue;
        }
        loop {
            if cursor.goto_next_sibling() {
                continue 'walk;
            }
            if !cursor.goto_parent() {
                return;
            }
        }
    }
}
}

The pattern is: visit, descend, climb back up while there is no next sibling, repeat. Every example in this chapter is a thin wrapper around this walker — the code fences below are marked ignore because they assume walk_preorder is already in scope; the matching set of tests in tests/api/book_ast_traversal_examples.rs keeps them honest, so a refactor that broke an example would fail cargo test.

種別ごとにノードをカウントする

AST クエリのレシピ にある bca count -t if_expression -t for_expression -t while_expression のライブラリ版です。

use big_code_analysis::{Ast, LANG, Source};
use std::collections::HashMap;

let ast = Ast::parse(Source::new(
    LANG::Rust,
    b"fn a() { if true { 1 } else { 2 } } fn b() { for _ in 0..10 {} }",
))
.expect("rust feature enabled");

let mut counts: HashMap<&str, usize> = HashMap::new();
walk_preorder(ast.as_tree_sitter(), |node| {
    *counts.entry(node.kind()).or_default() += 1;
});

assert_eq!(counts.get("if_expression").copied().unwrap_or(0), 1);
assert_eq!(counts.get("for_expression").copied().unwrap_or(0), 1);

文字列キー("if_expression""for_expression" など)は、tree-sitter 文法のノード型名です。新しい言語でこれらを調べる最も速い方法は、AST 全体を出力する bca dump --paths sample.rs です。

匿名トークン。 ウォーカーは、"{"";"、キーワードリテラルのような匿名トークンも含め、tree-sitter が出力するすべてのノードを訪問します。上記のような対象を絞った counts.get("if_expression") の検索は影響を受けませんが(匿名トークンは異なる種別名を持ちます)、counts.values().sum()名前付き の文法プロダクションの数よりはるかに大きくなります。名前付きノードだけが必要な場合は、ビジター内で tree_sitter::Node::is_named() を使ってフィルタしてください。

種別ごとにノードを検索する

bca find -t unsafe_block のライブラリ版です。

use big_code_analysis::{Ast, LANG, Source};

let ast = Ast::parse(Source::new(
    LANG::Rust,
    b"fn safe() {} fn risky() { unsafe { } }",
))
.expect("rust feature enabled");

let source = ast.source();
// キャプチャしたスライスは `source` から借用します。ヒットごとの `String` アロケーションはありません。
let mut hits: Vec<((usize, usize), &str)> = Vec::new();
walk_preorder(ast.as_tree_sitter(), |node| {
    if node.kind() == "unsafe_block" {
        let span = (node.start_position().row, node.end_position().row);
        let text = node
            .utf8_text(source)
            .expect("source is valid utf-8");
        hits.push((span, text));
    }
});

assert_eq!(hits.len(), 1);

Node::utf8_text(&source[..]) は、ノードのバイト範囲でソースのバイト列をスライスします。Ast::source と組み合わせて使ってください。プリプロセッサ入力を Ast::parse に渡した C++ の場合、source は元の入力ではなく、パーサーが実際に見た 展開後 のバッファです(C++ プリプロセッサに関する注記 を参照)。

パースエラーを検出する

tree-sitter はロスレスです。不正な入力に対してもツリーを返しますが、マッチできなかったノードにはエラーのタグが付きます。最も安価なチェックはルートに対するものです。

#![allow(unused)]
fn main() {
use big_code_analysis::{Ast, LANG, Source};

let ast = Ast::parse(Source::new(LANG::Rust, b"fn broken("))
    .expect("rust feature enabled");

// 何かが失敗したことを確認できるところまでは走査しますが、すべての
// エラー箇所を列挙するわけではありません。
assert!(ast.as_tree_sitter().root_node().has_error());
}

問題のノードを列挙するには、ツリーを走査して各ノードをチェックします。

use big_code_analysis::{Ast, LANG, Source};

let ast = Ast::parse(Source::new(LANG::Rust, b"fn broken("))
    .expect("rust feature enabled");

let mut error_lines = Vec::new();
walk_preorder(ast.as_tree_sitter(), |node| {
    if node.is_error() || node.is_missing() {
        error_lines.push(node.start_position().row);
    }
});

assert!(!error_lines.is_empty());

Node::is_error() は、tree-sitter が文法にマッチできなかった箇所に挿入する合成の ERROR ノードを示します。Node::is_missing() は、欠落したトークンから回復するためにパーサーが作り出したファントムノードを示します。CLI の bca find -t ERROR レシピも同じノードを使っています。

メトリクスとカスタム走査を組み合わせる

Ast の眼目は「1 回パースして何度も計算する」ことにあります。現実的なパイプラインは、同じパースからメトリクスを計算し、さらに シンボルテーブルも抽出します。

use big_code_analysis::{Ast, LANG, MetricsOptions, Source};

let ast = Ast::parse(Source::new(
    LANG::Rust,
    b"fn outer() { fn inner() {} } fn alone() {}",
))
.expect("rust feature enabled");

// 1 回のパース。メトリクスウォーカーがそれを使い…
let space = ast
    .metrics(MetricsOptions::default())
    .expect("walker succeeds");

// …カスタム走査もまったく同じツリーに対して行われます。キャプチャ
// した名前は、関数ごとに新しい `String` をアロケートするのではなく
// `source` から借用します。上記の `find_unsafe_blocks` と同じ
// パターンです。
let source = ast.source();
let mut functions: Vec<&str> = Vec::new();
walk_preorder(ast.as_tree_sitter(), |node| {
    if node.kind() == "function_item"
        && let Some(name_node) = node.child_by_field_name("name")
    {
        let name = name_node
            .utf8_text(source)
            .expect("source is valid utf-8");
        functions.push(name);
    }
});

assert_eq!(space.metrics.nom.functions_sum(), 3);
assert_eq!(functions, ["outer", "inner", "alone"]);

Node::child_by_field_name は名前付きの文法フィールドをたどります。これは、シリアライズされた AST(REST の /astAst::dump)の field_name キーに現れるのと同じフィールドです。フィールドベースの検索は、匿名トークン(カンマ、丸括弧など)に対して文法がどの子ノードを出力するかに依存しないため、位置ベースのインデックス参照より堅牢です。

シリアライズ可能な JSON ツリーが欲しい場合

構造化された AST をデータとして扱いたいパイプライン(差分、ワイヤ越しのクエリ、言語非依存のスキーマ作業など)のために、Ast::dump はツリーを、AstNode からなる Serialize 可能な AstResponse として実体化します。これは REST の /ast エンドポイントが生成するのと同じ形状です。パースハンドルに対して呼び出します。

#![allow(unused)]
fn main() {
use big_code_analysis::{Ast, AstCfg, AstPayload, LANG, Source};

let payload = AstPayload {
    id: "snippet".to_owned(),
    file_name: "snippet.rs".to_owned(),
    code: "fn f() {}".to_owned(),
    comment: false,
    span: true,
};
let cfg = AstCfg {
    id: payload.id.clone(),
    language: "rust".to_owned(),
    comment: payload.comment,
    span: payload.span,
};
let response = Ast::parse(
    Source::new(LANG::Rust, payload.code.as_bytes())
        .with_name(Some(payload.file_name.clone())),
)
.expect("rust feature enabled")
.dump(cfg);
let json = serde_json::to_string(&response).expect("AstResponse serializes");
println!("{json}");
}

一度きりのプロセス内作業には、上記の as_tree_sitter() ウォーカーのほうが安価です(ノードごとのアロケーションがありません)。シリアライズ可能な所有ツリーが必要なときに Ast::dump を使ってください。

対象外

  • 増分再パース — tree-sitter は増分更新のための tree_sitter::InputEdit をサポートしていますが、Ast はスナップショットです。ソースの編集を反映するには、新しく Ast::parse を実行するか、再エクスポートされた tree_sitter を介して tree_sitter::Parser::parse(&new_source, Some(&old_tree)) を直接実行し、その結果を Ast::from_tree_sitter に渡してください。
  • クレート内部の big_code_analysis::Node ラッパー。 これはメトリクスウォーカーの走査ニーズのために公開されていますが、その走査メソッドの大半(kindchild_countchildrencursor など)は pub(crate) のままです。ライブラリ利用者は as_tree_sitter().root_node() を通じて tree-sitter の Node にアクセスしてください。それが文書化された継ぎ目です。