# RustでMarkdownをパースする — parserの選び方

Markdown原稿をlinterや抽出ツールで扱うとき、最初に決めるのはparserライブラリだ。linterを組む段階で「どのcrateをparse層に置くか」で迷った経験はないだろうか。同じ`# 見出し`一行でも、返ってくるAPIの形と位置情報が違えば、指摘位置の表現（byte / 行・列）や走査方式が変わる。

返り値は大きく分けて、pull parser（呼び出すたびに次の構造片を取り出す方式）のイベント列、AST（abstract syntax tree、抽象構文木）、mdast（Markdown abstract syntax tree、remark/unified系の中間表現）、汎用構文木（tree-sitterが返す言語非依存のノード木）の4系統に整理できる。位置情報も、byte範囲だけを返すもの、行と列だけを返すもの、両方を返すものがある。

この記事では、Rustでよく使われる4 crate（pulldown-cmark、comrak、markdown-rs、tree-sitter-md）のAPIと位置モデルの違いを整理し、出口（linter、HTML、mdast連携、エディタ）から逆算して選ぶ手順を示す。

## Markdown parserが決めること

Markdown parserの仕事は、プレーンテキストを「構造つきデータ」に変換することだ。最低限、次の3点が後続処理を左右する。

1. 構造の取り出し方 — 見出し / 段落 / コードブロック / リンクを、どの型で受け取るか
2. 位置情報 — 原文のどのbyte、行と列に対応するか
3. 拡張記法 — GFM（テーブル / タスクリスト / 打ち消し線 / autolinkなど）をどこまで解釈するか

構文木ライブラリ（rowanなど）はparserではない。parserが返した構造を、別レイヤーで木に載せ替えるのは別レイヤーの話だ。

## pull parserとAST — 返り値の四系統

pull parser（呼び出すたびに次の event を取り出す方式）と、ASTやmdastのように木全体を組み立ててから処理する方式が、RustのMarkdown parserを分ける軸になる。加えてtree-sitter系は汎用構文木grammarとしてparseする。

| 系統 | 代表crate | 返り値のイメージ | 位置情報 |
| --- | --- | --- | --- |
| pull / event | pulldown-cmark | `Event`のストリーム | `into_offset_iter`でbyte範囲 |
| CommonMark AST | comrak | `AstNode`木 | `sourcepos`（行と列） |
| mdast | markdown (markdown-rs) | mdastノード | byteと行・列（両方） |
| 構文木 | tree-sitter-md | 汎用syntax tree | byte範囲 |

```mermaid
flowchart LR
  md["Markdown 原稿"]
  md --> pull["pulldown-cmark\nEvent + byte range"]
  md --> ast["comrak\nAstNode + line/col"]
  md --> mdast["markdown-rs\nmdast + both"]
  md --> ts["tree-sitter-md\nNode + byte range"]
  pull --> tool["linter / 抽出 / 変換"]
  ast --> tool
  mdast --> tool
  ts --> tool
```

pull parser（event列）は走査しながら処理する用途に向きやすい。ASTやmdastの木は、一度組み立ててからルールを書く用途に向きやすい。

tree-sitter-mdは別枠で、grammarを差し替えやすく、エディタ連携の文脈で選ばれることが多い。

## pulldown-cmark — イベント列とbyte offset

[`pulldown-cmark`](https://docs.rs/pulldown-cmark)はCommonMark / GFMをevent列としてpullするparserだ。`into_offset_iter`を使うと、各eventに原文のbyte範囲（startからendまで）が付く。

```rust
use pulldown_cmark::{Event, Options, Parser};

let source = "# Heading One\n\n## Heading Two\n\n";
let parser = Parser::new_ext(source, Options::empty());

for (event, range) in parser.into_offset_iter() {
    if let Event::Text(text) = event {
        println!("{:?} @ {}..{}", text, range.start, range.end);
    }
}
```

```text
"Heading One" @ 2..13
"Heading Two" @ 18..29
```

見出しの開始と終了は`Start` eventと`End` eventで別途取れる。byte範囲は取れるが、行・列はAPI上は返さない。

GFM拡張は`Options::all()`で有効化できる。テーブルやタスクリストはeventとして現れる一方、autolinkはURL文字列が`Text` eventのまま残ることがある。

## comrak — CommonMark ASTとsourcepos

[`comrak`](https://docs.rs/comrak)はCommonMark ASTを構築するparserだ。`parse_document`でルート`AstNode`を得て、子ノードを再帰的に走査する。`sourcepos`はcomrakが各ノードに付ける行・列の位置で、始点と終点を行番号と列番号で表す。

```rust
use comrak::{parse_document, Arena, ComrakOptions, nodes::NodeValue};

let source = "# Heading One\n\n## Heading Two\n\n";
let arena = Arena::new();
let root = parse_document(&arena, source, &ComrakOptions::default());

for child in root.children() {
    let data = child.data.borrow();
    if let NodeValue::Heading(h) = &data.value {
        let sp = &data.sourcepos;
        println!(
            "h{}: {}:{} .. {}:{}",
            h.level, sp.start.line, sp.start.column, sp.end.line, sp.end.column
        );
    }
}
```

```text
h1: 1:1 .. 1:13
h2: 3:1 .. 3:14
```

byte範囲は返さない。HTMLレンダリングも同梱されるが、構造抽出だけが目的ならAST走査だけで足りる。

GFM拡張は`extension.*`フラグで個別に有効化する。テーブル、タスク、打ち消し線、autolinkはいずれもextensionを有効にすればAST上で扱える。

## markdown-rsとtree-sitter-md — mdastと構文木grammar

### markdown (markdown-rs)

[`markdown`](https://docs.rs/markdown) crate（markdown-rs 1.0系）はmdastを返す。`ParseOptions`の`constructs`に`Constructs::gfm()`を指定すればGFM構文を有効化できる。入口は`to_mdast`で、返り値は`mdast::Node`の木だ。

```rust
use markdown::{mdast, to_mdast, ParseOptions};

let source = "# Heading One\n\n## Heading Two\n\n";
let opts = ParseOptions {
    constructs: markdown::Constructs::gfm(),
    ..ParseOptions::default()
};
let root = to_mdast(source, &opts).expect("parse");

if let mdast::Node::Root(doc) = &root {
    for node in &doc.children {
        if let mdast::Node::Heading(h) = node {
            let pos = node.position().expect("position");
            println!(
                "h{} byte {}..{} line {}:{} .. {}:{}",
                h.depth,
                pos.start.offset,
                pos.end.offset,
                pos.start.line,
                pos.start.column,
                pos.end.line,
                pos.end.column,
            );
        }
    }
}
```

```text
h1 byte 0..13 line 1:1 .. 1:14
h2 byte 15..29 line 3:1 .. 3:15
```

木全体の形はおおむね次のとおり。各ノードに`position`（byteの`offset`と行・列）が載る。

```text
Root
  Heading depth=1          ← "# Heading One" 全体
    Text "Heading One"     ← 見出しテキストだけ
  Heading depth=2          ← "## Heading Two" 全体
    Text "Heading Two"
```

`Heading`ノードの`position`は`#`を含む行全体、`Text`ノードは地の文部分だけを指す。4 parserのうち、位置モデルが最も情報量が多い。

autolinkだけ扱いが弱いことがある。本文中のbare URLがmdastの`Link`として現れないケースがあり、後段で補完が要る可能性がある。

### tree-sitter-md

tree-sitterはgrammar定義から構文木を返す汎用パーサー基盤だ。エディタのハイライトや増分parseで広く使われ、言語を変えるときはgrammarだけ差し替える。Markdown専用のAST crateではなく、syntax treeエンジンにgrammarを載せた位置づけになる。

[`tree-sitter-md`](https://docs.rs/tree-sitter-md)はそのMarkdown用grammarだ。Rustからは`tree_sitter` crate経由でparserを組み立て、`Node::kind()`とbyte範囲で木を走査する。

```rust
use tree_sitter::{Node, Parser};

fn visit(node: Node, source: &str, kind: &str) {
    if node.kind() == kind {
        let text = node.utf8_text(source.as_bytes()).unwrap_or("");
        println!(
            "{} byte {}..{} {:?}",
            kind,
            node.start_byte(),
            node.end_byte(),
            text.trim_end()
        );
    }
    for i in 0..node.child_count() {
        if let Some(child) = node.child(i as u32) {
            visit(child, source, kind);
        }
    }
}

let source = "# Heading One\n\n## Heading Two\n\nParagraph with `inline` code.\n";
let mut parser = Parser::new();
parser
    .set_language(&tree_sitter_md::LANGUAGE.into())
    .expect("language");
let tree = parser.parse(source, None).expect("parse");
visit(tree.root_node(), source, "atx_heading");
visit(tree.root_node(), source, "paragraph");
```

```text
atx_heading byte 0..14 "# Heading One"
atx_heading byte 15..30 "## Heading Two"
paragraph byte 31..61 "Paragraph with `inline` code."
```

木を1段深く見ると、見出しは`atx_heading`の下に`inline`、段落も`paragraph`の下に`inline`がぶら下がる。byte範囲は取れるが、行・列はAPI上は返さない。

```text
document
  atx_heading byte 0..14
    inline byte 2..13 "Heading One"
  atx_heading byte 15..30
    inline byte 18..29 "Heading Two"
  paragraph byte 31..61
    inline byte 31..60 "Paragraph with `inline` code."
      ` byte 46..47 "`"
      ` byte 53..54 "`"
```

`inline`のバッククォートは`inline_code`ノードではなく、文字ノードとして残る。リンクも`inline`テキストからアプリ側で拾う必要がある場合がある。打ち消し線はgrammar定義次第で専用ノードになる。

GFMのテーブル、タスクリスト、打ち消し線、autolinkはgrammar定義次第で`pipe_table`などのノードになる。

## 例示原稿で見る — 構造、位置、拡張記法

```markdown
# Heading One

## Heading Two

Paragraph with `inline` code.
```

### 位置モデル

| parser | byte範囲 | 行と列 | 見出しh1テキストの取り方 |
| --- | --- | --- | --- |
| pulldown-cmark | あり | なし | `Text` eventのslice（`#`は除く） |
| comrak | なし | あり | `sourcepos`の行・列 |
| markdown-rs | あり | あり | mdast `position` |
| tree-sitter-md | あり | なし | 子`inline`のbyte範囲（`#`除く） |

byte位置で指摘や修正を返すlinterなら、pulldown-cmarkとmarkdown-rsがそのまま使える。tree-sitter-mdもbyte範囲は取れるが、インライン要素によっては二次抽出が要る場合がある。行と列だけを要求するeditorなら、comrakのsourceposか、markdown-rsの両方付きpositionが扱いやすい。

### GFM拡張

| 拡張 | pulldown-cmark | comrak | markdown-rs | tree-sitter-md |
| --- | --- | --- | --- | --- |
| テーブル | event化 | AST + extension | mdast + GFM constructs | grammarノード |
| タスクリスト | event化 | AST + extension | mdast + GFM constructs | grammarノード |
| 打ち消し線 | event化 | AST + extension | mdast + GFM constructs | grammarノード |
| autolink | Textに残りやすい | extensionでLink | Linkにならないことがある | grammar依存 |

### 壊れた入力

```text
```rust
unclosed fence

[broken link(
```

| parser | ふるまいの傾向 |
| --- | --- |
| pulldown-cmark | panicせず、block数が少なく出ることがある |
| comrak | panicせず、未閉linkを段落テキストとして残すことがある |
| markdown-rs | panicせず、未閉linkを段落テキストとして残すことがある |
| tree-sitter-md | panicせず、構文木は返る |

未閉 fence / link の例でも panic はしない。部分parseの結果をそのまま信頼するか、別途well-formedチェックを挟むかはアプリ側で決める必要がある。

## ユースケース別の選定

| 用途 | 向くparser | 理由 |
| --- | --- | --- |
| linter / 指摘位置をbyteで返す | pulldown-cmark, markdown-rs | byte範囲が直接取れる |
| HTML変換 + AST走査 | comrak | CommonMark AST + レンダラ同梱 |
| mdastエコシステムと接続 | markdown-rs | mdast出力、位置もbyte + 行・列 |
| エディタ / 増分parse / grammar差し替え | tree-sitter-md | 汎用syntax tree、grammar差し替えが容易 |
| ストリーミング走査 | pulldown-cmark | event pullでメモリ効率が良い |
