Leistungsstarke Parser in Rust mit nom und pest erstellen
Lukas Schneider
DevOps Engineer · Leapcell

Einleitung
Im Bereich der Softwareentwicklung ist die Notwendigkeit, strukturierte Daten zu interpretieren und zu verarbeiten, allgegenwärtig. Ob Konfigurationsdateien, domänenspezifische Sprachen, Netzwerkprotokolle oder komplexe Benutzereingaben – Parsing ist das Herzstück vieler Anwendungen. Während das manuelle Schreiben von Parsern eine mühsame und fehleranfällige Aufgabe sein kann, insbesondere für nicht-triviale Grammatiken, bietet Rust mit seinem Fokus auf Leistung und Sicherheit leistungsstarke Werkzeuge, um diese Aufgabe zu vereinfachen. Dieser Blogbeitrag untersucht zwei herausragende Parser-Kombinator-Bibliotheken im Rust-Ökosystem – nom
und pest
– und zeigt, wie sie Entwickler befähigen, effiziente und robuste Parser mit Eleganz und Leichtigkeit zu erstellen. Wir werden uns mit ihren Methoden befassen, ihre Ansätze vergleichen und Ihnen das Wissen vermitteln, um das richtige Werkzeug für Ihre nächste Parsing-Herausforderung auszuwählen.
Kernkonzepte, bevor wir parsen
Bevor wir uns mit den Besonderheiten von nom
und pest
befassen, definieren wir einige grundlegende Konzepte, die für das Verständnis ihrer Funktionsweise entscheidend sind:
- Parser: Eine Funktion oder ein Komponente, die einen Eingabe-String oder Byte-Stream annimmt und ihn in eine strukturierte Darstellung umwandelt, normalerweise einen Abstract Syntax Tree (AST) oder eine einfachere Datenstruktur.
- Kombinator: Im Kontext des Parsens ist ein Kombinator eine höherwertige Funktion, die einen oder mehrere Parser als Eingabe nimmt und einen neuen Parser zurückgibt. Dies ermöglicht den Aufbau komplexer Parser aus einfacheren, wiederverwendbaren Komponenten, ähnlich den Paradigmen der funktionalen Programmierung.
- Grammatik: Eine Reihe von Regeln, die die gültige Struktur einer Sprache oder eines Datenformats definieren. Grammatiken werden oft mit formalen Grammatiken wie Backus-Naur-Form (BNF) oder Extended Backus-Naur-Form (EBNF) ausgedrückt.
- Abstract Syntax Tree (AST): Eine Baumdarstellung der abstrakten syntaktischen Struktur von Quellcode, der in einer Programmiersprache geschrieben ist. Jeder Knoten im Baum bezeichnet eine Konstruktion, die im Quellcode vorkommt.
- Lexer (oder Tokenizer): Die erste Phase des Parsens, die den Eingabetext in eine Sequenz von Tokens zerlegt (sinnvolle Einheiten wie Schlüsselwörter, Bezeichner, Operatoren usw.).
- Parser Generator: Ein Werkzeug, das eine Grammatikdefinition als Eingabe nimmt und automatisch Quellcode für einen Parser generiert.
pest
ist ein Beispiel für einen Parser Generator. - Parser Combinator Library: Eine Bibliothek, die eine Reihe von Funktionen (Kombinatoren) bereitstellt, mit denen ein Parser manuell aus kleineren Parsing-Funktionen zusammengesetzt werden kann.
nom
ist ein Beispiel für eine Parser Combinator Library.
Erstellen von Parsern mit nom
nom
ist eine leistungsstarke, Zero-Copy Parser Combinator Library für Rust. Ihre Designphilosophie betont einen funktionalen Ansatz, bei dem Parsing-Regeln aus kleineren, leicht testbaren Funktionen zusammengesetzt werden. nom
arbeitet direkt auf Bye-Slices oder String-Slices und vermeidet unnötige Speicherzuweisungen und Kopien, was erheblich zu seiner Effizienz beiträgt.
Lassen Sie uns nom
mit einem einfachen Beispiel veranschaulichen: das Parsen eines grundlegenden Schlüssel-Wert-Paarformats wie key:value
.
use nom::; // Definiere einen Parser für einen Schlüssel (alphanumerische Zeichen) fn parse_key(input: &str) -> Option<(&str, &str)> { alpha1(input) } // Definiere einen Parser für einen Wert (beliebiges Zeichen bis zum Zeilenumbruch oder Ende der Eingabe) fn parse_value(input: &str) -> Option<(&str, &str)> { take_while1(|c: char| c.is_ascii_graphic())(input) } // Kombiniere Schlüssel- und Wert-Parser mit einem Trennzeichen fn parse_key_value(input: &str) -> Option<(&str, (&str, &str))>) { // `separated_pair` nimmt drei Parser: das erste Element, das Trennzeichen und das zweite Element. separated_pair(parse_key, tag(":"), parse_value)(input) } fn main() { let input = "name:Alice\nage:30"; match parse_key_value(input) { Some((remaining, (key, value))) => { println!("Geparster Schlüssel: {}, Wert: {}", key, value); println!("Verbleibende Eingabe: '{}'", remaining); } None => println!("Fehler beim Parsen"), } let input_with_whitespace = " city:NewYork "; let (remaining, (key, value)) = separated_pair( multispace0.and_then(parse_key), // Erlaubt optionalen Leerraum vor dem Schlüssel tag(":"), parse_value, )(input_with_whitespace) .expect("Fehler beim Parsen mit Leerraum"); println!("Geparster Schlüssel: {}, Wert: {}", key, value); println!("Verbleibende Eingabe: '{}'", remaining); }
In diesem Beispiel:
- Wir definieren
parse_key
undparse_value
mit den integrierten Kombinatoren vonnom
wiealpha1
(entspricht einem oder mehreren alphabetischen Zeichen) undtake_while1
(entspricht Zeichen, solange eine Bedingung erfüllt ist). tag(":")
ist ein einfacher Parser, der den literalen String:
abgleicht.separated_pair
ist ein leistungsstarker Kombinator, der drei Parser nacheinander anwendet: einen Parser für das erste Element, einen Parser für das Trennzeichen und einen Parser für das zweite Element. Er gibt die Ergebnisse der Element-Parser als Tupel zurück.- Der von
nom
-Parsern zurückgegebeneIResult
-Typ enthält entweder die verbleibende Eingabe und den geparsten Wert bei Erfolg oder einen Fehler.
nom
glänzt, wenn Sie eine detaillierte Kontrolle über das Parsen benötigen, mit Binärformaten arbeiten oder wenn die Leistung absolut entscheidend ist, aufgrund seiner Zero-Copy-Natur. Seine Lernkurve kann für absolute Anfänger steiler sein, da das Verständnis der Zusammenstellung vieler kleiner Parsing-Funktionen erforderlich ist.
Erstellen von Parsern mit pest
pest
verfolgt einen anderen Ansatz, indem es Parser-Generatoren nutzt. Anstatt die Parsing-Logik in Rust-Code zu schreiben, definieren Sie Ihre Grammatik in einer separaten Datei mit der benutzerdefinierten EBNF-ähnlichen Syntax von pest
. pest
generiert dann den Parsing-Code für Sie, was es sehr gut für komplexe Grammatiken und domänenspezifische Sprachen (DSLs) geeignet macht, bei denen die Lesbarkeit und Wartbarkeit der Grammatikdefinition von größter Bedeutung sind.
Lassen Sie uns das gleiche Schlüssel-Wert-Paarformat mit pest
parsen. Definieren Sie zuerst die Grammatik in einer Datei namens key_value.pest
:
// key_value.pest WHITESPACE = _{ " " | "\t" } key = @{ ASCII_ALPHA+ } value = @{ (ANY - NEWLINE)+ } pair = { key ~ ":" ~ value }
Als Nächstes integrieren Sie pest
in Ihre main.rs
:
use pest::Parser; use pest_derive::Parser; // Fügen Sie den generierten Parser aus der Grammatikdatei ein #[derive(Parser)] #[grammar = "key_value.pest"] // Pfad zu unserer Grammatikdatei pub struct KeyValueParser; fn main() { let input = "name:Alice\n misure:100cm"; // Parsen der Eingabe-Zeichenkette mit der "pair"-Regel let pairs = KeyValueParser::parse(Rule::pair, input) .expect("Fehler beim Parsen der Eingabe"); 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!("Geparster Schlüssel: {}, Wert: {}", key, value); } } // Beispiel mit Leerraum (automatisch von der WHITESPACE-Regel behandelt) let input_with_whitespace = " city:NewYork "; let parsed_with_whitespace = KeyValueParser::parse(Rule::pair, input_with_whitespace) .expect("Fehler beim Parsen mit Leerraum"); 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!("Geparster Schlüssel: {}, Wert: {}", key, value); } } }
In der key_value.pest
-Grammatik:
WHITESPACE = _{ " " | "\t" }
definiert eine Regel für Leerraum. Das_
macht ihn "unsichtbar" –pest
ignoriert Leerraum zwischen Regeln automatisch, es sei denn, es wird ausdrücklich etwas anderes angegeben.key = @{ ASCII_ALPHA+ }
definiert einen Schlüssel als ein oder mehrere alphabetische Zeichen.@
bedeutet, dass wir den abgeglichenen Text erfassen möchten.value = @{ (ANY - NEWLINE)+ }
definiert einen Wert als ein oder mehrere beliebige Zeichen außer einem Zeilenumbruch. Dies ist ein gängiges Muster für Werte wie "Rest der Zeile".pair = { key ~ ":" ~ value }
kombiniert die Regelnkey
, literal":"
undvalue
, um einpair
zu bilden. Der Operator~
bezeichnet sequentiellen Abgleich.
pest
eignet sich hervorragend für:
- Bearbeitung komplexer, formal definierter Grammatiken.
- Wenn Grammatik-Lesbarkeit und Wartbarkeit kritisch sind.
- Wenn Sie einen deklarativen Weg zur Definition von Parsing-Regeln bevorzugen.
- Wenn der Overhead des generierten Parsers akzeptabel ist.
Choosing Between nom and pest
Sowohl nom
als auch pest
sind ausgezeichnete Werkzeuge, aber sie bedienen leicht unterschiedliche Anwendungsfälle und Präferenzen:
Feature | nom | pest |
---|---|---|
Ansatz | Parser Combinator Library (imperativ) | Parser Generator (deklarativ, grammatikgetrieben) |
Grammatikdef. | Rust-Code (Funktionen, Makros) | Separate .pest -Datei (EBNF-ähnliche Syntax) |
Leistung | Generell sehr hoch (Zero-Copy-Parsing) | Hoch, aber mit etwas Overhead durch generierten Code |
Flexibilität | Hoch, ideal für Binärformate, benutzerdefinierte Logik | Moderat, gut für textuelle Grammatiken |
Lernkurve | Steiler für komplexe Szenarien | Zugänglicher für die Grammatikdefinition |
Fehlerbehandlung | Explizite IResult -Handhabung | Integrierte Fehlerberichterstattung mit Span-Informationen |
Anwendungsfälle | Netzwerkprotokolle, Binärdaten, einfache Zeilenprotokolle | DSLs, Konfigurationsdateien, Programmiersprachen, Markup |
Für rohe Geschwindigkeit und Low-Level-Kontrolle, insbesondere bei Binäreingaben, ist nom
oft die erste Wahl. Sein Kombinator-Ansatz kann nach der Beherrschung unglaublich leistungsfähig sein. Für Sprach-Parsing, DSLs oder jedes Szenario, in dem eine klare Trennung zwischen Grammatikdefinition und Parsing-Logik vorteilhaft ist, bietet pest
eine deklarativere und oft lesbarere Lösung.
Letztendlich hängt die Wahl oft von der Komplexität Ihrer Grammatik, Ihren Leistungsanforderungen und Ihrem Komfortniveau mit jedem Paradigma ab. In einigen fortgeschrittenen Szenarien kombinieren Entwickler sogar Elemente, indem sie nom
für die lexikalische Analyse (Tokenisierung) verwenden und diese Tokens dann an einen pest
-generierten Parser für die syntaktische Analyse weitergeben.
Fazit
Rust bietet außergewöhnliche Möglichkeiten zum Erstellen effizienter und robuster Parser, und nom
und pest
stechen als die führenden Bibliotheken in diesem Bereich hervor. nom
bietet mit seinem funktionalen Parser-Kombinator-Ansatz unübertroffene Leistung und detaillierte Kontrolle und ist damit ideal für Low-Level- und Binär-Parsing-Aufgaben. pest
vereinfacht hingegen die Erstellung komplexer textueller Parser durch seine leistungsstarke Grammatikdefinitions-Sprache und Code-Generierung, was eine klare und wartbare DSL ermöglicht. Durch das Verständnis ihrer Kernprinzipien und Anwendungszenarien können Rust-Entwickler selbstbewusst das richtige Werkzeug auswählen, um jede Parsing-Herausforderung zu meistern und unstrukturierte Daten präzise und schnell in aussagekräftige Erkenntnisse umzuwandeln.