🦀

Rustのパーサーライブラリ、Chumskyの紹介

2022/03/19に公開

はじめに

この記事ではRustのパーサーライブラリ、Chumskyに軽く入門していきます。
ChumskyはAriadneと連携することで綺麗なエラー表示を行うことができるのでそれもやっていきます。

参考リンク

  • Chumsky
    本書で紹介するChumskyのリポジトリ
  • Ariadne
    エラー表示用のライブラリ。Chumskyと同じ作者が作っている
  • Tao
    Chumskyと同じ作者が作っているプログラミング言語。パーサーにChumskyを使っているので例を見るのにちょうど良い

Chumskyとは

ChumskyはRust用のパーサーライブラリで、他のパーサーライブラリと比べると

  • エラー処理に力を入れている
  • Ariadneと連携することでかっこいいエラー表示ができる。(もちろん他のパーサーでもAriadneを使うことはできます)
  • トークン列用のパーサーから元の文字列の範囲(スパン)を取れる。先にLexerでトークンに分割してそのトークン列に対してさらにパースするというワークフローが強力にサポートされている。本記事では触れませんが後でこれに関する記事を書くかもしれません。

などの特徴があり、個人的には自作プログラミング言語用のパーサーを作るのにかなり適していると感じています。

パフォーマンス

パフォーマンスはChumskyの最優先事項ではないようですが、現在開発中のZero-Copy parsing機能(マッチした文字列をStringではなく元の入力への&strで取る機能)を使ったベンチマークではJSONのパースがnomよりも速いという結果が出ています(これはおどろき)。#94

Chumsky入門

Chumskyはいわゆるパーサーコンビネーターライブラリです。色々なパーサーを組み合わせて目的のパーサーを作っていきます。

全体のソースコードはこちらにあります。

例として、yyyy/mm/ddの形式の日付のパーサーを書いてみます。
各数字の桁数が想定と違う場合はエラーを報告しつつ、パースは続けていくようにします。

use chumsky::prelude::*;

// パーサーの具体的な型は多くの場合書くことが不可能なため`impl ...`を使う
// Errorとして`chumsky::error::Simple`を使う。自分でエラー型を定義して独自のエラーを保持するようにすることもできる。
fn yyyy_mm_dd() -> impl Parser<char, (u32, u32, u32), Error = Simple<char>> {
    // len桁の数字のパーサー
    let number = |len| {
        // 10進法の数字列
        text::digits(10)
            // 出力をバリデートする
            // 数字列の桁数が`len`でない場合エラーを報告するが。パース処理はそのまま続く
            .validate(move |number: String, span, emit| {
                // [0-9]+の文字列なのでバイト長がそのまま数字の桁数になる
                if number.len() != len {
                    // エラーを報告
                    // どのようなエラーが報告できるかは後述する
                    emit(Simple::custom(
                        span,
                        format!("length of a number must be {}, but got {}", len, &number),
                    ))
                }
                number
            })
            // 数字列をu32に変換する
            // 例えば数字が大きすぎてu32に変換できないときはもうどうしようもないのでパースを打ち切る
            .try_map(|number, span| {
                number.parse().map_err(|_| {
                    Simple::custom(span, format!("{} is an invalid u32 string", &number))
                })
            })
    };

    // yyyy
    number(4)
        // ラベルを付けるとエラー時にわかりやすくなる
        .labelled("yyyy")
        // '/'にマッチさせその結果は破棄する
        .then_ignore(
            // 名前の通り`just`は引数の文字列にそのままマッチする
            just('/').labelled("slash between yyyy and mm"))
        // mm
        // thenでパーサーをつなげると双方の結果がタプルで得られる
        .then(number(2).labelled("mm"))
        .then_ignore(just('/').labelled("slash between mm and dd"))
        // dd
        .then(number(2).labelled("dd"))
        .map(|((y, m), d)| (y, m, d))
}

#[test]
fn test_yyyy_mm_dd() {
    assert_eq!(yyyy_mm_dd().parse("2020/03/19").unwrap(), (2020, 03, 19));
    assert!(yyyy_mm_dd().parse("20201/03/19").is_err());

    // エラーがあってもそのままパースを続ける
    assert_eq!(
        yyyy_mm_dd().parse_recovery("20201/03/19").0,
        Some((20201, 03, 19))
    );
}

エラーの型にchumsky::error::Simpleを使用しました。
この記事では行いませんが独自のエラーを記録するためにエラー型を自分でつくることもできます。
Chumskyにビルトインで入っているchumsky::error::Simpleもいくつかのエラーの種類を記録することができます。

pub enum SimpleReason<I, S> {
    // 予期しない入力が来た
    Unexpected,
    // かっこの対応が取れていない
    Unclosed {
        span: S,
        delimiter: I,
    },
    // カスタムのエラーメッセージ
    Custom(String),
}

Ariadne入門

Ariadneはエラー出力用のライブラリです。Chumskyと同じ方が作者です。

ariadne

こんな感じでかっこよくエラーを表示することができます。

上記のエラー出力をする例

use ariadne::{Color, Fmt, Label, Report, ReportKind, Source};

fn main() {
    let src_id = "input.txt";
    let src = "2022a/03/19";

    Report::build(ReportKind::Error, src_id, 4)
        .with_message("Unexpected char")
        .with_label(
            Label::new((src_id, /* スパンは文字数単位、バイト数ではない */ 4..5))
                .with_message(format!("unexpected char {}", "a".fg(Color::Red))),
        )
        .finish()
        .print((src_id, Source::from(src)))
        .unwrap();
}

Chumsky, Ariadne両方とも文字列のSpanは文字数単位のstd::ops::Range<usize>なので他のパーサーライブラリでAriadneを使う際には注意が必要かもしれません。

大体見ただけでわかると思うので、Ariadneで上でつくったパーサーのエラー出力をやっていきます。
chumsky::error::Simpleをみていい感じにエラー表示のReportを作っていくだけです。

fn main() {
    for src in ["20221/03/19", "2021/june/10", "2022@10@10", "2022/"] {
        let (_, errs) = yyyy_mm_dd().parse_recovery(src);

        for e in errs {
            let message = match e.reason() {
                chumsky::error::SimpleReason::Unexpected
                | /* 括弧の対応についてはこのパーサーについては関係がないのでこのバリアントは出てこない */ chumsky::error::SimpleReason::Unclosed { .. } => {
                    format!(
                        "{}{}, expected {}",
                        if e.found().is_some() {
                            "unexpected token"
                        } else {
                            "unexpected end of input"
                        },
                        if let Some(label) = e.label() {
                            format!(" while parsing {}", label.fg(Color::Green))
                        } else {
                            " something else".to_string()
                        },
                        if e.expected().count() == 0 {
                            "somemething else".to_string()
                        } else {
                            e.expected()
                                .map(|expected| match expected {
                                    Some(expected) => expected.to_string(),
                                    None => "end of input".to_string(),
                                })
                                .collect::<Vec<_>>()
                                .join(", ")
                        }
                    )
                }
                chumsky::error::SimpleReason::Custom(msg) => msg.clone(),
            };

            Report::build(ReportKind::Error, (), e.span().start)
                .with_message(message)
                .with_label(Label::new(e.span()).with_message(match e.reason() {
                    chumsky::error::SimpleReason::Custom(msg) => msg.clone(),
                    _ => format!(
                            "Unexpected {}",
                            e.found()
                                .map(|c| format!("token {}", c.fg(Color::Red)))
                                .unwrap_or_else(|| "end of input".to_string())
                        ),
                }))
                .finish()
                .print(Source::from(src))
                .unwrap();
        }
    }
}

errors

いい感じにエラー表示ができてます。

おわり

本記事ではChumskyでパースし、Ariadneでエラーを表示するやりかたを簡単に紹介しました。
読者の方が例えば自作言語を作る際にChumsky, Ariadneでやる方法の参考になれば幸いです。

GitHubで編集を提案

Discussion