rust-markdown-parser-comparison

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

 
0
このエントリーをはてなブックマークに追加
Kazuki Moriyama
Kazuki Moriyama (森山 和樹)

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範囲
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はCommonMark / GFMをevent列としてpullするparserだ。into_offset_iterを使うと、各eventに原文のbyte範囲(startからendまで)が付く。

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);
    }
}
"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はCommonMark ASTを構築するparserだ。parse_documentでルートAstNodeを得て、子ノードを再帰的に走査する。sourceposはcomrakが各ノードに付ける行・列の位置で、始点と終点を行番号と列番号で表す。

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
        );
    }
}
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 crate(markdown-rs 1.0系)はmdastを返す。ParseOptionsconstructsConstructs::gfm()を指定すればGFM構文を有効化できる。入口はto_mdastで、返り値はmdast::Nodeの木だ。

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,
            );
        }
    }
}
h1 byte 0..13 line 1:1 .. 1:14
h2 byte 15..29 line 3:1 .. 3:15

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

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はそのMarkdown用grammarだ。Rustからはtree_sitter crate経由でparserを組み立て、Node::kind()とbyte範囲で木を走査する。

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");
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上は返さない。

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などのノードになる。

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

# 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依存

壊れた入力

```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でメモリ効率が良い
info-outline

お知らせ

K.DEVは株式会社KDOTにより運営されています。記事の内容や会社でのITに関わる一般的なご相談に専門の社員がお答えしております。ぜひお気軽にご連絡ください。