概要

code-tree は Rust/Go/Python/TypeScript 等のプログラミング言語からシンボルを抽出する静的解析ツールとして開発された。しかし、実際のプロジェクトではプログラミング言語だけでなく、HTML テンプレート(Hugo Go Template、Jinja2)や Markdown ドキュメントも重要な構成要素である。本稿では、code-tree をこれらのドキュメント形式に拡張した設計判断と実装過程を記述する。

背景:なぜドキュメント形式をサポートするか

課題

code-tree の当初の設計は .rs.go.py 等のソースコードファイルを対象としていた。しかし、Hugo ベースの Web サイトプロジェクトや、Jinja2 テンプレートを使用する Python Web フレームワークでは、テンプレートファイルと Markdown コンテンツがプロジェクトの大部分を占める。

これらをシンボル抽出の対象外にすると:

  • テンプレートの構造変更がコンテキストウィンドウに反映されない
  • Markdown ドキュメントのセクション構造が把握できない
  • LLM エージェントがプロジェクト全体を理解するための情報が不足する

設計目標

  1. HTML テンプレートから define/block/macro 等のスコープ定義シンボルを抽出
  2. テンプレート変数・制御フローを weak スコープとして分類
  3. Markdown からセクション構造とブロック要素をシンボルとして抽出
  4. 既存の SymbolRecord 形式との互換性を維持

HTML テンプレートシンボル抽出

Hugo/Jinja テンプレートの正規表現設計

HTML テンプレートは tree-sitter の HTML パーサーだけでは不十分である。{{ define "main" }}{% block content %} はHTMLノードとしては認識されず、テキストコンテンツとして埋め込まれている。このため、正規表現ベースのパターンマッチングと tree-sitter AST の組み合わせが必要となった。

  // Hugo テンプレートパターン
static HUGO_BLOCK_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"\{\{-?\s*(define|block)\s+").unwrap()
});
static HUGO_CONTROL_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"\{\{-?\s*(if|else|range|with|end)\b").unwrap()
});
static HUGO_VAR_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"\{\{-?\s*(\.\w+|\$\w+)").unwrap()
});

// Jinja テンプレートパターン
static JINJA_BLOCK_RE: Lazy<Regex> = Lazy::new(|| {
    Regex::new(r"\{%-?\s*(extends|block|macro)\s+").unwrap()
});
  

Strong/Weak スコープ分類

テンプレートシンボルの分類は、プログラミング言語の function/struct と同様の strong/weak 概念を適用した:

Strong スコープ(構造を定義するシンボル):

  • Hugo: defineblock — テンプレート継承の単位
  • Jinja: extendsblockmacro — レイアウト構造の定義

Weak スコープ(参照・使用されるシンボル):

  • if/range/with — 制御フロー
  • .Title/$site — 変数参照
  • partial — パーシャルテンプレート呼び出し

この分類により、code-tree の --scope strong オプションでテンプレートの骨格構造のみを表示し、--scope weak で詳細な制御フローまで含めた表示が可能になる。

Strong キーワード定数

  const HUGO_STRONG_KEYWORDS: &[&str] = &["define", "block"];
const JINJA_STRONG_KEYWORDS: &[&str] = &["extends", "block", "macro"];
  

これらのキーワードに一致するテンプレートブロックのみが strong シンボルとして分類される。残りの制御フローや変数参照は weak シンボルとなる。

Markdown スキャナーの設計

tree-sitter クエリベースアプローチ

初期実装では tree-sitter AST を手動で walk する方式を採用していたが、これはノードタイプの判定が冗長になり、新しいブロック要素の追加時にコードの修正箇所が多くなる問題があった。

リファクタリングでは tree-sitter のクエリ機能を活用し、単一のクエリで全ブロック要素を一括検出する方式に変更した:

  let query = Query::new(
    &lang.into(),
    r#"
    (atx_heading) @heading
    (setext_heading) @heading
    (fenced_code_block) @code_fence
    (block_quote) @blockquote
    (list) @list
    (table) @table
    (html_block) @html_block
    (link_reference_definition) @link_ref
    "#,
)?;
  

この方式の利点:

  • ブロック要素の追加がクエリ文字列への1行追加で完結
  • パターンマッチングが tree-sitter エンジンに委託され、Rust 側のコードがシンプル
  • キャプチャ名でシンボル種別を直接判定可能

セクション生成ロジック

Markdown ドキュメントの特徴は、見出しがセクションの境界を定義することである。code-tree では以下のロジックでセクションシンボルを生成した:

  1. 全見出しの位置とレベルを収集
  2. 最初の見出し前のコンテンツを (preamble) セクションとして扱う
  3. 各見出しから次の見出し(または文書末尾)までをセクション範囲とする
  4. セクション名を H{level}: {heading_text} 形式で生成
  struct Heading {
    level: usize,
    line_no: usize,
    text: String,
}

// セクション範囲の計算
for (i, heading) in headings.iter().enumerate() {
    let start_line = heading.line_no;
    let end_line = if i + 1 < headings.len() {
        headings[i + 1].line_no - 1
    } else {
        lines.len()
    };
    let name = format!("H{}: {}", heading.level, heading.text);
    // セクションシンボルとして登録
}
  

セクション範囲の計算は O(n) で完結する。全見出し位置を先に集約してから線形走査でセクション境界を決定するため、ネストされた見出し構造でもパフォーマンスに影響しない。

コードフェンスのタイトル抽出

コードフェンスのタイトルは以下の優先順位で決定する:

  1. フェンス直後の行のコメントからタイトルを抽出(# title// title<!-- title -->
  2. info string に title=... が含まれる場合、その値を使用
  3. いずれも該当しない場合、fence:{lang} をデフォルトタイトルとする
  "code_fence" => {
    let info = node
        .child_by_field_name("info_string")
        .map_or("", |n| n.utf8_text(source.as_bytes()).unwrap_or(""));
    let lang_label = if info.is_empty() { "plain" } else { info };
    // title= の検出、コメント行の検出は省略
    let title = format!("fence:{}", lang_label);
    md_push_symbol(&mut out, rel_path, scope, "codeblock", title, line_no, &text);
}
  

シンボル種別の設計

Markdown のシンボル種別は既存の kind システムとの互換性を考慮して設計した:

kind対象name の内容
section見出しセクションH{level}: {heading_text}
heading見出し行見出しテキスト
codeblockコードフェンスfence:{lang} + タイトル
tableGFM テーブルヘッダー行
blockquote引用ブロック最初の行
listリストブロック最初の項目
html_blockHTML ブロック最初のタグ
link_refリンク参照定義[label]

Strong サマリーの出力形式

既存の Rust/Go 出力との一貫性を保つため、1シンボル1行の形式を採用:

    12 [section][abcd1234] H2: Markdown AST (lines 12-58) // ## Markdown AST
  20 [codeblock][beef5678] fence:rust title=example (lines 20-31) // ```rust fn main() { ... }
  40 [table][cafe9999] cols: name|type|desc (lines 40-45) // | name | type | desc |
  

テスト設計

Hugo テンプレート Zoo

HTML テンプレートのテストには、Hugo テンプレートの主要機能を網羅した「テンプレート Zoo」ファイルを作成した:

  {{ define "main" }}
  {{ $p := . }}
  {{ $site := .Site }}
  {{ $title := cond (ne .Title "") .Title $site.Title }}
  <!-- 変数、パイプライン、条件分岐、ループ、パーシャル... -->
{{ end }}

{{ define "inline-snippet" }}
  {{ $p := .p }}
  <div>{{ printf "Inline: %s" $p.Title }}</div>
{{ end }}
  

このテストファイルにより以下を検証:

  • define ブロックが strong シンボルとして検出されること
  • 変数参照($title.Site)が weak シンボルとして分類されること
  • 制御フロー(if/range/with)の検出
  • パイプライン構文(| upper | printf)の扱い
  • partial/partialCached/template 呼び出しの認識

統合テストフレームワーク

新しい言語サポートの追加時は、2つのテスト関数で検証する:

  #[test]
fn detect_lang_covers_main_extensions() {
    assert_eq!(
        code_tree::detect_lang_from_rel_path("a.html"),
        Some(Lang::Html)
    );
}

#[test]
fn scan_file_covers_languages() {
    assert_scan_non_empty(
        Lang::Html,
        "testdata/hugo.html",
        r#"{{ define "main" }} ... {{ end }}"#,
    );
}
  

assert_scan_non_empty ヘルパーは一時ディレクトリにソースファイルを作成し、スキャン結果が空でないことを確認する。言語検出とシンボル抽出の両方を自動化されたテストで保証する。

実装上の課題

課題1:GFM 方言の差異

tree-sitter-markdown は GFM(GitHub Flavored Markdown)と標準 CommonMark で異なるノード名を持つ場合がある。特に table ノードは GFM 拡張に依存しており、GFM 拡張なしの環境ではテーブルが認識されない。

対応:tree-sitter-md クレートの GFM 拡張を有効化し、| 区切りのヒューリスティック検出をフォールバックとして実装。

課題2:テンプレート方言の自動判定

同じ .html ファイルでも、Hugo テンプレート({{ }} 構文)と Jinja2 テンプレート({% %} 構文)では異なる正規表現パターンが必要。

対応:ファイル内容をスキャンし、{{ define/{{ block パターンが見つかれば Hugo、{% extends/{% block パターンが見つかれば Jinja と自動判定。混在する場合は両方のパターンを適用。

課題3:setext 見出しのレベル判定

setext 見出しは ===(H1)と ---(H2)の下線で見出しレベルを指定する。atx 見出し(# の個数)とは異なる判定ロジックが必要。

対応

  let level = if node.kind() == "atx_heading" {
    // # の個数でレベル判定
    node.child(0).unwrap().utf8_text(src.as_bytes()).unwrap().len()
} else {
    // setext: = なら H1、- なら H2
    if text.contains("=") { 1 } else { 2 }
};
  

得られた知見

1. 正規表現と tree-sitter の併用

テンプレート言語のように、ホスト言語(HTML)にゲスト言語(テンプレート構文)が埋め込まれるケースでは、tree-sitter 単体では不十分である。tree-sitter で HTML の構造を解析し、テキストノード内のテンプレート構文を正規表現で検出する二段階アプローチが実用的。

2. クエリベースのシンボル抽出

tree-sitter のクエリ機能を使うことで、ノードタイプの判定をクエリ文字列に集約できる。手動の AST ウォークに比べてコードが簡潔になり、新しいブロック要素の追加も容易。

3. セクション概念の汎用性

Markdown のセクション生成ロジックは、他のドキュメント形式(reStructuredText、AsciiDoc)にも応用可能。見出し位置を収集してから線形走査でセクション境界を決定するパターンは汎用的。

成果

  1. HTML テンプレートサポート:Hugo Go Template と Jinja2 の両方からシンボル抽出が可能
  2. Markdown スキャナー:セクション生成を含む包括的なブロック要素抽出
  3. 既存システムとの互換性:SymbolRecord 形式を維持し、strong/weak サマリーに統合
  4. 拡張性:新しいテンプレート方言の追加がパターン定義の追加で完結する設計