Themen
PEG-Grammatiken mit Antra::PEG
Antra::PEG bringt Parsing Expression Grammars im Janet-Stil nach Antra.
Die Grundideen, viele Operatoren und die allgemeine Arbeitsweise sind dabei zu
großen Teilen von Janet übernommen und dann auf normale Antra-Datenstrukturen
abgebildet.
Wenn du die Ursprungsquelle ansehen willst: die Sprache selbst steht unter janet-lang.org, und die PEG-Dokumentation ist unter janet-lang.org/docs/peg.html zu finden.
Die Grammatik selbst ist dabei kein Sonderformat, sondern einfach ein normaler Antra-Wert: eine Map aus Regeln, Arrays, Keywords, Strings, Zahlen und Character-Ranges.
import Antra::PEG as PEG
const grammar = %{
main: [:sequence, "hello", -1]
}
println(PEG.match(grammar, "hello"))Das Ergebnis ist bei Erfolg der Capture-Stack als Array, sonst undef.
Wenn du lieber im normalen Antra-Stil mit %Ok[...] und %Err[...] arbeiten
willst, gibt es zusätzlich PEG.match_result(...):
println(PEG.match_result(grammar, "hello"))
println(PEG.match_result(grammar, "hellx"))Diese Variante liefert %Ok[captures] bei Erfolg und %Err[message] bei
Fehlschlag.
Für Byte-Parsing gibt es zusätzlich PEG.match_bytes(...). Diese Variante
arbeitet nicht auf normalen Strings, sondern auf ByteString-Werten aus
Antra::Bytes.
Wenn du mit PEGs noch gar keine Erfahrung hast, lies vorher oder parallel das Tutorial in 25-peg-tutorial-fuer-einsteiger.md.
Grundidee#
Eine Grammatik ist eine Map mit benannten Regeln. Der Einstiegspunkt ist immer
die Regel :main.
const grammar = %{
main: [:sequence, :word, ":", :word, -1],
word: [:some, :w]
}Regelreferenzen passieren über Keywords wie :word oder :main.
Wichtig zur Syntax in Antra#
Janet zeigt PEG-Beispiele oft s-expression-artig mit Leerzeichen:
(sequence (some :w) ":" (some :d))In Antra wird dasselbe als normales Array mit Kommas geschrieben:
[:sequence, [:some, :w], ":", [:some, :d]]Das ist die wichtigste Übersetzungshilfe: Janet-Operatoren bleiben als Keywords erkennbar, aber die umgebende Form ist normale Antra-Datenstruktur-Syntax.
Primitive Patterns#
Strings#
Strings matchen exakt und case-sensitiv.
"cat"Positive Integers#
Eine positive Zahl konsumiert blind genau so viele Zeichen.
3-1 für End-of-Input#
-1 matcht nur dann, wenn der gesamte Resttext bereits verbraucht ist.
[:sequence, "abc", -1]Wenn du Vollverbrauch willst, hängst du -1 typischerweise ans Ende von
:main.
Character-Ranges#
Character-Ranges verwenden Einzelzeichen-Strings als Endpunkte.
'a'..'z'
'0'..'9'Sie matchen genau ein Zeichen im angegebenen Bereich.
Regex-Primitiv#
Wenn du an der aktuellen Position einen bereits kompilierten Regex pruefen
willst, kannst du :regex verwenden.
[:regex, regex"[0-9]+"]Wichtig dabei:
:regexist nur fuer Text-PEGs gedacht- der Match ist an der aktuellen Position verankert
- das Primitiv legt selbst keine Captures an
Wenn du den gematchten Text im Ergebnis haben willst, kombinierst du es wie
gewohnt mit [:capture, ...]:
[:capture, [:regex, regex"[0-9]+"]]Benannte Regex-Gruppen werden auf PEG-Ebene aktuell noch nicht als eigene
User-Surface exportiert. Eine moegliche spaetere Erweiterung dafuer ist
[:capture_named, [:regex, ...]], aber das ist derzeit nur als Vorschlag in
specs/spec_peg_capture_named.md beschrieben und nicht Teil der aktuellen
implementierten PEG-Doku.
Eingebaute Zeichenklassen#
Wenn ein Keyword nicht auf eine benutzerdefinierte Regel zeigt, prüft
Antra::PEG die eingebauten Klassen aus der PEG-Spezifikation:
:a- alphanumerisch (A-Z,a-z,0-9):d- Ziffern:w- Buchstaben:s- Whitespace:h- hexadezimale Zeichen:g- druckbare ASCII-Zeichen
Wichtig: Diese Zuordnung folgt der hier eingeführten Antra-Spezifikation, nicht
der Janet-Default-Tabelle. Insbesondere ist hier :a alphanumerisch und :w
steht nur für Buchstaben.
Kombinatoren#
Sequenz und Choice#
[:sequence, a, b, c]
[:*, a, b, c]
[:choice, a, b, c]
[:+, a, b, c]:+ probiert Alternativen von links nach rechts. Wie bei Janet ist PEG-Matching
gierig und hat kein allgemeines Backtracking; die Reihenfolge der Alternativen
entscheidet also.
Wiederholung#
[:any, x]
[:some, x]
[:opt, x]
[:?, x]
[:repeat, 3, x]
[:between, 1, 5, x][:any, x] schlägt nie fehl. [:some, x] verlangt mindestens einen Treffer.
Lookahead#
[:not, x]
[:!, x]
[:if, cond, patt]Diese Formen konsumieren den Lookahead selbst nicht. :if prüft cond an der
aktuellen Position und matcht erst dann patt.
Suchen im Text#
[:to, x]
[:thru, x][:to, x] konsumiert bis direkt vor den Treffer von x, [:thru, x]
konsumiert den Treffer mit.
Commit mit :cut#
[:cut][:cut] setzt in einem begonnenen Branch einen Commit-Punkt. Wenn danach in
diesem Branch später ein Match fehlschlägt, probiert [:choice, ...] keine
spätere Alternative mehr.
Das ist nützlich, wenn du nach einem klaren Präfix weißt: Ab hier sind wir im richtigen Ast, also soll bei Fehlern lieber eine präzise Diagnose kommen als stilles Zurückfallen auf andere Alternativen.
Beispiel:
const grammar = %{
main: [:+,
[:sequence, "a", [:cut], [:expect, "b", "letter b"], -1],
[:sequence, "a", "c", -1]
]
}Auf Eingabe "ac" verhindert [:cut], dass nach dem Präfix "a" noch in die
zweite Alternative zurückgewechselt wird.
Captures#
Captures schreiben Werte auf einen internen Capture-Stack. Genau dieser Stack
wird von PEG.match(...) zurückgegeben.
Text capturen#
[:capture, patt]
[:<-, patt]Das Ergebnis ist der exakt konsumierte Textabschnitt als String.
Gruppen#
[:group, patt]Alle tieferen Captures aus patt werden in ein Unter-Array gepackt.
Position und Konstanten#
[:position]
[:constant, :done][:position] legt die aktuelle Byte-/Zeichenposition als Integer auf den Stack,
[:constant, v] legt einen statischen Wert ab.
Binary-Parsing mit ByteString#
Für binärorientierte Daten gibt es einen zweiten Einstieg:
import Antra::PEG as PEG
import Antra::Bytes as Bytes
my data = Bytes.from_hex("504b54314f4b30303432")Gematcht wird dann mit PEG.match_bytes(...) statt PEG.match(...):
const grammar = %{
main: [:sequence,
[:capture, [:bytes, 4]],
[:capture, [:bytes, 2]],
[:capture, [:bytes, 4]],
-1
]
}
println(PEG.match_bytes(grammar, data))Das Ergebnis enthält bei [:capture, [:bytes, n]] dann ByteString-Werte statt
normaler Strings.
Auch hier gibt es eine Result-Variante:
println(PEG.match_bytes_result(grammar, data))Das ist besonders praktisch, wenn ein falscher Eingabetyp oder ein Parsefehler nicht als Ausnahme hochfliegen, sondern als normaler Wert weitergegeben werden soll.
Fehlerdiagnosen#
PEG.match(...) und PEG.match_bytes(...) bleiben absichtlich minimal. Bei
Fehlschlag geben sie einfach undef zurück.
Für bessere Diagnosen nutzt du PEG.match_result(...) oder
PEG.match_bytes_result(...). Diese APIs merken sich den weitesten bekannten
Fehlschlag und bauen daraus eine Fehlermeldung.
Bei Text-Eingaben enthält die Meldung:
- Zeile
- Spalte
- Offset
- erwartete Eingabe
- gefundene Eingabe
Zusätzlich wird eine kleine Zeilenansicht mit Caret eingebettet.
Bei match_bytes_result(...) gibt es statt Zeile/Spalte einen Byte-Offset und
den gefundenen Hex-Wert.
Erste Byte-Primitiven#
Die erste Version unterstützt diese binären PEG-Operatoren:
[:bytes, n]- konsumiert genaunBytes[:byte, n]- matcht genau ein Byte mit dem Wertn[:u8]- liest ein Byte und legt den Integer auf den Capture-Stack[:u16be]- liest 2 Bytes big-endian und legt den Integer auf den Stack[:u16le]- liest 2 Bytes little-endian und legt den Integer auf den Stack[:u32be]- liest 4 Bytes big-endian und legt den Integer auf den Stack[:u32le]- liest 4 Bytes little-endian und legt den Integer auf den Stack[:u64be]- liest 8 Bytes big-endian und legt den Integer auf den Stack[:u64le]- liest 8 Bytes little-endian und legt den Integer auf den Stack[:cstring]- liest Bytes bis zum Null-Byte und legt einenByteStringauf den Stack[:take, patt]- liest erst mitpatteine Längenangabe und danach genau so viele Bytes alsByteString
Wichtig:
- Diese Operatoren funktionieren nur in
PEG.match_bytes(...). - In normalem
PEG.match(...)bleiben Grammatik und Captures textorientiert. - String-Literale in
match_bytes(...)werden als UTF-8-Bytes verglichen.
Length-Prefixed Daten mit :take#
[:take, patt] ist für typische Binärformate gedacht, bei denen erst eine
Länge und dann ein Payload kommt.
Beispiel mit 16-Bit-Big-Endian-Länge:
import Antra::PEG as PEG
import Antra::Bytes as Bytes
import Antra::Bytes as B
const grammar = %{
main: [:sequence, [:take, [:u16be]], -1]
}
my %Ok[result] = PEG.match_bytes_result(grammar, Bytes.from_hex("000548656c6c6f"))
println(result[0])
println(B.to_string(result[1]))Das Ergebnis ist dann zuerst die gelesene Länge und danach der entnommene
ByteString.
Ersetzen / Transformieren#
[:replace, patt, |x| -> num(x)]patt wird normal gematcht. Danach werden die dabei entstandenen Captures an
die Funktion übergeben, und deren Rückgabewert ersetzt die alten Captures.
Erwartete Fehler klar benennen#
[:expect, patt, "heading text"][:expect, ...] ist für bessere Fehlermeldungen gedacht. Das Pattern patt
wird normal gematcht. Wenn es fehlschlägt, wird in der Result-Fehlermeldung
nicht nur ein technisches Detail, sondern die angegebene Erwartung verwendet.
Das ist vor allem zusammen mit PEG.match_result(...) nützlich, zum Beispiel:
const grammar = %{
main: [:sequence,
[:some, "#"],
[:expect, " ", "space after heading markers"],
[:expect, [:some, :w], "heading text"],
-1
]
}Damit wird aus einem rohen Parsefehler eine deutlich verständlichere Diagnose.
Rekursive Grammatiken#
Wie in Janet liegt die eigentliche Stärke in benannten Regeln und Rekursion.
const grammar = %{
pair: [:group, [:sequence, [:capture, [:some, :w]], ":", [:capture, :value]]],
value: [:+, :number, [:some, :w]],
number: [:sequence, [:opt, [:+, "+", "-"]], [:some, '0'..'9']],
main: [:sequence, :pair, -1]
}Regeln dürfen sich gegenseitig referenzieren. Die Implementierung ist rekursiv; sehr tiefe oder schlecht geschriebene Grammatiken können daher wie bei Janet Stack oder Laufzeit belasten.
Komplettes Beispiel#
import Antra::PEG as PEG
const kv_grammatik = %{
main: [:some, [:sequence, :paar, [:any, [:+, ",", :s]]]],
paar: [:group, [:sequence, [:capture, [:some, :w]], ":", [:any, :s], [:capture, :wert]]],
wert: [:+, :zahl, [:some, :w]],
zahl: [:sequence, [:opt, [:+, "+", "-"]], [:some, '0'..'9']]
}
my daten = PEG.match(kv_grammatik, "User: admin, ID: 105")
println(daten)Ergebnis:
[["User", "admin"], ["ID", "105"]]CSV-Beispiel Schritt für Schritt#
Ein gutes PEG-Beispiel ist CSV, weil man daran sofort sieht, warum PEGs mehr
leisten als simples split(",").
Das Problem ist: Kommas sind normalerweise Trenner, aber innerhalb von Quotes plötzlich normaler Inhalt.
Name,Beruf
"Müller, Hans",EntwicklerWenn man hier stumpf auf Kommas splittet, bekommt man bei "Müller, Hans" das
falsche Ergebnis.
Die einfache CSV-Grammatik aus dem Beispiel trennt das deshalb in drei Ebenen:
mainliest mehrere Zeilenzeileliest mehrere Felder plus Zeilenendefeldunterscheidet zwischen quoted und unquoted Text
const csv_grammatik = %{
main: [:sequence, [:some, :zeile], -1],
zeile: [:group, [:sequence,
:feld,
[:any, [:sequence, ",", :feld]],
[:+, "\r\n", "\n"]
]],
feld: [:+, :escaped_text, :normal_text],
escaped_text: [:sequence,
'"',
[:capture, [:to, '"']],
'"'
],
normal_text: [:capture, [:any, [:sequence,
[:not, [:+, ",", '"', "\n", "\r"]],
1
]]]
}Wichtig daran:
:groupmacht aus jeder Zeile ein eigenes Unter-Array:capturesammelt nur den eigentlichen Feldinhalt[:to, '"']liest in quoted fields bis direkt vor das schließende Quote
Für dieses einfache Beispiel reicht das schon für eine tabellenartige Struktur:
[["Name", "Beruf"], ["Müller, Hans", "Entwickler"]]Mathe-Beispiel Schritt für Schritt#
Ein anderes gutes PEG-Beispiel ist ein Mathe-Ausdruck wie:
5 * (3 + 2) - 8 / 4Hier reicht es nicht, einfach nur Zahlen und Operatoren zu erkennen. Wir wollen auch die Struktur verstehen:
- was gehört zusammen?
- was steckt in Klammern?
- was bindet stärker?
Darum ist die Grammatik in Ebenen aufgebaut:
summefür+und-produktfür*und/faktorfür Zahlen oder Klammern
const mathe_grammatik = %{
main: [:sequence, :summe, -1],
summe: [:+,
[:replace, [:sequence,
:produkt,
[:any, :s],
[:capture, [:+, "+", "-"]],
[:any, :s],
:summe
], |lhs, op, rhs| -> mk_bin(lhs, op, rhs)],
:produkt
],
produkt: [:+,
[:replace, [:sequence,
:faktor,
[:any, :s],
[:capture, [:+, "*", "/"]],
[:any, :s],
:produkt
], |lhs, op, rhs| -> mk_bin(lhs, op, rhs)],
:faktor
],
faktor: [:+, :zahl, :klammer]
}Das Entscheidende ist hier nicht nur das Matchen, sondern :replace:
- Zahlen werden direkt zu
%Num[...] - Operator-Knoten werden direkt zu
%Add[...],%Sub[...],%Mul[...],%Div[...]
So entsteht direkt ein einfacher AST statt nur einer Liste von Strings.
Für den Beispielausdruck ergibt das etwa:
[%Sub[%Mul[%Num[5], %Add[%Num[3], %Num[2]]], %Div[%Num[8], %Num[4]]]]Das ist als Beispiel besonders nützlich, weil man daran sieht, dass PEGs nicht nur Text erkennen, sondern beim Parsen auch direkt strukturierte Werte bauen können.
Aktueller Stand in Antra#
Der aktuelle Umfang von Antra::PEG deckt den umgesetzten Kern aus der
Spezifikation ab:
- String-, Integer-,
-1- und Character-Range-Primitives - Built-ins
:a,:d,:w,:s,:h,:g :sequence/:*,:choice/:+,:any,:some,:opt/:?,:repeat,:between:to,:thru,:not/:!,:if:capture/:<-,:group,:position,:constant,:replace
Noch nicht umgesetzt sind die weitergehenden Janet-Features wie peg/compile,
look, if-not, backref, backmatch, drop, only-tags, line,
column, argument, accumulate, cmt, Binär-Integer-Captures oder
number-/lenprefix-Helfer.
