解析是把文字轉換成有意義資料的過程。
String 套件提供的函式在這方面有所限制,而正規表達式又不容易使用,所以在 Elm 裡,慣用的解析做法是使用 elm/parser 套件。
我們來看一個例子:為程式語言 LOLCODE(的一部分)撰寫解析器。 先看看下面這個加上了註解的程式:
HAI 1 # program version 1
CAN HAS STDIO? # import stdio
VISIBLE "HAI WORLD!" # print "HAI WORLD!"
KTHXBYE # end of program
首先,我們來寫一個 Elm 型別,用來表示各種可能的指令:
type Command
= Version Int
| Import String
| Print String
這樣一來,我們的 LOLCODE 程式就可以用 List Command 來表示。
先從 HAI 1 這一行開始寫解析器:
versionP : Parser Command
versionP =
Parser.succeed Version
|. Parser.keyword "HAI"
|. Parser.spaces
|= Parser.int
versionP 是用 解析器管線 打造出來的。
(|.) 運算子的意思是「解析字串,但丟棄結果」,而 (|=) 的意思是「解析字串並保留結果」。
在這個例子裡,Parser.keyword "HAI" 會消耗一個 "HAI",但那個結果會被忽略。
Parser.spaces 會消耗任意數量的 ' '、'\n' 和 '\r' 字元,但那些同樣會被忽略。
Parser.int 會解析出一個整數,而這個整數的值(假設是 1)會傳給最上層的 Parser.succeed Version,解析器就會成功回傳 Version 1 這個值。
管線中只要有任一個解析器失敗,就會回傳 Parser.DeadEnd,整個解析器也隨之失敗。
解析器可以用 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 }]
接著來處理 "CAN HAS STDIO?" 這一行:
importP : Parser Command
importP =
Parser.succeed Import
|. Parser.keyword "CAN HAS"
|. Parser.spaces
|= packageP
|. Parser.symbol "?"
importP 同樣是一條解析器管線,只是這次結尾用的是 Parser.symbol,適合解析像 "?" 這類符號。
我們想保留的值(也就是要匯入的套件名稱),則由下面這段程式解析:
packageP : Parser String
packageP =
Parser.chompWhile Char.isAlphaNum
|> Parser.getChompedString
|> Parser.map String.toLower
由於我們不知道套件名稱會是什麼,所以沒辦法用 Parser.keyword,改用 Parser.chompWhile;它會逐一消耗字元,只要這些字元符合某個條件(在這裡是必須為英數字元)。
這些被消耗的字元接著會由 Parser.getChompedString 收集起來,並回傳一個 Parser String。
在這個例子裡,我們想把套件名稱正規化,所以用 Parser.map 修改解析器內含的字串,把它變成小寫。
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 }]
接下來,VISIBLE "HAI WORLD!" 是用下面這段程式解析的:
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!")
現在我們已經為每個指令各寫好一個解析器了。 當然,在一般的程式裡,這些指令出現的順序無法預測,所以我們建立一個通用的指令解析器:
commandP : Parser Command
commandP =
Parser.oneOf
[ versionP
, importP
, printP
]
Parser.oneOf 會依照給定的順序,一個接一個嘗試解析器。
如果某個解析器失敗,就換下一個試;要是成功,就直接回傳它的結果,不再嘗試剩下的解析器。
如果所有解析器都失敗,Parser.oneOf 也會失敗。
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 }
-- ]
注意每一次失敗的嘗試都會產生一個 Parser.DeadEnd,並由 Parser.oneOf 收集起來。
要解析一整支程式,我們需要依序解析多個指令,寫法如下:
programP : Parser (List Command)
programP =
Parser.sequence
{ start = ""
, separator = "\n"
, end = "KTHXBYE"
, spaces = Parser.succeed ()
, item = commandP
, trailing = Parser.Optional
}
Parser.sequence 是高度可自訂的解析器,帶有許多選項。
start、separator 和 end 接收用來分隔序列中各項目的字串;如果要解析 Elm 陣列,我們會用 "["、"," 和 "]"。
spaces 提供了彈性,它接收一個解析器,用來處理項目與分隔符之間可能出現的空白;解析 Elm 陣列時我們會用 Parser.spaces,但在這個例子裡不接受任何空白,所以改用 Parser.succeed (),它會立刻成功,實際上不消耗任何東西。
item 接收解析項目的解析器,最後,trailing 讓我們決定結尾的分隔符是 Parser.Forbidden、Parser.Mandatory 還是 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 }
-- ]
解析器成功之後,就會停止消耗字串:
Parser.run Parser.int "1"
--> Ok 1
Parser.run Parser.int "1 BTW VISIBLE \"U SEE NOTHING\""
--> Ok 1
所以如果需要確認字串裡的內容都解析完了,可以使用 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 }]
最後,有時候我們需要根據解析出來的內容,決定是否讓解析器失敗。 舉例來說,如果我們需要一個解析器,讓開頭連續出現兩個 "HAI" 指令的程式判定為失敗,可以這樣寫:
singleVersionProgramP : Parser (List Command)
singleVersionProgramP =
fullProgramP
|> Parser.andThen
(\commands ->
case commands of
(Version _) :: (Version _) :: _ ->
Parser.problem "Multiple versions defined"
_ ->
Parser.succeed commands
)
Parser.andThen 可以檢視解析器解析出的內容,並根據它的值回傳一個新的解析器。
在這裡,其中一種情況我們用 Parser.problem 讓解析器帶著自訂訊息失敗,另一種情況則回傳原本的值。
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 }]
你很高興能加入 Paola's Prestigious Pizza,成為新任的餐廳經理。 對於要如何有效率地經營這間店,你有很多計畫,而為了達成這個目標,你需要一些資料。 首先,你需要分析菜單。
菜單上有很多種類的披薩,Paola 給了你一份文字檔清單,每一行是一份披薩。 每一筆披薩資料都有三個部分:名稱、選用的素食標記,以及價格。
你看了一下菜單最前面的幾行:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
價格似乎是個不錯的起點。 所有價格都是以歐元為單位的整數。
實作priceParser來解析價格。
由於所有價格都是歐元,你不需要另外記錄貨幣,不過還是得解析它。
Parser.run priceParser "8€"
--> Ok 8
Parser.run priceParser "8"
--> Err [{ problem = ExpectingSymbol "€", col = 2, row = 1 }]
你再看一次菜單:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
素食標記"(v)"也相當簡單,於是你決定接下來就處理它!
實作vegetarianParser來解析這個標記。
這個解析器應該回傳一個Bool,如果標記存在就回傳True,否則回傳False。
Parser.run vegetarianParser "(v)"
--> Ok True
Parser.run vegetarianParser ""
--> Ok False
好,接下來呢?
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
食材和披薩名稱看起來相當單純:它們全都是單一詞彙,由大寫和小寫的 ASCII 字元組成。
實作wordParser來解析這些名稱。
既然都在做了,就順便把所有的詞都轉成小寫,以求一致。
Parser.run wordParser "REGINA"
--> Ok "regina"
Parser.run wordParser "tomato"
--> Ok "tomato"
Parser.run wordParser "(v)"
--> Ok ""
Parser.run wordParser ""
--> Ok ""
不過,食材是成群出現的:
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
它們是以逗號分隔的單字,中間可能還夾著空格。
實作ingredientsParser來解析食材。
Parser.run ingredientsParser "tomato, emmental"
--> Ok ["tomato", "emmental"]
Parser.run ingredientsParser "tomato"
--> Ok ["tomato"]
現在你已經備齊了所有食材,該來做一份Pizza了!
Regina: tomato, ham, mushrooms, cantal - 11€
Formaggio (v): tomato, emmental - 8€
...
首先你會看到披薩名稱、選用的素食標記、一個冒號':'、食材清單、一個連字號'-',以及價格,中間可能穿插著空格。
實作pizzaParser來解析完整的披薩。
Parser.run pizzaParser "Regina: tomato, ham, mushrooms, cantal - 11€"
--> Ok (Pizza "regina" False ["tomato", "ham", "mushrooms", "cantal"] 11)
派對時間!
實作menuParser,解析文字檔中所有以換行字元'\n'分隔的披薩清單。
請讓解析器持續執行,直到抵達檔案結尾,以確保你取得了所有的披薩。
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 }]
喔不,看來你漏掉了什麼。 在菜單更下方的某處,有些食材不是單一詞彙:
...
Tonno: tomato sauce, tuna - 10€
Hawaii: tomato sauce, fresh pineapple, ham - 9€
...
實作oneIngredientParser,它會接受大寫和小寫的 ASCII 字元,或是空格' '。
也趁這個機會修正wordParser的一個小瑕疵,確保空字串不會被辨識為有效的食材,而是改為產生Problem "empty string"。
既然都在做了,也請把字串轉成小寫,並去除兩側的空白字元。
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 }]
定義好這個解析器之後,就在ingredientsParser中使用它。