Antra

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 Result statt nur undef
  • 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:

[]
undef

Das 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-API
  • match_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:

  • :expect an 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:

  1. Der Parser probiert den ersten Ast: "a", dann "b".
  2. "a" passt.
  3. "b" passt nicht.
  4. Also wird der erste Ast verworfen.
  5. Danach wird der zweite Ast probiert: "a", dann "c".
  6. 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:

  1. Der Parser probiert den ersten Ast.
  2. "a" passt.
  3. [:cut] sagt: Ab hier committen wir auf diesen Ast.
  4. Danach wird "b" erwartet.
  5. Es kommt aber "c".
  6. Der erste Ast scheitert.
  7. Wegen :cut wird der zweite Ast jetzt nicht mehr probiert.

Genau das ist der Sinn von :cut.

Die Kurzform ist:

  • vor :cut darf noch zwischen Alternativen gewechselt werden
  • nach :cut ist 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:

  1. Ein Präfix erkennt den richtigen Ast.
  2. :cut commitet auf diesen Ast.
  3. :expect sorgt 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 markers

Byte-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 Checks
  • PEG.match_result(...) für Parser, die Fehler lesbar melden sollen
  • PEG.match_bytes(...) für schnelle Byte-Matches ohne Diagnosebedarf
  • PEG.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:

  1. Kleine Regeln für Tokens oder Zeilenformen bauen.
  2. An fachlich wichtigen Stellen :expect ergänzen.
  3. Nach eindeutigen Präfixen :cut setzen.
  4. Nach außen nur noch match_result(...) oder match_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:

Quelle dieser Seite