解析是把文本转换成有意义的数据的过程。在这方面,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里。