💻

Rust言語のChumskyでパーサ入門

に公開

パーサとは

広義には、文字列をプログラミングで扱うデータ構造にするため、文字列の構文を解析すること(構文解析)で、その途中の文字列を単語単位に分割する処理(字句解析)を含む、全体の処理を指します。

つまり、文字列を解析してコンピュータ上で使用するための前処理です。
例としては

  • 1 * (2 + 3) の文字列を構文解析し計算を行う
  • プログラミング言語を構文解析し、処理を実行 (インタープリタ)
  • プログラミング言語を構文解析し、マシン語に変換 (コンパイラ)
  • HTML、JSON、TOMLなどデータ記述形式を構文解析し、データの読み込み

chumskyとは

chumskyは、Rust言語のいわゆるパーサコンビネーターライブラリです。
小さなパース用のプリミティブ関数を組み合わせてパースしていきます。
公式Webサイトによるとパフォーマンスの良いパーサを簡単に書けるとのことです。

https://github.com/zesterer/chumsky


Rust言語用のパーサライブラリ、パーサジェネレータとしてはNomPestなどもあるのですが、今回はchumskyでは同じ作者のエラー表示ライブラリのariadneを使うと文法エラー時の表示もわかりやすく表示できそうだったので一緒に試していきたいと思います。

今回は簡単な使用方法を確認して、次回はエラー表示についても確認したいと思います。

セットアップ

何はともあれcargoでセットアップします。
cargorustupなどRust言語自体のセットアップはこちらを参照してください。

$ cargo new chumsky-sample
$ cd chumsky-sample/
$ cargo add chumsky

例1

最初に任意の一文字を読み込むprimitiveのany()*を使用しany()を繰り返し呼び出し全ての文字列を正常にパースするパーサ関数を作成してみます。

src/main.rsを以下の様に記述します。

use chumsky::prelude::*;

fn parser<'a>() -> impl Parser<'a, &'a str, String, extra::Err<Simple<'a, char>>> {
    any()                    // 1文字読み込む
        .repeated()          // 繰り返し読み込む
        .collect::<String>() // Vec<char>をStringに変換
        .then_ignore(end())  // 入力の終端を確認
}

fn main() {
    let parser = parser();

    let input = "Hello, Chumsky!";
    let output = parser.parse(input);
    println!("Input: \"{}\", Output: '{:?}'", input, output);
}

これでcargo runします。

$ cargo run
   Compiling chumsky-sample v0.1.0 (/home/test-user/chumsky-sample)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.44s
     Running `target/debug/chumsky-sample`
Input: "Hello, Chumsky!", Output: 'ParseResult { output: Some("Hello, Chumsky!"), errs: [] }'

例2

例1だと全ての文字列をパスしてしまうので、一文字も入力がない場合はエラーとします。
また、Rust言語のテストビルド機能も使用してみます。

src/main.rsを以下の様に修正します。

use chumsky::prelude::*;

fn parser<'a>() -> impl Parser<'a, &'a str, String, extra::Err<Simple<'a, char>>> {
    any()                    // 1文字読み込む
        .repeated()          // 繰り返し読み込む
        .at_least(1)         // 最低1文字必要
        .collect::<String>() // Vec<char>をStringに変換
        .then_ignore(end())  // 入力の終端を確認
}

fn main() {
    let parser = parser();

    let input = "Hello, Chumsky!";
    let output = parser.parse(input);
    println!("Input: \"{}\", Output: '{:?}'", input, output);

    let input = "";
    let output = parser.parse(input);
    println!("Input: \"{}\", Output: '{:?}'", input, output);
}

#[cfg(test)]
mod tests {
    use super::*; // mod testsの外側の関数を呼び出し可能
    #[test]
    fn test_hello() {
        assert_eq!(parser().parse("hello").unwrap(), "hello");
        assert!(parser().parse("").has_errors());
    }
}

これでcargo runします。

$ cargo run
   Compiling chumsky-sample v0.1.0 (/home/test-user/chumsky-sample)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.19s
     Running `target/debug/chumsky-sample`
Input: "Hello, Chumsky!", Output: 'ParseResult { output: Some("Hello, Chumsky!"), errs: [] }'
Input: "", Output: 'ParseResult { output: None, errs: [found end of input at 0..0] }'

cargo testするとテストを実行できます。

$ cargo test
   Compiling chumsky-sample v0.1.0 (/home/test-user/chumsky-sample)
    Finished `test` profile [unoptimized + debuginfo] target(s) in 0.21s
     Running unittests src/main.rs (target/debug/deps/chumsky_sample-613369c44cd28885)

running 1 test
test tests::test_hello ... ok

test result: ok. 1 passed; 0 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.00s

primitiveについて

例1、例2で使用したものや代表的なprimitiveについて説明します。
詳細についてはこちらを参照してください。

  • any()
    どんな単一のトークンでもパースします。パース結果としてそのトークンを返します。
  • repeated()
    パーサを指定された回数だけ繰り返します。
    repeated().at_least(1)repeated().exactly(3)などで回数を指定します。
  • end()
    入力の終端(EOF)をパースします。
  • just()
    基本的なパーサです。特定の単一のトークン(文字、数字など)をパースします。引数に渡されたトークンと厳密に一致するものをパースします。
    例: just('a')aという文字をパースします。

整数の四則演算

括弧付きの整数の四則演算のパーサを、パースしてAST(構文木)として取得するところまで作成してみます。

use chumsky::prelude::*;

#[derive(Debug, PartialEq, Clone)]
enum Op {
    Add,
    Sub,
    Mul,
    Div,
}

#[derive(Debug, PartialEq, Clone)]
enum Expr {
    Num(i64),
    Op(Box<Expr>, Op, Box<Expr>),
}

fn parser<'a>() -> impl Parser<'a, &'a str, Expr, extra::Err<Simple<'a, char>>> {
    // 整数をパースするパーサ
    let num = just('-')
        .or_not()
        .then(text::int(10))
        .to_slice()
        .padded()
        .map(|s: &str| Expr::Num(s.parse().unwrap()));

    // 括弧をパースするパーサを遅延評価で定義
    let expr = recursive(|expr| {
        // 最も単純な項(整数または括弧で囲まれた式)
        let term = num.or(expr.delimited_by(just('('), just(')'))).padded();

        // 乗除算のパーサ
        let factor = term.clone().foldl(
            just('*')
                .to(Op::Mul)
                .or(just('/').to(Op::Div))
                .then(term)
                .repeated(),
            |lhs, (op, rhs)| Expr::Op(Box::new(lhs), op, Box::new(rhs)),
        );

        // 加減算のパーサ
        factor.clone().foldl(
            just('+')
                .to(Op::Add)
                .or(just('-').to(Op::Sub))
                .then(factor)
                .repeated(),
            |lhs, (op, rhs)| Expr::Op(Box::new(lhs), op, Box::new(rhs)),
        )
    });

    expr.then_ignore(end())
}

fn main() {
    let parser = parser();

    // 実行例1: 優先順位が考慮される
    let input1 = "10 + 2 * 3"; // 10 + (2 * 3) = 16
    match parser.parse(input1).into_result() {
        Ok(ast) => println!("Input: \"{}\"\nParsed AST: {:?}\n", input1, ast),
        Err(errors) => eprintln!("Parse failed for \"{}\": {:?}\n", input1, errors),
    }

    // 実行例2: 括弧の処理
    let input2 = "(10 + 2) * 3"; // 12 * 3 = 36
    match parser.parse(input2).into_result() {
        Ok(ast) => println!("Input: \"{}\"\nParsed AST: {:?}\n", input2, ast),
        Err(errors) => eprintln!("Parse failed for \"{}\": {:?}\n", input2, errors),
    }

    // 実行例3: 組み合わせの確認
    let input3 = "10 / 2 - 5 * 2 + 100"; // (10 / 2) - (5 * 2) + 100 = 5 - 10 + 100 = 95
    match parser.parse(input3).into_result() {
        Ok(ast) => println!("Input: \"{}\"\nParsed AST: {:?}\n", input3, ast),
        Err(errors) => eprintln!("Parse failed for \"{}\": {:?}\n", input3, errors),
    }

    // 実行例4: 無効な式
    let input4 = "10 + * 2";
    match parser.parse(input4).into_result() {
        Ok(ast) => println!("Input: \"{}\"\nParsed AST: {:?}\n", input4, ast),
        Err(errors) => eprintln!("Parse failed for \"{}\": {:?}\n", input4, errors),
    }
}

cargo runしてみます。
カッコや剰余の優先順位を崩さずASTとして表現できました。

$ cargo run
   Compiling chumsky-sample v0.1.0 (/home/test-user/chumsky-sample)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.92s
     Running `target/debug/chumsky-sample`
Input: "10 + 2 * 3"
Parsed AST: Op(Num(10), Add, Op(Num(2), Mul, Num(3)))

Input: "(10 + 2) * 3"
Parsed AST: Op(Op(Num(10), Add, Num(2)), Mul, Num(3))

Input: "10 / 2 - 5 * 2 + 100"
Parsed AST: Op(Op(Op(Num(10), Div, Num(2)), Sub, Op(Num(5), Mul, Num(2))), Add, Num(100))

Parse failed for "10 + * 2": [found ''*'' at 5..6]

最後に

四則演算もお代としてはよくあるものですがchumskyprimitiveを使用しながらパースしていくのには慣れが必要でした。

次回はariadneを使用したエラー表示の確認をしたいと思います。

Discussion