精简解析器 (Parsimonious) 是一个用 Swift 编写的解析器组合库。虽然现在已经有其他的库了,但 Parsimonious 是最早的库之一,始于 2019 年初。
Parsimonious 从一开始就使用了函数式编程的概念,但最新版本是迄今为止函数式程度最高的版本,强调可组合性和不变性。
一个 Parsimonious 解析器是一个类型,形式为 Parser<Source: Collection, Output>
,其中 Source
是我们要解析的集合,而 Output
是解析器返回的值的类型。
在大多数情况下,Source
是一个 String
,但它可以是任何 Collection
类型。完全可以解析整数数组,例如。(请参阅单元测试。)
解析器组合器是一个很大的主题,我无法在此涵盖。如果您已经熟悉解析器组合器,那么 Parsimonious 应该不会太难。单元测试包含几乎所有您需要知道的内容,包括一个完整的 JSON 解析器的实现。
大多数解析器组合器,例如 Haskell 中的那些,都是流解析器。流解析器是高效解析大量数据的好方法。例如,如果您有一个非常大的文件,并且想在不将整个文件一次性加载到内存中的情况下进行解析,那么流解析器是一个很好的选择。
然而,Parsimonious 是一个 Collection
解析器。Collection
解析器的主要缺点是必须将整个集合加载到内存中。(理论上,Swift 的 Collection
没有这个要求,但实际上总是这样。)Collection
解析器的优点是它易于编写和易于使用:它开箱即用地支持回溯。
因此,Parsimonious 非常适合分词、编写 DSL,甚至对 token 进行进一步处理以生成 AST。例如,第一个解析的集合可以是 String
(Character
实例的 Collection
。) 这些可以被解析成一个 Token
实例的数组 (即,一个 tokens 的 Collection
)。但是因为 Parsimonious 可以解析任何类型的 Collection
,这些 tokens 可以随后被解析来创建 AST。
最基本的组合器是 match
,它使用谓词匹配底层集合中的单个元素
public func match<C: Collection>(_ test: @escaping (C.Element) -> Bool) -> Parser<C, C.Element>
所有其他组合器最终都建立在这个组合器之上。
使用字符串时,match
可能有点不方便,因为它返回一个 Character
,并且基于它构建的组合器通常会返回 [Character]
。大多数时候,这不是我们想要的,因此有专门的组合器用于处理 Character
和 String
。这些总是返回一个 String
。
public func char<C: Collection>(
_ test: @escaping (Character) -> Bool
) -> Parser<C, String> where C.Element == Character {
match(test).joined()
}
这与 match
本质上相同,但它消耗一个 Character
并给出一个 String
。
如果没有操作符,什么样的解析器组合器库才算合格呢?
Parsimonious 只有几个。
后缀运算符 *
此运算符与 many
组合器完全相同,后者匹配 0 个或多个底层解析器。例如,要匹配 0 个或多个字符 "a"
match("a")*
这将返回 [Character]
。后缀 *
被重载以与返回 Character
或 String
的组合器一起使用,在这种情况下,它总是返回 String
char("a")*
这将返回一个包含匹配字符的 String
。
后缀运算符 +
此运算符与 many1
组合器相同,后者匹配 1 个或多个底层解析器。
char("a")+
以上匹配 1 个或多个 "a" 字符。
我上面说的关于 *
的一切都适用于 +
。
前缀运算符 *
这与 optional
组合器相同
*char("a")
上面返回 0 或 1 个 "a"。 返回的值必须实现 Defaultable
协议。 如果匹配失败,则返回默认值。 在这种情况下,这是一个空 String
。
中缀运算符 <|>
这是 choice 运算符
string("foo") <|> string("bar")
它首先尝试匹配字符串 "foo"。 如果失败,它会尝试匹配 "bar"。 如果这失败了,则解析失败,并出现上次尝试匹配的错误。 fail
组合器可用于自定义错误
string("foo") <|> string("bar") <|> fail(MyError.oops)
中缀运算符 +
内置的 +
运算符已被重载以分别连接数组和字符串。
string("foo")* + string("bar")
这匹配 0 个或多个字符串 "foo",后跟 1 个字符串 "bar" 的实例,因此它将匹配 "bar"、"foobar"、"foofoobar" 等。
match
和 char
组合器具有采用元素谓词的重载。 元素谓词匹配一个元素,如果匹配成功,则解析器返回该元素。
最基本的元素谓词是 (C.Element) -> Bool
。 还有另外两个。 首先,类型为 KeyPath<C.Element, Bool>
的 KeyPath
,其次,类型为 C.Element
的模型元素,其中 C.Element
是 Equatable
。
以下是一些简单的例子
char { $0 == "e" } // Matches and returns the character "e"
char(\Character.isWhitespace) // Matches a whitespace character and returns it
char("e") // Matches and returns the character "e"
这可以使用一个小型的 DSL 来扩展
// Match any character which is not whitespace or a newline.
char(!any(\.isWhitespace, \.isNewline))
当模型元素、键路径和谓词混合在一起时,前缀 ^
运算符可以将元素和键路径转换为谓词,例如,
char(!any(^"e", ^\.isWhitespace))
当取反时,不需要 ^
char(all(!"e", !\.isWhitespace))
在任何可能的情况下,将其他解析器作为参数的组合器都使用 @escaping @autoclosure
来实现这一点。
这在解析器组合器库中很重要,因为组合器通常是递归的。 考虑以下来自单元测试的例子
var jarray: JParser = bracketed(many(json, separator: ",")) >>> JSON.array
let json = whitespacedWithNewlines(jstring <|> jnumber <|> jobject <|> jarray <|> jbool <|> jnull)
请注意,jarray
引用 json
,反之亦然。 如果没有延迟加载,这是不可能的。 Swift 编译器会报错。
如果您编写自己的组合器,我强烈建议您采用这种做法。
有些地方无法使用 @escaping @autoclosure
,例如可变参数。 大多数时候,这应该不是问题,但在极少数情况下,可以使用 deferred
组合器使任何解析器成为延迟加载的
let bar = chain(deferred(foo), baz)
let foo = chain(deferred(bar), boo)
由于 chain
使用可变参数,因此它们不能是自动闭包。 在此处使用 deferred
允许这两个定义相互引用。 由于 baz
和 boo
大概不引用 bar
和 foo
,因此没有必要对它们使用 deferred
。
deferred
的重载允许定义一个 ad hoc
的延迟加载解析器
let nothing: Parser<String, Void> = deferred { source, index in
return .success(State(output: (), range: index..<index>))
}
此解析器不消耗任何东西并返回 Void
,但说明了这一点。