Parsen ist der Prozess, Text in sinnvolle Daten umzuwandeln.
Funktionen aus dem String-Paket sind in dieser Hinsicht begrenzt, und reguläre Ausdrücke sind schwer zu verwenden. Deshalb ist der idiomatische Weg in Elm zum Parsen das Paket elm/parser.
Schauen wir uns ein Beispiel an, wie man einen Parser für (eine Teilmenge von) die Programmiersprache LOLCODE schreibt. Betrachten wir das folgende kommentierte Programm:
HAI 1 # program version 1
CAN HAS STDIO? # import stdio
VISIBLE "HAI WORLD!" # print "HAI WORLD!"
KTHXBYE # end of program
Schreiben wir zuerst einen Elm-Typ, der die möglichen Befehle repräsentiert:
type Command
= Version Int
| Import String
| Print String
Unser LOLCODE-Programm lässt sich dann als List Command modellieren.
Beginnen wir mit einem Parser für die Zeile HAI 1:
versionP : Parser Command
versionP =
Parser.succeed Version
|. Parser.keyword "HAI"
|. Parser.spaces
|= Parser.int
versionP wird mit einer Parser-Pipeline aufgebaut.
Der Operator (|.) bedeutet „parse den String, aber verwirf das Ergebnis“ und (|=) bedeutet „parse den String und behalte das Ergebnis“.
In diesem Beispiel konsumiert Parser.keyword "HAI" ein „HAI“, aber dieses Ergebnis wird ignoriert.
Parser.spaces konsumiert eine beliebige Anzahl von ' ', '\n' und '\r'-Zeichen, aber auch das wird ignoriert.
Parser.int parst eine Ganzzahl, und der Wert dieser Ganzzahl (sagen wir 1) wird an das oberste Parser.succeed Version übergeben, und der Parser gibt erfolgreich den Wert Version 1 zurück.
Wenn einer der Parser in der Pipeline fehlschlägt, gibt er einen Parser.DeadEnd zurück und der gesamte Parser schlägt fehl.
Ein Parser kann mit Parser.run ausgeführt werden:
Parser.run versionP "HAI 1"
--> Ok (Version 1)
Parser.run versionP "HAI 2"
--> Ok (Version 2)
Parser.run versionP "BYE 2"
--> Err [{ problem = ExpectingKeyword "HAI", col = 1, row = 1 }]
Parser.run versionP "HAI MOM"
--> Err [{ problem = ExpectingInt, col = 5, row = 1 }]
Widmen wir uns der Zeile „CAN HAS STDIO?“ mit
importP : Parser Command
importP =
Parser.succeed Import
|. Parser.keyword "CAN HAS"
|. Parser.spaces
|= packageP
|. Parser.symbol "?"
importP ist ebenfalls eine Parser-Pipeline, aber diesmal endet sie mit Parser.symbol, das geeignet ist, Symbole wie „?“ zu parsen.
Der Wert, den wir behalten wollen (der Name des zu importierenden Pakets), wird von
packageP : Parser String
packageP =
Parser.chompWhile Char.isAlphaNum
|> Parser.getChompedString
|> Parser.map String.toLower
geparst.
Da wir nicht wissen, wie der Paketname lauten wird, können wir Parser.keyword nicht verwenden. Stattdessen nutzen wir Parser.chompWhile, das einzelne Zeichen konsumiert, solange sie eine Bedingung erfüllen (hier: ein alphanumerisches Zeichen zu sein).
Diese abgeknabberten Zeichen werden dann von Parser.getChompedString gesammelt, das einen Parser String zurückgibt.
In diesem Fall möchten wir die Paketnamen normalisieren, also verwenden wir Parser.map, um den im Parser enthaltenen String zu ändern und ihn in Kleinbuchstaben umzuwandeln.
Parser.run importP "CAN HAS STDIO?"
--> Ok (Import "stdio")
Parser.run importP "CAN HAS StDiO?"
--> Ok (Import "stdio")
Parser.run importP "CAN HAS STDIO"
--> Err [{ problem = ExpectingSymbol "?", col = 14, row = 1 }]
Als Nächstes wird VISIBLE "HAI WORLD!" geparst durch
printP : Parser Command
printP =
Parser.succeed Print
|. Parser.keyword "VISIBLE"
|. Parser.spaces
|. Parser.symbol "\""
|= stringP
|. Parser.symbol "\""
stringP : Parser String
stringP =
Parser.chompWhile (\c -> c /= '"')
|> Parser.getChompedString
Parser.run printP "VISIBLE \"HAI WORLD!\""
--> Ok (Print "HAI WORLD!")
Wir haben jetzt für jeden Befehl einen Parser. In einem allgemeinen Programm lässt sich die Reihenfolge dieser Befehle natürlich nicht vorhersagen, also bauen wir einen generischen Befehls-Parser
commandP : Parser Command
commandP =
Parser.oneOf
[ versionP
, importP
, printP
]
Parser.oneOf probiert jeweils einen Parser in der angegebenen Reihenfolge.
Schlägt ein Parser fehl, wird der nächste probiert. Gelingt er jedoch, wird sein Ergebnis zurückgegeben, ohne die übrigen Parser zu probieren.
Wenn alle Parser fehlschlagen, schlägt Parser.oneOf fehl.
Parser.run commandP "HAI 2"
--> Ok (Version 2)
Parser.run commandP "CAN HAS STDIO?"
--> Ok (Import "stdio")
Parser.run commandP "VISIBLE \"HAI WORLD!\""
--> Ok (Print "HAI WORLD!")
Parser.run commandP "O NOES"
--> Err [ { problem = ExpectingKeyword "HAI", col = 1, row = 1 }
-- , { problem = ExpectingKeyword "CAN HAS", col = 1, row = 1 }
-- , { problem = ExpectingKeyword "VISIBLE", col = 1, row = 1 }
-- ]
Beachte, wie jeder fehlgeschlagene Versuch einen Parser.DeadEnd erzeugt, der von Parser.oneOf gesammelt wird.
Um ein vollständiges Programm zu parsen, müssen wir mehrere Befehle nacheinander parsen mit
programP : Parser (List Command)
programP =
Parser.sequence
{ start = ""
, separator = "\n"
, end = "KTHXBYE"
, spaces = Parser.succeed ()
, item = commandP
, trailing = Parser.Optional
}
Parser.sequence ist ein sehr anpassbarer Parser mit einer Reihe von Optionen.
start, separator und end nehmen Strings, die die Elemente der Sequenz trennen. Zum Parsen einer Elm-Liste würden wir „[“, „,“ und „]“ verwenden.
spaces sorgt für Flexibilität, indem es einen Parser nimmt, der mögliche Leerzeichen zwischen Elementen und Trennzeichen behandelt. Zum Parsen einer Elm-Liste würden wir Parser.spaces verwenden, aber in diesem Fall akzeptieren wir keine Leerzeichen, also nutzen wir Parser.succeed (), das sofort gelingt, ohne tatsächlich etwas zu konsumieren.
item nimmt den Element-Parser, und schließlich lässt uns trailing entscheiden, ob nachfolgende Trennzeichen Parser.Forbidden, Parser.Mandatory oder Parser.Optional sind.
Parser.run programP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Ok [Version 2, Import "stdio"]
Parser.run programP "KTHXBYE"
--> Ok []
Parser.run programP "HAI 2\nCAN HAS STDIO?\n"
--> Err [ { problem = ExpectingKeyword "HAI", col = 1, row = 3 }
-- , { problem = ExpectingKeyword "CAN HAS", col = 1, row = 3 }
-- , { problem = ExpectingKeyword "VISIBLE", col = 1, row = 3 }
-- , { problem = Expecting "KTHXBYE", col = 1, row = 3 }
-- ]
Wenn Parser gelingen, hören sie auf, den String zu konsumieren
Parser.run Parser.int "1"
--> Ok 1
Parser.run Parser.int "1 BTW VISIBLE \"U SEE NOTHING\""
--> Ok 1
Wenn wir also sicherstellen müssen, dass wir alles im String geparst haben, können wir Parser.end verwenden
fullProgramP : Parser (List Command)
fullProgramP =
programP
|. Parser.spaces
|. Parser.end
Parser.run fullProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Ok [Version 2, Import "stdio"]
Parser.run fullProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE\nBTW VISIBLE \"U SEE NOTHING\""
--> Err [{ problem = ExpectingEnd, col = 1, row = 4 }]
Schließlich müssen wir manchmal je nach geparstem Inhalt entscheiden, einen Parser fehlschlagen zu lassen. Wenn wir zum Beispiel einen Parser bräuchten, der Programme fehlschlagen lässt, die mit zwei aufeinanderfolgenden „HAI“-Befehlen beginnen, könnten wir Folgendes verwenden
singleVersionProgramP : Parser (List Command)
singleVersionProgramP =
fullProgramP
|> Parser.andThen
(\commands ->
case commands of
(Version _) :: (Version _) :: _ ->
Parser.problem "Multiple versions defined"
_ ->
Parser.succeed commands
)
Parser.andThen kann den Inhalt eines Parsers untersuchen und gibt je nach dessen Wert einen neuen Parser zurück.
Hier lassen wir den Parser in einem Fall mit einer eigenen Nachricht über Parser.problem fehlschlagen und geben im anderen Fall den ursprünglichen Wert zurück.
Parser.run singleVersionProgramP "HAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Ok [Version 2, Import "stdio"]
Parser.run singleVersionProgramP "HAI 1\nHAI 2\nCAN HAS STDIO?\nKTHXBYE"
--> Err [{ problem = Problem "Multiple versions defined", col = 8, row = 4 }]
Du freust dich darauf, als neue Restaurantleitung bei Paola's Prestigious Pizza anzufangen. Du hast viele Pläne, wie du den Laden effizient führen kannst, und dafür brauchst du einige Daten. Eins nach dem anderen: Zuerst musst du die Speisekarte analysieren.
Auf der Speisekarte stehen viele verschiedene Pizzen. Paola hat dir eine Liste in einer Textdatei gegeben, mit einer Pizza pro Zeile. Jeder Pizza-Eintrag hat drei Bestandteile: den Namen, eine optionale Vegetarisch-Kennzeichnung und einen Preis.
Du siehst dir die ersten paar Zeilen der Speisekarte an:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
Der Preis scheint ein guter Anfang zu sein. Alle Preise sind ganze Zahlen in der Währung Euro.
Implementiere priceParser, um die Preise zu parsen.
Da alle Preise in Euro sind, musst du die Währung nicht weiterverfolgen, aber parsen musst du sie trotzdem.
Parser.run priceParser "8€"
--> Ok 8
Parser.run priceParser "8"
--> Err [{ problem = ExpectingSymbol "€", col = 2, row = 1 }]
Du siehst dir die Speisekarte noch einmal an:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
Die Vegetarisch-Kennzeichnung "(v)" ist auch recht einfach, also beschließt du, sie als Nächstes anzugehen!
Implementiere vegetarianParser, um die Kennzeichnung zu parsen.
Der Parser sollte einen Bool zurückgeben: True, wenn die Kennzeichnung vorhanden ist, und False, wenn nicht.
Parser.run vegetarianParser "(v)"
--> Ok True
Parser.run vegetarianParser ""
--> Ok False
Na gut, was kommt als Nächstes?
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
Die Zutaten und Pizzanamen scheinen recht einfach zu sein: Sie sind alle einzelne Wörter, die aus ASCII-Zeichen in Groß- und Kleinschreibung bestehen.
Implementiere wordParser, um die Namen zu parsen.
Wenn du schon dabei bist, wandle alle Wörter der Einheitlichkeit halber in Kleinbuchstaben um.
Parser.run wordParser "REGINA"
--> Ok "regina"
Parser.run wordParser "tomato"
--> Ok "tomato"
Parser.run wordParser "(v)"
--> Ok ""
Parser.run wordParser ""
--> Ok ""
Zutaten kommen allerdings in Gruppen vor:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
Es sind einzelne Wörter, die durch Kommas und möglicherweise Leerzeichen getrennt sind.
Implementiere ingredientsParser, um die Zutaten zu parsen.
Parser.run ingredientsParser "tomato, emmental"
--> Ok ["tomato", "emmental"]
Parser.run ingredientsParser "tomato"
--> Ok ["tomato"]
Jetzt, wo du alle Zutaten hast, ist es an der Zeit, eine Pizza zu bauen!
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
Zuerst kommt der Pizzaname, dann die optionale Vegetarisch-Kennzeichnung, ein Doppelpunkt ':', die Zutatenliste, ein Bindestrich '-' und der Preis, wobei möglicherweise Leerzeichen dazwischen stehen.
Implementiere pizzaParser, um die vollständige Pizza zu parsen.
Parser.run pizzaParser "Regina: tomato, ham, mushrooms, cantal - 11€"
--> Ok (Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11)
Zeit zum Feiern!
Implementiere menuParser, um eine Liste aller Pizzen zu parsen, die in der Textdatei durch Zeilenumbruchzeichen '\n' getrennt sind.
Stelle sicher, dass du alle Pizzen erfasst, indem du den Parser so lange laufen lässt, bis er das Ende der Datei erreicht.
Parser.run menuParser "Regina: tomato, ham, mushrooms, cantal - 11€\nFormaggio (v): tomato, emmental - 8€"
--> Ok [Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11,
-- Pizza "formaggio" True ["tomato", "emmental"] 8]
Parser.run menuParser "Regina: tomato, ham, mushrooms, cantal - 11€\[END]"
--> Err [{ problem = ExpectingEnd, col = 1, row = 2 }]
Oh nein, anscheinend hast du etwas übersehen. Weiter unten auf der Speisekarte stehen einige Zutaten, die nicht aus einem einzigen Wort bestehen:
...
Tonno: tomato sauce, tuna - 10€
Hawaii: tomato sauce, fresh pineapple, ham - 9€
...
Implementiere oneIngredientParser, der ASCII-Zeichen in Groß- und Kleinschreibung oder Leerzeichen ' ' akzeptiert.
Nutze außerdem die Gelegenheit, einen kleinen Fehler von wordParser zu beheben, indem du sicherstellst, dass leere Strings nicht als gültige Zutaten erkannt werden, sondern stattdessen Problem "empty string" ausgeben.
Wenn du schon dabei bist, wandle die Strings in Kleinbuchstaben um und entferne zusätzlich Leerzeichen an beiden Seiten.
Parser.run oneIngredientParser "Tomato Sauce"
--> Ok "tomato sauce"
Parser.run oneIngredientParser " tomato sauce "
--> Ok "tomato sauce"
Parser.run oneIngredientParser ""
--> Err [{ problem = Problem "empty string", col = 1, row = 1 }]
Sobald der Parser definiert ist, verwende ihn in ingredientsParser.
Melde dich bei Exercism an, um Elm mit 28 Konzepte110 Übungen und echtem menschlichen Mentoring zu lernen und zu meistern, alles kostenlos.