精简解析器 (Parsimonious)

精简解析器 (Parsimonious) 是一个用 Swift 编写的解析器组合库。虽然现在已经有其他的库了,但 Parsimonious 是最早的库之一,始于 2019 年初。

Parsimonious 从一开始就使用了函数式编程的概念,但最新版本是迄今为止函数式程度最高的版本,强调可组合性和不变性。

一个 Parsimonious 解析器是一个类型,形式为 Parser<Source: Collection, Output>,其中 Source 是我们要解析的集合,而 Output 是解析器返回的值的类型。

在大多数情况下,Source 是一个 String,但它可以是任何 Collection 类型。完全可以解析整数数组,例如。(请参阅单元测试。)

解析器组合器是一个很大的主题,我无法在此涵盖。如果您已经熟悉解析器组合器,那么 Parsimonious 应该不会太难。单元测试包含几乎所有您需要知道的内容,包括一个完整的 JSON 解析器的实现。

集合解析器 vs 流解析器

大多数解析器组合器,例如 Haskell 中的那些,都是流解析器。流解析器是高效解析大量数据的好方法。例如,如果您有一个非常大的文件,并且想在不将整个文件一次性加载到内存中的情况下进行解析,那么流解析器是一个很好的选择。

然而,Parsimonious 是一个 Collection 解析器。Collection 解析器的主要缺点是必须将整个集合加载到内存中。(理论上,Swift 的 Collection 没有这个要求,但实际上总是这样。)Collection 解析器的优点是它易于编写和易于使用:它开箱即用地支持回溯。

因此,Parsimonious 非常适合分词、编写 DSL,甚至对 token 进行进一步处理以生成 AST。例如,第一个解析的集合可以是 StringCharacter 实例的 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]。大多数时候,这不是我们想要的,因此有专门的组合器用于处理 CharacterString。这些总是返回一个 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]。后缀 * 被重载以与返回 CharacterString 的组合器一起使用,在这种情况下,它总是返回 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" 等。

元素谓词

matchchar 组合器具有采用元素谓词的重载。 元素谓词匹配一个元素,如果匹配成功,则解析器返回该元素。

最基本的元素谓词是 (C.Element) -> Bool。 还有另外两个。 首先,类型为 KeyPath<C.Element, Bool>KeyPath,其次,类型为 C.Element 的模型元素,其中 C.ElementEquatable

以下是一些简单的例子

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 允许这两个定义相互引用。 由于 bazboo 大概不引用 barfoo,因此没有必要对它们使用 deferred

deferred 的重载允许定义一个 ad hoc 的延迟加载解析器

let nothing: Parser<String, Void> = deferred { source, index in
  return .success(State(output: (), range: index..<index>))
}

此解析器不消耗任何东西并返回 Void,但说明了这一点。