Il parsing è il processo che trasforma del testo in dati significativi.
Le funzioni del pacchetto String sono limitate sotto questo aspetto e le espressioni regolari sono difficili da usare, quindi il modo idiomatico di fare parsing in Elm è usare il pacchetto elm/parser.
Vediamo un esempio in cui scriviamo un parser per (un sottoinsieme del) linguaggio di programmazione LOLCODE. Consideriamo il seguente programma commentato:
HAI 1 # program version 1
CAN HAS STDIO? # import stdio
VISIBLE "HAI WORLD!" # print "HAI WORLD!"
KTHXBYE # end of program
Per prima cosa, scriviamo un tipo Elm che rappresenti i comandi possibili:
type Command
= Version Int
| Import String
| Print String
Il nostro programma LOLCODE può quindi essere rappresentato come una List Command.
Iniziamo con un parser per la riga HAI 1:
versionP : Parser Command
versionP =
Parser.succeed Version
|. Parser.keyword "HAI"
|. Parser.spaces
|= Parser.int
versionP è costruito usando una pipeline di parser.
L'operatore (|.) significa «analizza la stringa ma scarta il risultato» e (|=) significa «analizza la stringa e conserva il risultato».
In questo esempio, Parser.keyword "HAI" consumerà un "HAI", ma quel risultato viene ignorato.
Parser.spaces consumerà un numero qualsiasi di caratteri ' ', '\n' e '\r', ma anche in questo caso il risultato verrà ignorato.
Parser.int analizzerà un numero intero, e il valore di quel numero (diciamo 1) verrà passato al Parser.succeed Version più esterno, così il parser riuscirà a restituire il valore Version 1.
Se uno qualsiasi dei parser nella pipeline fallisce, restituirà un Parser.DeadEnd e l'intero parser fallirà.
Un parser può essere eseguito con Parser.run:
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 }]
Passiamo alla riga "CAN HAS STDIO?" con
importP : Parser Command
importP =
Parser.succeed Import
|. Parser.keyword "CAN HAS"
|. Parser.spaces
|= packageP
|. Parser.symbol "?"
Anche importP è una pipeline di parser, ma questa volta termina con Parser.symbol, adatto ad analizzare simboli come "?".
Il valore che vogliamo conservare (il nome del pacchetto da importare) viene analizzato da
packageP : Parser String
packageP =
Parser.chompWhile Char.isAlphaNum
|> Parser.getChompedString
|> Parser.map String.toLower
Dato che non sappiamo quale sarà il nome del pacchetto, non possiamo usare Parser.keyword, quindi al suo posto usiamo Parser.chompWhile, che consumerà singoli caratteri finché soddisfano una condizione (qui, essere un carattere alfanumerico).
Questi caratteri consumati vengono poi raccolti da Parser.getChompedString, che restituisce un Parser String.
In questo caso vogliamo normalizzare i nomi dei pacchetti, quindi usiamo Parser.map per modificare la stringa contenuta nel parser e renderla minuscola.
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 }]
Passiamo ora a VISIBLE "HAI WORLD!", che viene analizzato da
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!")
Ora abbiamo un parser per ogni comando. Naturalmente, in un programma generico l'ordine di questi comandi non è prevedibile, quindi costruiamo un parser generico per i comandi
commandP : Parser Command
commandP =
Parser.oneOf
[ versionP
, importP
, printP
]
Parser.oneOf proverà un parser alla volta, nell'ordine dato.
Se un parser fallisce, si passa al successivo; se invece riesce, il suo risultato viene restituito senza provare i parser rimanenti.
Se tutti i parser falliscono, Parser.oneOf fallisce.
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 }
-- ]
Nota come ogni tentativo fallito produca un Parser.DeadEnd che viene raccolto da Parser.oneOf.
Per analizzare un programma completo, dobbiamo analizzare più comandi in sequenza con
programP : Parser (List Command)
programP =
Parser.sequence
{ start = ""
, separator = "\n"
, end = "KTHXBYE"
, spaces = Parser.succeed ()
, item = commandP
, trailing = Parser.Optional
}
Parser.sequence è un parser molto personalizzabile, con diverse opzioni.
start, separator e end accettano stringhe che separano gli elementi della sequenza; per analizzare una lista Elm useremmo "[", "," e "]".
spaces offre flessibilità accettando un parser che gestirà i potenziali spazi tra elementi e separatori; per analizzare una lista Elm useremmo Parser.spaces, ma in questo caso non accettiamo spazi, quindi usiamo Parser.succeed (), che riesce immediatamente senza consumare nulla.
item accetta il parser degli elementi e, infine, trailing ci permette di decidere se i separatori finali sono Parser.Forbidden, Parser.Mandatory o Parser.Optional.
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 }
-- ]
Quando i parser riescono, smettono di consumare la stringa
Parser.run Parser.int "1"
--> Ok 1
Parser.run Parser.int "1 BTW VISIBLE \"U SEE NOTHING\""
--> Ok 1
Quindi, se dobbiamo assicurarci di aver analizzato tutto nella stringa, possiamo usare Parser.end
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 }]
Infine, a volte dobbiamo decidere di far fallire un parser in base al contenuto che ha analizzato. Per esempio, se avessimo bisogno di un parser che faccia fallire i programmi che iniziano con due comandi "HAI" di fila, potremmo usare
singleVersionProgramP : Parser (List Command)
singleVersionProgramP =
fullProgramP
|> Parser.andThen
(\commands ->
case commands of
(Version _) :: (Version _) :: _ ->
Parser.problem "Multiple versions defined"
_ ->
Parser.succeed commands
)
Parser.andThen può ispezionare il contenuto di un parser e restituire un nuovo parser in base al suo valore.
Qui, in un caso facciamo fallire il parser con un messaggio personalizzato usando Parser.problem, nell'altro restituiamo il valore originale.
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 }]
Sei entusiasta di entrare a far parte di Paola's Prestigious Pizza come nuovo manager del ristorante. Hai molti piani su come gestire il locale in modo efficiente e, per farlo, ti servono alcuni dati. Per prima cosa, devi analizzare il menu.
Nel menu ci sono molti tipi di pizza; Paola ti ha dato un elenco in un file di testo, con una pizza per riga. Ogni voce di pizza ha tre componenti: il nome, un indicatore vegetariano opzionale e un prezzo.
Dai un'occhiata alle prime righe del menu:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
Il prezzo sembra un buon punto di partenza. Tutti i prezzi sono numeri interi in euro.
Implementa priceParser per analizzare i prezzi.
Dato che tutti i prezzi sono in euro, non devi tenere traccia della valuta, anche se devi comunque analizzarla.
Parser.run priceParser "8€"
--> Ok 8
Parser.run priceParser "8"
--> Err [{ problem = ExpectingSymbol "€", col = 2, row = 1 }]
Guardi di nuovo il menu:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
Anche l'indicatore vegetariano "(v)" è piuttosto semplice, decidi di occupartene subito dopo!
Implementa vegetarianParser per analizzare l'indicatore.
Il parser deve restituire un Bool, True se l'indicatore è presente e False altrimenti.
Parser.run vegetarianParser "(v)"
--> Ok True
Parser.run vegetarianParser ""
--> Ok False
Bene, e adesso?
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
Gli ingredienti e i nomi delle pizze sembrano piuttosto semplici: sono tutte parole singole composte da caratteri ASCII maiuscoli e minuscoli.
Implementa wordParser per analizzare i nomi.
Già che ci sei, rendi tutte le parole minuscole per uniformità.
Parser.run wordParser "REGINA"
--> Ok "regina"
Parser.run wordParser "tomato"
--> Ok "tomato"
Parser.run wordParser "(v)"
--> Ok ""
Parser.run wordParser ""
--> Ok ""
Gli ingredienti, però, arrivano in gruppo:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
Sono parole singole separate da virgole e, talvolta, da spazi.
Implementa ingredientsParser per analizzare gli ingredienti.
Parser.run ingredientsParser "tomato, emmental"
--> Ok ["tomato", "emmental"]
Parser.run ingredientsParser "tomato"
--> Ok ["tomato"]
Ora che hai tutti gli ingredienti, è il momento di preparare una Pizza!
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
Prima c'è il nome della pizza, l'indicatore vegetariano opzionale, i due punti ':', l'elenco degli ingredienti, un trattino '-' e il prezzo, con possibili spazi intervallati.
Implementa pizzaParser per analizzare la pizza completa.
Parser.run pizzaParser "Regina: tomato, ham, mushrooms, cantal - 11€"
--> Ok (Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11)
Ora si festeggia!
Implementa menuParser per analizzare un elenco di tutte le pizze separate da caratteri di nuova riga '\n' nel file di testo.
Assicurati di ottenere tutte le pizze eseguendo il parser finché non raggiunge la fine del file.
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 no, sembra che ti sia sfuggito qualcosa. Più avanti nel menu, alcuni ingredienti non sono parole singole:
...
Tonno: tomato sauce, tuna - 10€
Hawaii: tomato sauce, fresh pineapple, ham - 9€
...
Implementa oneIngredientParser che accetterà caratteri ASCII maiuscoli e minuscoli oppure spazi ' '.
Cogli anche l'occasione per correggere un piccolo difetto che aveva wordParser, assicurandoti che le stringhe vuote non vengano riconosciute come ingredienti validi e che venga invece emesso Problem "empty string".
Già che ci sei, assicurati di rendere le stringhe minuscole e di rimuovere gli spazi bianchi su entrambi i lati.
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 }]
Una volta definito il parser, usalo in ingredientsParser.
Iscriviti a Exercism per imparare e padroneggiare Elm con 28 concetti110 esercizi e il mentoring di persone reali, tutto gratis.