Themen
PEG-Tutorial Teil 2 für Fortgeschrittene
Dieses Kapitel ist für den Moment gedacht, an dem du mit PEG.match(...),
[:capture, ...], [:group, ...] und kleinen Grammatiken schon zurechtkommst
und jetzt die Frage hast:
"Wie baut man damit Parser, die sich im Alltag besser debuggen lassen?"
Die Antwort ist: nicht nur mit mehr Grammatik, sondern mit besserem Fehlerverhalten.
Genau dafür sind die neueren oder erweiterten PEG-Features wichtig:
PEG.match_result(...)PEG.match_bytes_result(...)[:expect, ...][:cut]
Worum es in Teil 2 geht#
Im Einsteiger-Kapitel war die Grundidee:
- Text matchen
- Werte capturen
- kleine Regeln zusammensetzen
Hier kommt jetzt die zweite Ebene dazu:
- Fehler als
Resultstatt nurundef - lesbare Erwartungen statt technischer Details
- Commit-Punkte, damit ein erkannter Ast nicht still wieder verworfen wird
- Byte-Parsing mit derselben PEG-Idee
Das ist der Punkt, an dem aus einer netten Demo eher ein ernstzunehmender Parser wird.
PEG.match(...) gegen PEG.match_result(...)#
Die einfache API ist weiterhin gut für schnelle Experimente:
import Antra::PEG as PEG
const grammar = %{
main: [:sequence, "abc", -1]
}
println(PEG.match(grammar, "abc"))
println(PEG.match(grammar, "abx"))Ergebnis:
[]
undefDas ist kurz und praktisch. Aber sobald du verstehen willst, warum etwas nicht
gematcht hat, reicht undef nicht mehr.
Dann ist PEG.match_result(...) die bessere Wahl:
import Antra::PEG as PEG
const grammar = %{
main: [:sequence, "abc", -1]
}
println(PEG.match_result(grammar, "abc"))
println(PEG.match_result(grammar, "abx"))Das liefert ungefähr so etwas:
%Ok[[]]
%Err["PEG.match failed at line 1, column 3 (offset 2): expected \"c\", found \"x\""]Der wichtige Denkwechsel ist:
match(...)ist die knappe Matcher-APImatch_result(...)ist die Parser-API für echten Gebrauch
Warum Result hier besser ist#
Mit Result kannst du Parsefehler wie normale Werte behandeln:
import Antra::PEG as PEG
fn parse_or_message(grammar, input) -> {
my result = PEG.match_result(grammar, input)
match result {
%Ok[value] -> value
%Err[msg] -> "parse error: " <> msg
}
}Das passt besser zu Antra als ein stilles undef, sobald ein Parsefehler Teil
des normalen Kontrollflusses wird.
[:expect, ...]: bessere Fehlermeldungen#
Ohne zusätzliche Hilfe sind Fehlermeldungen oft technisch korrekt, aber nicht besonders freundlich.
Beispiel:
const grammar = %{
main: [:sequence, "#", " ", [:some, :w], -1]
}Wenn hinter # kein Leerzeichen kommt, ist die Diagnose oft noch okay. Aber
bei größeren Regeln willst du den Fehler bewusster benennen.
Dafür ist [:expect, patt, "beschreibung"] da:
const grammar = %{
main: [:sequence,
"#",
[:expect, " ", "space after heading marker"],
[:expect, [:some, :w], "heading text"],
-1
]
}Jetzt wird aus einem internen Pattern-Fehlschlag eine fachlich lesbare Erwartung.
Die Faustregel ist einfach:
:expectan Stellen, an denen ein Mensch die Fehlermeldung lesen soll- nicht überall wahllos, sondern an semantisch wichtigen Übergängen
Gute Stellen für :expect sind oft:
- nach einem Marker wie
#,-,:oder= - vor einem Pflichtfeld
- vor dem eigentlichen Inhalt einer Zeile oder eines Blocks
[:cut]: kein stilles Zurückfallen mehr#
choice ist nützlich, kann aber Fehler manchmal zu freundlich behandeln.
Das Problem ist nicht das Matchen selbst, sondern dieses Verhalten:
- Ast 1 wirkt erst passend
- Ast 1 scheitert später
- der Parser probiert still Ast 2
- am Ende siehst du entweder Erfolg oder einen Fehler aus dem falschen Ast
[:cut] ist die Anweisung:
"Ab hier bitte nicht mehr auf andere Alternativen zurückfallen."
Das versteht man am besten an einem sehr kleinen Beispiel.
Ohne :cut#
Nimm diese Grammatik:
const grammar = %{
main: [:+,
[:sequence, "a", "b", -1],
[:sequence, "a", "c", -1]
]
}Auf Eingabe "ac" passiert das:
- Der Parser probiert den ersten Ast:
"a", dann"b". "a"passt."b"passt nicht.- Also wird der erste Ast verworfen.
- Danach wird der zweite Ast probiert:
"a", dann"c". - Der zweite Ast passt.
Ergebnis: alles okay.
Das ist sinnvoll, solange beide Alternativen wirklich gleichberechtigt sind.
Mit :cut#
Aber manchmal willst du nach einem bestimmten Punkt nicht mehr still auf einen
anderen Ast zurückfallen. Dann kommt [:cut] ins Spiel:
const grammar = %{
main: [:+,
[:sequence, "a", [:cut], [:expect, "b", "letter b"], -1],
[:sequence, "a", "c", -1]
]
}Auf dieselbe Eingabe "ac" passiert jetzt etwas anderes:
- Der Parser probiert den ersten Ast.
"a"passt.[:cut]sagt: Ab hier committen wir auf diesen Ast.- Danach wird
"b"erwartet. - Es kommt aber
"c". - Der erste Ast scheitert.
- Wegen
:cutwird der zweite Ast jetzt nicht mehr probiert.
Genau das ist der Sinn von :cut.
Die Kurzform ist:
- vor
:cutdarf noch zwischen Alternativen gewechselt werden - nach
:cutist die Wahl getroffen - ein späterer Fehler gehört dann genau zu diesem Ast
Wann ist :cut sinnvoll?#
Immer dann, wenn ein Präfix den Ast schon eindeutig bestimmt.
Ein paar typische Fälle:
- Eine Zeile beginnt mit
#, also ist es eine Heading-Zeile. - Eine Zeile beginnt mit
-, also ist es eine Listenzeile. - Ein Binärformat hat bereits einen eindeutig erkannten Header.
- Nach einem festen Startzeichen ist klar, welche Struktur jetzt kommen muss.
Nicht sinnvoll ist :cut, wenn die Alternativen noch wirklich offen sind.
Dann würdest du zu früh committen und sinnvolle Fallbacks verlieren.
Eine einfache Denkregel#
Wenn du unsicher bist, frag dich:
"Würde ein Mensch an dieser Stelle sagen: Jetzt ist klar, was das werden soll?"
Wenn die Antwort ja ist, ist :cut oft richtig.
Wenn die Antwort nein ist, ist :cut meist noch zu früh.
Warum :cut die Fehlermeldung verbessert#
Ohne :cut kann ein Fehler aus dem eigentlich richtigen Ast verschwinden,
weil der Parser noch etwas anderes ausprobiert.
Mit :cut bleibt der Parser im bereits erkannten Ast. Dadurch passt die
Fehlermeldung besser zu dem, was der Leser der Eingabe auch intuitiv erwartet.
Das ist besonders nützlich bei Formaten mit klaren Präfixen:
- Markdown-Zeilen mit
# - Listenzeilen mit
- - Objekt- oder Record-Syntax nach einem festen Startzeichen
- Binärformate, sobald ein Header eindeutig erkannt wurde
Zusammenspiel von :expect und :cut#
Allein ist jedes Feature schon nützlich. Zusammen werden sie richtig stark.
Ein typisches Muster ist:
[:sequence,
prefix,
[:cut],
[:expect, irgendwas, "lesbare erwartung"]
]Die Logik ist:
- Ein Präfix erkennt den richtigen Ast.
:cutcommitet auf diesen Ast.:expectsorgt dafür, dass der Fehlertext sinnvoll bleibt.
Das ist genau der Übergang von "Grammatik matcht irgendwie" zu "Parser erklärt seine Fehler".
Praktisches Beispiel: Markdown-Heading#
Das vereinfachte Muster sieht so aus:
heading: [:sequence,
[:capture, [:between, 1, 3, "#"]],
[:cut],
[:expect, " ", "space after heading markers"],
[:capture, [:expect, :nonempty_zeilen_text, "heading text"]],
:newline
]Warum ist das gut?
- Die
#-Marker sagen schon: das ist eine Heading-Zeile. - Danach soll nicht mehr still auf Absatz-Logik zurückgefallen werden.
- Wenn das Leerzeichen fehlt, kommt eine direkte Meldung dazu.
So wird aus #Ohne Leerzeichen nicht "irgendein Parsefehler", sondern ein
konkretes:
expected space after heading markersByte-Parsing mit Result#
Die gleiche Idee funktioniert auch mit ByteString.
Die einfache API ist:
PEG.match_bytes(grammar, data)Für echten Gebrauch ist aber oft besser:
PEG.match_bytes_result(grammar, data)Der Unterschied ist derselbe wie im Textmodus:
- Erfolg:
%Ok[...] - Fehlschlag:
%Err[...]
Der Fehler nennt dann einen Byte-Offset statt Zeile und Spalte.
Das ist genau richtig für Formate wie:
- Header
- Paketformate
- kleine Binärprotokolle
- Chunk-basierte Daten
Wann man welche API nimmt#
Eine brauchbare Arbeitsregel ist:
PEG.match(...)für kleine Experimente, REPL-artiges Testen und sehr knappe ChecksPEG.match_result(...)für Parser, die Fehler lesbar melden sollenPEG.match_bytes(...)für schnelle Byte-Matches ohne DiagnosebedarfPEG.match_bytes_result(...)für echte Binärparser mit brauchbaren Fehlern
Typische Architektur für ernstere Parser#
Wenn ein Parser langsam größer wird, funktioniert dieses Muster gut:
- Kleine Regeln für Tokens oder Zeilenformen bauen.
- An fachlich wichtigen Stellen
:expectergänzen. - Nach eindeutigen Präfixen
:cutsetzen. - Nach außen nur noch
match_result(...)odermatch_bytes_result(...)verwenden.
Dann bleibt die Grammatik selbst noch lesbar, aber das Fehlerverhalten ist deutlich besser.
Häufige Fehler in Teil 2#
1. :expect überall hineinstreuen#
Dann werden Fehlermeldungen zwar redselig, aber nicht unbedingt klarer.
Setze :expect lieber dort, wo ein Mensch wirklich eine fachliche Erwartung
lesen soll.
2. :cut zu früh setzen#
Wenn du zu früh commitest, blockierst du sinnvolle Alternativen.
Setze :cut erst dann, wenn der Ast wirklich erkennbar ist.
3. Weiter match(...) benutzen, obwohl Fehler wichtig sind#
Dann baust du eine ernstere Grammatik, wirfst aber den nützlichsten Teil der Diagnose wieder weg.
4. Text- und Byte-Welt vermischen#
match(...) arbeitet auf Strings.
match_bytes(...) arbeitet auf ByteString.
Wenn dein Format binär ist, nutze die Byte-API konsequent.
Ein gutes mentales Modell#
Teil 1 war vor allem:
- Was matched?
- Was wird gecaptured?
Teil 2 ist zusätzlich:
- Wie soll ein Fehler aussehen?
- Ab wann ist klar, in welchem Ast wir sind?
- Soll der Parser noch Alternativen probieren oder nicht mehr?
Das ist der Unterschied zwischen einer PEG als Übungsgrammatik und einer PEG, mit der du echte Parser bauen willst.
Nächste Schritte#
Wenn du nach diesem Kapitel weitermachen willst, sind diese Dateien die besten Anschlusspunkte:
- 24-peg-grammatiken.md für die Referenz aller Operatoren
- examples/peg/markdown_parser.ant für einen größeren Textparser
- examples/peg/png_header_parser.ant für ein ByteString-Beispiel
