sqleibnizist ein statisches Analysewerkzeug, das für SQL im SQLite-Dialekt die Syntax, das Vorhandensein von Tabellen, Spalten und Funktionen sowie Laufzeitbedingungen prüfen soll; dafür sind Tokenisierung und Parsing zentrale Schritte- Rusts
macro_rules!hilft dabei, AST-Node-Structs, Implementierungen desNode-Traits und tabellenbasierte Tests im Go-Stil ohne Wiederholungen zu erstellen - Mit
matches!- undmatch-Patterns lassen sich Grammatikverzweigungen wie SQLite-Zahlenliterale, Identifikatoren, Symbole undEXPLAIN QUERY PLANnah am Code abbilden Optionmitis_some_and,map,map_orsowie der?-Operator machen bei der Verarbeitung von Eingaben und Token-Streams das Prüfen auf vorhandene Werte, Transformationen, Default-Werte und Fehlerweitergabe kompakt- Rust-Iteratoren werden zum Entfernen von
_aus Zahlenliteralen, zur Validierung hexadezimaler Zeichen in Blobs und zur Berechnung von Fehlerpositionen genutzt; daraus ergibt sich der Vorteil, dass Tokenisierungs- und Parsing-Code besser lesbar wird
Analyseablauf von sqleibniz
sqleibnizist ein SQL-Analysewerkzeug in Entwicklung, das auf den SQLite-Dialekt ausgerichtet ist- Für SQL-Eingaben soll es Syntaxprüfungen durchführen, das Vorhandensein von Tabellen, Spalten und Funktionen verifizieren und Bedingungen in Kombination mit der eingebetteten SQLite-Runtime prüfen
- Fehlermeldungen sollen Kontext und Erklärungen liefern und es ermöglichen, bestimmte Diagnosen zu ignorieren
- Der Analyseablauf beginnt mit lexikalischer Analyse/Tokenisierung, führt über das Parsen von SQL gemäß der SQLite-Dokumentation und anschließend zur Analyse der Ergebnisstruktur
- Nach Abschluss des statischen Analyse-Teils ist außerdem geplant, einen LSP-Server für SQL zu schreiben
Wiederholungen bei AST-Nodes mit Makros entfernen
- Die AST-Nodes von
sqleibnizsind Structs, die einTokenenthalten, und alle Nodes müssen denNode-Trait implementieren - Der
Node-Trait nutztstd::fmt::Debugals Supertrait, sodass nur Typen, dieDebugerfüllen,Nodeimplementieren können - Damit nicht für jeden Node die Struct-Definition und die Implementierung von
fn token(&self) -> &Tokenwiederholt werden müssen, erzeugt das Makronode!diese Teile- Der Node-Name wird als
ident-Metavariable entgegengenommen - Der Dokumentationsstring wird als
literal-Metavariable entgegengenommen - Zusätzliche Felder werden in der Form
$($field_name:ident:$field_type:ty),*wiederholt verarbeitet
- Der Node-Name wird als
- Der Node
Literalhat nur das Token-Feld, während der NodeExplainzusätzlich das Feldchild: Option<Box<dyn Node>>besitzt - Dokumentationskommentare werden nicht als
///, sondern in der Form#[doc = $documentation]als Makroargumente an den Compiler übergeben
Tabellenbasierte Tests im Go-Stil mit Rust-Makros umsetzen
- Wie bei tabellenbasierten Tests in Go, bei denen ein Array von Eingabefällen durchlaufen wird und jeder Fall als eigenständiger Test ausgeführt wird, wird dieses Vorgehen mit Rust-Makros nachgebildet
- Lexer-Tests verwenden die Makros
test_group_pass_assert!undtest_group_fail!- Erfolgreiche Tests geben die Eingabe an den
Lexerweiter und vergleichen die Liste der Token-Typen aus dem Ergebnis vonLexer.run()mit dem erwarteten Wert - Fehlgeschlagene Tests prüfen, ob der Ergebnis-Token-Vektor leer ist und
Lexer.errorsmindestens einen Fehler enthält - Beim Ausführen von
cargo testgibt jeder Fall Feedback wie eine separate Testfunktion mitokoderfailaus
- Erfolgreiche Tests geben die Eingabe an den
- Parser-Tests folgen derselben Struktur, initialisieren aber nach dem Lexer-Lauf den
Parserund prüfen das Ergebnis vonparse()EXPLAIN VACUUM;undEXPLAIN QUERY PLAN VACUUM;sind ErfolgsfälleEXPLAIN;undEXPLAIN QUERY PLAN;sind Fehlerfälle- Die Fehlerfälle prüfen die Bedingung aus der SQLite-
sql-stmt-Grammatik, dass nachEXPLAINeine Anweisung erforderlich ist
Unbequeme Seiten von macro_rules!
- Innerhalb von
macro_rules!ist die Unterstützung durchrust-analyzereingeschränkt- Es gibt keine echte IntelliSense
- Es gibt kein Springen zur Definition
- Es gibt kein Hover für Literale und Signaturen von Sprachstrukturen
cargo fmtformatiert oder rückt weder das Innere vonmacro_rules!noch Makroaufrufe eintreesitterundchromahaben gelegentlich Schwierigkeiten mit Syntax-Highlighting fürmacro_rules!- Auch die Makro-Dokumentation ist eher unzureichend
Wo matches! und match beim Zeichen-Matching glänzen
- Zeichenvergleiche im Lexer sind die Grundlage weiterer Verarbeitung, und Rusts
matches!-Makro sowiematch-Patterns machen diesen Teil kompakt - Die Erkennung von SQLite-Zahlen wird mit
matches!geschrieben+,-_.a..=f,A..=F0..=9
- Auch die Erkennung von Identifikatoren wird etwa als
matches!(c, 'a'..='z' | 'A'..='Z' | '_' | '0'..='9')ausgedrückt - Die Hauptschleife des Lexers teilt das aktuelle Zeichen mit
matchauf- Leerzeichen werden übersprungen
*,;,,,%usw. erzeugen jeweils das entsprechende Token- Das Beispiel für die Behandlung unbekannter Symbole wird ausgelassen und mit
panic!("whoops")dargestellt
SQL-Grammatikstrukturen durch Token-Matching verarbeiten
- Der Lexer wandelt einen Zeichenstrom in einen Stream von
Token-Structs mit Positions- und Typinformationen um, und der Parser konsumiert diesen, um einen AST zu erstellen - Das Enum
Typeenthält unter anderemKeyword,Ident,Number,String,Blob,Boolean,ParamName,Param,Dot,Asteriks,Semicolon,Percent,Comma,Eof sql_stmt_prefixist eine Parser-Funktion, die dieEXPLAIN-Anweisung aus der SQLite-Dokumentation verarbeitet- Wenn das aktuelle Token
Type::Keyword(Keyword::EXPLAIN)ist, erzeugt sie einenExplain-Node und konsumiertEXPLAIN - Wenn das nächste Token
QUERYist, konsumiert sie nacheinanderQUERYundPLAN - Danach wird die eigentliche SQL-Anweisung als
childgeparst - Wenn es sich nicht um
EXPLAINhandelt, ruft sie die normale Verarbeitung vonsql_stmtauf
- Wenn das aktuelle Token
literal_valueerstellt aus Strings, Zahlen, Blobs und Booleans sowie Keyword-Literalen wieNULL,CURRENT_TIME,CURRENT_DATE,CURRENT_TIMESTAMPeinenLiteral-Node
Fehleranzeige und Nutzung von Option
- Lexer und Parser zeigen dem Nutzer Fälle wie ein fehlendes Semikolon am Ende einer SQL-Anweisung als Fehler an
- Rusts
?-Operator wird zur Fehlerbehandlung und Fehlerweitergabe verwendet Option::is_some_andwird genutzt, um zu prüfen, ob das nächste oder aktuelle Zeichen vorhanden ist und eine Bedingung erfülltself.source.get(self.pos + 1).is_some_and(...)self.source.get(self.pos).is_some_and(...)
Option::mapwird verwendet, um bei einerVec<u8>-Eingabe das nächste Byte in eincharumzuwandelnOption::map_orwird so genutzt, dass ein Typvergleich nur durchgeführt wird, wenn das aktuelle oder nächste Token vorhanden ist; andernfalls wirdfalsezurückgegeben
Zahlen und Blobs mit Iteratoren verarbeiten
- Beim Parsen von SQLite-Zahlen ist
_erlaubt, während Rusts Zahlenparser_nicht erlaubt; daher konsumiert der Lexer die Zahl inklusive_und entfernt sie vor dem Parsen - Diese Verarbeitung wird als Iterator-Chain geschrieben
- Ein Byte-Slice wird geholt
- Jedes Byte wird in ein
charumgewandelt - Nur Zeichen, die nicht
_sind, werden gefiltert - Das Ergebnis wird als
Stringgesammelt
- In dieser Situation wird
unwrap_or_default()verwendet, aber ein leerer String ist als Zahl ohnehin ungültig, sodass der Parser in jedem Fall fehlschlägt - In Go müsste man für dieselbe Verarbeitung eine Zeichenliste durchlaufen, Bytes in einen
strings.Builderschreiben und daraus wieder einen String erzeugen - SQLite-Blobs erlauben hexadezimale Daten in der Form
x'<hex>'; daher wird jedes Zeichen des Strings mitchars().enumerate()durchlaufen und mitis_ascii_hexdigit()geprüftenumeratewird verwendet, um die Position eines ungültigen Zeichens für die Fehleranzeige zu ermitteln- Wenn ein ungültiges hexadezimales Zeichen gefunden wird, wird ein Fehler erzeugt und die Verarbeitung abgebrochen
1 Kommentare
Hacker-News-Kommentare
Noch vor zwei Monaten hätte ich genauso gedacht wie der Autor, bin aber immer wieder gegen Rusts harte Grenze gestoßen: den Borrow Checker.
Algebraische Datentypen, etwa Enums und Pattern Matching, fand ich wirklich gut, aber wegen des Borrow Checkers und der Low-Level-Überlegungen zum Speicher verbrachte ich mehr Zeit damit, gegen den Borrow Checker zu kämpfen, als mit dem eigentlichen Kern des Projekts, also Problemen rund um Programmiersprachen.
Tokenisierung und Parsing waren deshalb in Ordnung, aber Interpreter und Typprüfung wurden zur Qual, und auf der Suche nach einer besser geeigneten Sprache prüfte ich F#, Zig/C und Go und entdeckte schließlich OCaml.
Die Syntax wirkt wie ein freundlicheres Haskell, und es sieht aus wie Rust ohne Lifetimes; das hat mich überzeugt. Außerdem wurde der erste Rust-Compiler in OCaml geschrieben, und OCaml ist im Bereich Programmiersprachen gut bekannt.
Ich lerne es noch, daher ist eine faire Bewertung schwierig, aber bisher kommt es ziemlich genau dem nahe, wonach ich gesucht habe.
Es ist pragmatisch und schnell, nicht wirklich Low-Level, kompiliert schnell und ist vor allem so populär, dass es alle Bibliotheken gibt, die man braucht — eigentlich sollte ich es wohl benutzen.
Aber die Sprache selbst mag ich fast schon irrational nicht, und ich empfinde alles an ihr als hässlich.
Es ist eine Sprache, die 2009 von Leuten aus der C-Ecke gebaut wurde, und selbst für damalige Maßstäbe wirkt es so, als hätten sie nichts von den interessanten Entwicklungen im Programmiersprachen-Design der vorangegangenen 20 Jahre mitbekommen.
Selbst PHP im Jahr 2009 war eine modernere und besser entworfene Sprache als Go, und ich werde das Gefühl nicht los, dass Go seitdem auch nicht wesentlich besser geworden ist.
Stattdessen sollte man etwas verwenden, das wie eine Bibliothek für statische Strings cheap clones und Interning ermöglicht, und für Textpositionen am besten nur Indizes nutzen.
Wenn möglich, sollte man das Speichern von Referenzen unbedingt vermeiden.
Je mehr man über Dinge hält, die sich billig/kostenlos klonen lassen, desto weniger kämpft man mit dem Borrow Checker, und wenn nötig kann man einfach auf einen Clone ausweichen.
Für den eigentlichen Interpreter sind Bibliotheken, die bei der Speicherverwaltung mit Ansätzen wie Arenas helfen, ziemlich nützlich.
Das ist ein sehr spezieller Bereich, bietet aber Performance und Nutzbarkeit zugleich; auch Projekte wie Ruffle verwenden solche Muster häufig.
Allerdings erledigen OCaml und Haskell solche Dinge dank eingebautem Reference Counting und Garbage Collection „gratis“, wobei mir die Idee trotzdem gefällt, mit Rust sehr schnell zu werden.
Go ist eher ein modernisiertes C, und das Modell, das es bietet, ist sehr einfach.
Aus C# kommend war es wegen dieser Einfachheit eher schwieriger zu lernen; die geringe konzeptionelle Last ist ein Vorteil, und es passt gut zu kleinen, fokussierten Anwendungen, bei denen man den Trade-off der Ausführlichkeit akzeptieren kann.
Wenn ich etwas empfehlen sollte, dann F#, oder auch modernes C#.
Microsoft ist zwar beteiligt, aber es wird schwierig, wenn man in einer Welt leben will, in der man gar nichts nutzt, was von einem bösen Großkonzern stammt.
Java, Go, Python, TypeScript/JavaScript und Swift fallen alle ebenfalls darunter, und dann bleiben kaum noch Optionen.
Ich wäre neugierig, was du nach ungefähr einem Jahr mit OCaml denkst.
Haskell-artige Sprachen sind interessant, aber bei Haskell selbst war für mich das Verhältnis von Lernkurve zu Nutzen nicht gut, und bei Rust ist es ähnlich.
In C# habe ich mich intensiv in das Typsystem eingearbeitet und es gemeistert, aber ich habe nicht die Zeit, bei Rust ähnlich tief einzusteigen.
Natürlich kommen dadurch etwa 10–20 MB bei Binary und Speicherverbrauch hinzu, aber nach heutigen Maßstäben ist das fast nichts.
Tailscale etwa scheint Go in seinen Mobile- und Desktop-Apps als plattformübergreifende WireGuard-Schicht zu verwenden, und das scheint gut zu funktionieren.
Eine native UI würde ich nicht in Go bauen, aber für Low-Level-Aufgaben ist es hervorragend.
TinyGo ermöglicht außerdem, Go für Mikrocontroller oder WebAssembly zu schreiben; vieles wird zwar nicht unterstützt, aber ein beträchtlicher Teil der Standardbibliothek ist nutzbar.
Zum Beispiel ist auch der Go-Compiler in Go geschrieben.
Dank Cross-Compilation und vergleichsweise kleiner Binaries ist die Bereitstellung sehr einfach.
Allerdings stimmt es, dass es an syntaktischem Zucker mangelt, und für Pattern Matching im funktionalen Stil ist es nicht gut geeignet.
Als Herangehensweise ans Parsing wirkt das etwas merkwürdig, und ich habe den Eindruck, dass der Autor mit Rust und den zugrunde liegenden Konzepten von Programmiersprachen vergleichsweise wenig vertraut ist.
Um ein paar Punkte herauszugreifen: Der AST ließe sich wahrscheinlich viel einfacher als algebraischer Datentyp definieren.
Es ist nicht zu erwarten, dass die sqlite-Syntax plötzlich so erweitert wird, dass massenhaft neue Knoten entstehen und dafür eine komplizierte Kodierung nötig wäre.
Die aktuelle Kodierung wirkt wie etwas, das jemandem einfällt, der mit Objektorientierung vertraut ist, aber nicht mit algebraischen Datentypen.
Die Aussage „Makros funktionieren in den meisten Sprachen unterschiedlich, aber der Hauptgrund ist, Code-Duplikation zu vermeiden und Wiederholungen zu reduzieren“ könnte man auch über jedes andere Abstraktionsmittel wie Funktionen sagen.
Das definierende Merkmal von Makros ist, dass sie zur Compile-Zeit ausgeführt werden.
Wenn man sehen möchte, wie man Parser sauber strukturiert, kann die Forschung zu Parser-Kombinatoren ein guter Ausgangspunkt sein.
Der Blogtitel lautet auch „Why I love ...“, und die Kritikpunkte wirken zwar berechtigt, aber auf mangelnde Erfahrung hinzuweisen scheint mir nicht wirklich nötig.
Es ist gut, wenn jemand Freude am Programmieren hat, und die Erfahrung wird schon kommen.
Das geht mit Funktionen nicht.
Aus Sicht von jemandem, der einen kleinen Parser [0] für die Forsyth-Edwards-Notation im Schach geschrieben hat, ist Haskell in Sachen Einfachheit und Lesbarkeit meiner Meinung nach klar überlegen.
Es liest sich fast wie BNF und hat kaum technisches Zeremoniell, sodass man sich tatsächlich auf die Grammatik konzentrieren kann, die man parsen will.
[0] https://github.com/ryandv/chesskell/blob/master/src/Chess/Fa...
[1] https://en.wikipedia.org/wiki/Forsyth%E2%80%93Edwards_Notati...
Ich frage mich, ob es einen klaren Grund gibt, warum ein ähnlicher Ansatz in Rust nicht gehen sollte.
Zum Beispiel scheint winnow [1] einen ausreichend deklarativen Stil zu bieten, und es gibt in Rust auch mehrere andere Parser-Kombinator-Bibliotheken.
[1]: https://docs.rs/winnow/latest/winnow/
Es lässt sich nämlich mit einer einfachen Funktion mit einer einzelnen Schleife implementieren.
Vor ein paar Tagen habe ich einen FEN-„Parser“ für eine experimentelle Quad-Bitboard-Implementierung geschrieben, und der schrieb sich praktisch von selbst.
Nebenbei: Ich bin der Autor von chessIO auf Hackage.
Ich habe in Rust einen eBPF-Disassembler und einen halb fertigen Emulator geschrieben, und Rust war für Parsing-artige Aufgaben eine ziemlich angenehme Sprache.
Allerdings wirkt es so, als würde der Autor seine eigene These schwächen, wenn er noch nicht einmal bei 1/6 der Fallstudie angekommen ist und schon Makros braucht.
Makros sind keine vollständige Codegenerierung, aber es fühlt sich auch nicht besonders stark danach an, idiomatisch innerhalb der Sprache zu arbeiten.
Das soll kein Vorwurf sein; ich sehe Rust tatsächlich als ziemlich stark in diesem Bereich.
Zum Beispiel kann die kontextfreie Regel
S ::= abc|aabbcc|aaabbbccc|...effektiva^Nb^Nc^Nparsen, und das ist ein Beispiel für eine kontextsensitive Grammatik.Es ist ein einfaches Beispiel, aber Ähnliches sieht man auch in der Praxis; ein Beispiel sind Sprachen, die die Definition von Operatoren erlauben.
Wie geht Rust damit um?
Sieht interessant aus.
Aus meiner Erfahrung mit dem Schreiben von Parsern und Lexern mit Ragel sowie mit Go, Java, C++ und C ist selbst reines C mit einem gewissen Boilerplate-Generator genauso gut wie der vom Autor beschriebene Rust-Code.
Wegen seiner Einfachheit ist es vielleicht sogar besser.
Für einen JSON-Parser braucht man zum Beispiel im Wesentlichen nur Code in diesem Umfang:
https://github.com/gritzko/librdx/blob/master/JSON.lex
Tatsächlich erzeugt dieses eBNF nur den Lexer; der Parser-Teil ist auch nicht besonders beeindruckend, 120 Zeilen lang und recht repetitiv.
https://github.com/gritzko/librdx/blob/master/JSON.c
Am Ende entwickelt sich Parser-Infrastruktur bis zu dem Punkt, an dem man aus eBNF allein einen Parser erzeugen kann, und das ist meiner Meinung nach der Sättigungspunkt.
Ich finde, Rusts algebraische Datentypen machen den Umgang mit dem erzeugten Syntaxbaum deutlich einfacher.
Ich stimme aber zu, dass ein wenig Codegenerierung oder Makro-Magie C deutlich handhabbarer machen kann.
Aber akzeptiert der Code hier
https://github.com/gritzko/librdx/blob/master/JSON.lex
nicht
[als gültiges JSON?delimiter = OpenObject | CloseObject | OpenArray | CloseArray | Comma | Colon;primitive = Number | String | Literal;JSON = ws* ( primitive? ( ws* delimiter ws* primitive? )* ) ws*;Root = JSON;Es sieht so aus, als könne man in JSON nur einen delimiter auswählen und alles Übrige jeweils nullmal.
Normalerweise fängt man beim RFC an.
https://datatracker.ietf.org/doc/html/rfc4627#autoid-3
Ich bin mir auch nicht sicher, ob man JSON mit Ragel implementieren kann.
Soweit ich weiß, kann Ragel nur reguläre Sprachen verarbeiten, während JSON eine kontextfreie Sprache ist.
https://en.wikipedia.org/wiki/Cloudbleed
In diesem Zusammenhang mag ich Rob Pikes Vortrag über lexikalisches Scanning in Go
Ein lehrreicher und eleganter Ansatz
https://www.youtube.com/watch?v=HxaD_trXwRE
Der Grund waren wohl der Scheduling-Overhead von Goroutinen oder ineffiziente Muster bei der Speicherallokation
Die beste Diskussion, die ich gefunden habe, ist [1]
Ein weiterer hervorragender Vortrag zum Bau effizienter Lexer und Parser ist Andrew Kelleys „Practical Data Oriented Design“ [2]
Kurz gesagt beschreibt er mehrere Strategien, um den Speichverbrauch eines Programms zu senken und es cache-freundlicher zu machen, um den Durchsatz zu erhöhen
1: https://news.ycombinator.com/item?id=31649617
2: https://www.youtube.com/watch?v=IroPQ150F6c
Für mich gab es eine erstaunliche Erfahrung
Ich konnte eine Parser-Kombinator-Bibliothek, die ich für einen High-Level-Compiler-Parser nutzte, unverändert in einer no-std-Umgebung verwenden, sie für einen Mikrocontroller kompilieren und dann als leistungsfähigen Protokollparser in einer Embedded-Umgebung ausliefern
Es war genau dieselbe Bibliothek
Der Unterschied bestand ungefähr darin, weniger String und mehr
&'static strzu verwendenDeshalb lässt sich das Herumspielen mit Compilern ziemlich gut in die Fähigkeit übertragen, Embedded-Protokollparser zu bauen
Was mir beim Schreiben eines vollständigen AST-Parsers in Rust schwerfiel, war, eine Hierarchie konkreter AST-Typen einschließlich Upcasting und Downcasting auszudrücken
Ich habe zwar einen Weg gefunden, brauchte dafür aber seltsame Typ-Spielereien wie
PhantomDataund MakrosAuch hier waren, glaube ich, ziemlich übertriebene Makros nötig
Ich frage mich, wie einschlägige Vorarbeiten dazu aussehen
Zumindest bis man bei Upcasting/Downcasting ankommt
Ich habe nicht genug Rust-Erfahrung, um zu wissen, ob es dafür eine gute Lösung gibt
Dynamische Traits könnten vielleicht möglich sein
Wie debuggt man solchen Makro-Code, oder wie versteht ihn jemand, der neu in die Codebase kommt?
Selbst wenn man die Verwendungsstellen des
node!-Makros und die Makrodefinition anschaut, dürfte es schwer sein zu erkennen, welcher Code tatsächlich erzeugt wirdIch frage mich, ob man ein Beispiel ausführt und schaut, welche Type-Hints erscheinen, ob man in der IDE per Hover die expandierte Version sehen kann, oder ob man, wenn man sicher sein will, auf den kompilierten Code Bezug nehmen muss
Da ich nur mit JS/TS arbeite und keine Makros anfasse, interessiert mich dieser Workflow
$ cargo expandkann man sich den resultierenden Code ansehenRust ist eigentlich fast mehrere Sprachen: „vanilla“ Rust, deklarative Makros und prozedurale Makros haben jeweils etwas unterschiedliche Fähigkeiten und Dialekte
Mit der Zeit gewöhnt man sich daran, mit jedem davon umzugehen
Unit-Tests sind ebenfalls ein guter Experimentierraum, um die Auswirkungen von Makroänderungen zu verstehen
Das ist nicht allzu schlecht, aber je weniger prozedurale Makros in einer Codebase, desto besser
Deklarative Makros sind etwas leichter zu verstehen, und Wartung und Tests sind deutlich einfacher
Bei opaker Codegenerierung in anderen Sprachen empfinde ich es ähnlich
Viel Glück beim Parsen der SQLite-Syntax
Vor ein paar Jahren musste ich beruflich einen Parser für eine recht kleine Teilmenge von SQLite schreiben
Ich mag SQLite wirklich sehr und es ist immer eine Quelle der Inspiration
Railroad-Diagramme sind enorm nützlich
https://www.sqlite.org/syntaxdiagrams.html
Der Parser-Generator lemon wird meiner Meinung nach nicht genug gewürdigt
https://sqlite.org/src/doc/trunk/doc/lemon.html
Was die Sprachwahl angeht, passt alles gut, was algebraische Datentypen hat
Sogar TypeScript kann für diesen Zweck großartig sein
Früher habe ich auch einen kleinen Einführungsartikel über das Schreiben eines Parsers von Hand in Rust verfasst
[https://www.nhatcher.com/post/a-rustic-invitation-to-parsing...](https://www.nhatcher.com/post/a-rustic-invitation-to-parsing/)