Rustにおけるnomとpestを用いた効率的なパーサー構築
Lukas Schneider
DevOps Engineer · Leapcell

はじめに
ソフトウェア開発の領域では、構造化されたデータを解釈および処理する必要性は遍在しています。設定ファイル、ドメイン固有言語、ネットワークプロトコル、あるいは複雑なユーザー入力であっても、パーシングは多くのアプリケーションの中心に位置しています。手動でパーサーを記述することは、特に非自明な文法の場合、退屈でエラーが発生しやすい作業となり得ますが、Rustはパフォーマンスと安全性に重点を置いているため、このタスクを簡素化する強力なツールを提供します。このブログ投稿では、Rustエコシステムにおける2つの著名なパーサーコンビネータライブラリ、nom
とpest
を探求し、それらが開発者にエレガントかつ容易に効率的で堅牢なパーサーを構築する力をどのように与えるかを実証します。それらの方法論を深く掘り下げ、アプローチを比較し、次のパーシングの課題に最適なツールを選択するための知識を身につけます。
パーシングの前に、コアコンセプトを理解する
nom
とpest
の複雑さに入る前に、それらの操作を理解するために不可欠ないくつかの基本的な概念を定義しましょう。
- パーサー (Parser): 入力文字列またはバイトストリームを受け取り、それを構造化された表現、通常は抽象構文木(AST)またはより単純なデータ構造に変換する関数またはコンポーネント。
- コンビネータ (Combinator): パーシングの文脈では、コンビネータは1つ以上のパーサーを入力として受け取り、新しいパーサーを返す高階関数です。これにより、関数型プログラミングのパラダイムに似た、より単純で再利用可能なコンポーネントから複雑なパーサーを構築できます。
- 文法 (Grammar): 言語またはデータ形式の有効な構造を定義するルールのセット。文法は、バッカステレビュー記法(BNF)または拡張バッカステレビュー記法(EBNF)のような形式文法を使用して表現されることがよくあります。
- 抽象構文木 (AST): プログラミング言語で記述されたソースコードの抽象構文構造のツリー表現。ツリーの各ノードは、ソースコードに発生するコンストラクトを表します。
- 字句解析器(またはトークナイザー)(Lexer/Tokenizer): パーシングの最初のフェーズであり、入力テキストをトークン(キーワード、識別子、演算子などの意味のある単位)のシーケンスに分割します。
- パーサー生成器 (Parser Generator): 文法定義を入力として受け取り、パーサーのソースコードを自動生成するツール。
pest
はその一例です。 - パーサーコンビネータライブラリ (Parser Combinator Library): 小さなパーシング関数からパーサーを手動で構築するために使用できる関数(コンビネータ)のセットを提供するライブラリ。
nom
はその一例です。
nomを使用したパーサーの構築
nom
はRust用の強力なゼロコピーパーサーコンビネータライブラリです。その設計思想は、パーシングルールが小さく、テストしやすい関数から構成される機能的なアプローチを重視しています。nom
はバイトスライスまたは文字列スライスに直接操作し、不要なメモリ割り当てやコピーを回避するため、その効率に大きく貢献します。
簡単な例でnom
を説明しましょう。key:value
のような基本的なキーと値のペア形式を解析します。
use nom::; use nom::bytes::complete::{tag, take_while1}; use nom::character::complete::{alpha1, multispace0}; use nom::sequence::separated_pair; use nom::IResult; // キー(英数字)のパーサーを定義 fn parse_key(input: &str) -> IResult<&str, &str> { alpha1(input) } // 値(改行または入力終了までの任意の文字)のパーサーを定義 fn parse_value(input: &str) -> IResult<&str, &str> { take_while1(|c: char| c.is_ascii_graphic())(input) } // キーと値のパーサーを区切り文字で結合 fn parse_key_value(input: &str) -> IResult<&str, (&str, &str)> { // separated_pair は 3 つのパーサーを取ります:最初の要素、区切り文字、2 番目の要素。 separated_pair(parse_key, tag(":"), parse_value)(input) } fn main() { let input = "name:Alice\nage:30"; match parse_key_value(input) { Ok((remaining, (key, value))) => { println!("Parsed key: {}, value: {}", key, value); println!("Remaining input: \'{}'", remaining); } Err(e) => println!("Error parsing: {:?}", e), } let input_with_whitespace = " city:NewYork "; let (remaining, (key, value)) = separated_pair( multispace0.and_then(parse_key), // キーの前にオプションの空白を許可 tag(":"), parse_value, )(input_with_whitespace) .expect("Failed to parse with whitespace"); println!("Parsed key: {}, value: {}", key, value); println!("Remaining input: \'{}'", remaining); }
この例では:
alpha1
(1つ以上のアルファベット文字にマッチ)やtake_while1
(条件が満たされる限り文字にマッチ)のようなnom
の組み込みコンビネータを使用してparse_key
とparse_value
を定義します。tag(":")
はリテラル文字列:
にマッチする単純なパーサーです。separated_pair
は、最初の要素のパーサー、区切り文字のパーサー、2番目の要素のパーサーの3つのパーサーを順番に適用する強力なコンビネータです。要素パーサーの結果をタプルとして返します。nom
パーサーによって返されるIResult
型には、成功時には残りの入力と解析された値、またはエラーが含まれています。
nom
は、解析の細かい制御が必要な場合、バイナリ形式を扱っている場合、またはゼロコピーの性質によりパフォーマンスが絶対に重要な場合に輝きます。完全な初心者にとっては学習曲線が急になる可能性があり、多くの小さなパーシング関数をどのように構成するかを理解する必要があります。
pestを使用したパーサーの作成
pest
は、パーサー生成器を活用することで異なるアプローチを取ります。Rustコードでパーシングロジックを記述する代わりに、pest
のカスタムEBNFライク構文を使用して別のファイルに文法を定義します。pest
は、パーシングコードを生成するため、文法定義の可読性と保守性が最優先される複雑な文法やドメイン固有言語(DSL)に非常に適しています。
pest
を使用して同じキーと値のペア形式を解析しましょう。まず、key_value.pest
という名前のファイルに文法を定義します。
// key_value.pest WHITESPACE = _{" " | "\t"} key = @{ ASCII_ALPHA+ } value = @{ (ANY - NEWLINE)+ } pair = { key ~ ":" ~ value }
次に、main.rs
でpest
を統合します。
use pest::Parser; use pest_derive::Parser; // 文法ファイルから生成されたパーサーをインクルード #[derive(Parser)] #[grammar = "key_value.pest"] // 文法ファイルへのパス pub struct KeyValueParser; fn main() { let input = "name:Alice\n misure:100cm"; // "pair"ルールを使用して入力文字列を解析 let pairs = KeyValueParser::parse(Rule::pair, input) .expect("Failed to parse input"); for pair in pairs { if pair.as_rule() == Rule::pair { let mut inner_rules = pair.into_inner(); let key = inner_rules.next().unwrap().as_str(); let value = inner_rules.next().unwrap().as_str(); println!("Parsed key: {}, value: {}", key, value); } } // 空白を含む例(WHITESPACEルールによって暗黙的に処理される) let input_with_whitespace = " city:NewYork "; let parsed_with_whitespace = KeyValueParser::parse(Rule::pair, input_with_whitespace) .expect("Failed to parse with whitespace"); for pair in parsed_with_whitespace { if pair.as_rule() == Rule::pair { let mut inner_rules = pair.into_inner(); let key = inner_rules.next().unwrap().as_str(); let value = inner_rules.next().unwrap().as_str(); println!("Parsed key: {}, value: {}", key, value); } } }
key_value.pest
文法では:
WHITESPACE = _{ " " | "\t" }
は空白のルールを定義します。_
はそれを「見えない」ようにします。明示的に指示されない限り、pest
はルール間の空白を自動的に無視します。key = @{ ASCII_ALPHA+ }
は、1つ以上のアルファベット文字をキーとして定義します。@
は、一致したテキストをキャプチャしたいことを示します。value = @{ (ANY - NEWLINE)+ }
は、改行以外の任意の文字の1つ以上のシーエンスを値として定義します。「行の残り」の値の一般的なパターンです。pair = { key ~ ":" ~ value }
は、key
、リテラル``:、および
valueルールを組み合わせて
pairを形成します。
~`演算子はシーケンシャルマッチングを示します。
pest
は以下の場合に優れています:
- 複雑で正式に定義された文法を扱う場合。
- 文法の可読性と保守性が重要な場合。
- 解析ルールの宣言的な方法を好む場合。
- 生成されたパーサーのオーバーヘッドが許容できる場合。
nomとpestの選択
nom
とpest
はどちらも優れたツールですが、それぞれわずかに異なるユースケースや好みに対応しています。
特徴 | nom | pest |
---|---|---|
アプローチ | パーサーコンビネータライブラリ(命令型) | パーサー生成器(宣言型、文法駆動) |
文法定義 | Rustコード(関数、マクロ) | 別途.pest ファイル(EBNFライク構文) |
パフォーマンス | 一般的に非常に高い(ゼロコピーパーシング) | 高いが、生成コードによるオーバーヘッドが多少ある |
柔軟性 | 高く、バイナリ形式、カスタムロジックに最適 | 中程度、テキスト文法に最適 |
学習曲線 | 複雑なシナリオではより急勾配 | 文法定義の学習はより容易 |
エラー処理 | 明示的なIResult 処理 | スパン情報付きの組み込みエラーレポート |
ユースケース | ネットワークプロトコル、バイナリデータ、単純なラインプロトコル | DSL、設定ファイル、プログラミング言語、マークアップ |
生の速度と低レベルの制御、特にバイナリ入力の場合は、nom
がしばしば選択肢となります。そのコンビネータアプローチは、習得すれば非常に強力になり得ます。一方、pest
は、強力な文法定義言語とコード生成を通じて、複雑なテキストパーサーの作成を簡素化し、明確で保守可能なDSLを可能にします。
最終的な選択は、文法の複雑さ、パフォーマンス要件、および各パラダイムに対する快適さのレベルにかかっています。一部の高度なシナリオでは、開発者は要素を組み合わせて、字列解析(トークン化)にはnom
を使用し、その後、構文解析のためにpest
で生成されたパーサーにそれらのトークンを供給することさえあります。
結論
Rustは効率的で堅牢なパーサーを構築するための優れた機能を提供しており、nom
とpest
はこの分野の主要なライブラリとして際立っています。機能的なパーサーコンビネータアプローチを備えたnom
は、比類のないパフォーマンスと細かい制御を提供し、低レベルおよびバイナリパーシングタスクに最適です。一方、pest
は、強力な文法定義言語とコード生成を通じて、複雑なテキストパーサーの作成を簡素化し、明確で保守可能なDSLを可能にします。それらのコア原則とアプリケーションシナリオを理解することで、Rust開発者は自信を持ってあらゆるパーシングの課題に取り組むための適切なツールを選択し、構造化されていないデータを精度と速度で意味のある洞察に変えることができます。