Ogma
Ogma 是一个用 Swift 编写的轻量级解析框架。再见了复杂的有限状态机!现在您可以使用纯函数轻松编写解析器。
如何编写解析器?
一旦您了解想要解析的内容(有一个定义好的上下文无关文法会是很有帮助的),您就需要实现以下内容:
- 您的模型:解析器结果的样子
- 您的词法分析器:将输入字符串转换为标记的组件
- 您的解析器:从词法分析器消费标记。解析器将接受标记作为输入并输出您的模型
安装
Ogma 通过 Cocoapods 提供。为了将 Ogma 添加到您的项目中,请在您的 Podfile 中添加它
pod 'Ogma', '~> 0.1'
然后运行
$ pod install
示例
目前,为了简化,我们将使用 Ogma 编写一个简单的计算器。我们的计算器将仅支持加法和乘法。我们现在不会在加法之前进行乘法。那是另一篇文章的内容。
让我们回到最初的想法:模型、词法分析器、解析器!
模型
我们可以将计算器的输入建模为一个表达式
public indirect enum Expression {
public struct Addition {
let lhs: Expression
let rhs: Expression
}
public struct Multiplication {
let lhs: Expression
let rhs: Expression
}
case int(Int)
case addition(Addition)
case multiplication(Multiplication)
}
既然我们正在做这件事,我们不妨直接写一个经典的eval
函数
extension Expression {
func eval() -> Int {
switch self {
case .int(let int):
return int
case .addition(let lhs, let rhs):
return lhs.eval() + rhs.eval()
case .multiplication(let lhs, let rhs):
return lhs.eval() * rhs.eval()
}
}
}
分词器
首先,让我们定义将要使用的标记。所以我们将允许整数、+
和 *
,以及开闭括号。
因此,我们的标记现在可以是一个枚举。为了清晰起见,我们建议使用名称空间将标记与您的模型一起使用。为了清楚地表示它们属于一起
extension Expression {
public enum Token: TokenProtocol {
case int(Int)
case plus
case times
}
}
在标记清晰后,我们现在可以只编写一个分词器。分词器是一个组件,它将一个字符串转换为标记。构建它的最简单方法是我们所说的TokenGenerator
。TokenGenerator尝试尽可能消耗字符串的开始部分以创建单一路标记。
要使用TokenGenerator
,我们的分词器只需遵守GeneratorLexer
并返回它将使用的生成器
extension Expression {
enum Lexer: GeneratorLexer {
typealias Token = Expression.Token
static let generators: Generators = [
IntLiteralTokenGenerator().map(Token.int),
RegexTokenGenerator(pattern: "\\+").map(to: .plus),
RegexTokenGenerator(pattern: "\\*").map(to: .times),
]
}
}
现在这里有很多事情在发生。我们主要使用两种东西
IntLiteralTokenGenerator
是由Ogma实现的已实现的TokenGenerator。它将一个字符串尝试解析一个整数。我们只是将这个生成器映射返回我们之前建模的标记。RegexTokenGenerator
是一个将返回与模式匹配的字符串的TokenGenerator。然后我们将每个实例映射到.plus
。所以如果我们找到+
,我们返回Token.plus
解析器
现在我们可以通过让我们的模型实现Parsable
来构建我们的解析器。对一个对象来说,如果要成为Parsable
,这只意味着有一个静态解析器可以返回类型。从开始,我们先为整数实现它。最简单的方法是为token写一个计算属性,当它有整数时
extension Expression.Token {
var int: Int? {
guard case .int(let int) = self else { return nil }
return int
}
}
并给出解析器的键路径
extension Int: Parsable {
public typealias Token = Expression.Token
public static let parser: AnyParser<Expression.Token, Int> = .consuming(keyPath: \.int)
}
现在,操作。通过使用&&
运算符,我们可以链接多个解析器并顺序地期望结果
extension Expression.Addition: Parsable {
public typealias Token = Expression.Token
public static let parser: AnyParser<Expression.Token, Expression.Addition> = {
let parser = Expression.self && .plus && Expression.self
return parser.map { Expression.Addition(lhs: .int($0), rhs: $1) }
}()
}
extension Expression.Multiplication: Parsable {
public typealias Token = Expression.Token
public static let parser: AnyParser<Expression.Token, Expression.Multiplication> = {
let parser = Expression.self && .times && Expression.self
return parser.map { Expression.Multiplication(lhs: .int($0), rhs: $1) }
}()
}
您可以看到,我们声明乘法表达式由一个表达式、一个操作符和一个另一个表达式组成。Dogma将自动正确匹配它;)。
最后,我们使表达式Parsable
。这次使用||
运算符来表示它应取第一个解析器成功的返回值
extension Expression: Parsable {
public static let parser: AnyParser<Expression.Token, Expression> = Addition.map(Expression.addition) ||
Multiplication.map(Expression.multiplication) ||
Int.map(Expression.int)
}
💸
利润!我们现在可以使用我们的解析器
extension String {
func calculate() throws -> Int {
return try Expression.parse(self, using: Expression.Lexer.self).eval()
}
}
太棒了!我们刚刚编写了一个计算器,我们没有在任何地方使用状态机。事实上,我们所有的函数都是完全纯函数,可以很容易地扩展。
其他酷功能
忽略标记
一些语言选择完全忽略某些字符和表达式。这些是应该根本不会到达解析器的标记。例如:注释和空白字符。
如果你已经有了消耗这些的标记生成器,你只需在你的词法分析器中调用 忽略
。例如,如果我们想在计算器中忽略空白字符
enum Lexer: GeneratorLexer {
...
static let generators: Generators = [
WhiteSpaceTokenGenerator().ignore(),
...
]
}
二元运算符和运算符优先级
编写解析器时的另一个常见场景是处理运算符优先级。甚至在上面提到的例子中,关于 "+
" 和 "-
" 之间的优先级。Ogma 已经提供了一种编写二元运算和处理运算符优先级的方法。
注意:目前这仅在你事先知道所有可能的运算符时才有效。如果你打算编写一个支持自定义运算符的语言,这个 API 不适合你。
要使用二元运算符 API,你必须实现两个协议
BinaryOperator
:基本上是运行运算符的标识符。这必须是可以比较的,以便我们知道优先级。值越小,优先级越高。- 和
MemberOfBinaryOperation
:这是你的运算符的左右两边。成员还知道它支持哪些运算符,并可以从运算符实例化。
示例
在我们的计算器案例中,我们现在可以废除加法
和乘法
结构,转而使用二元运算
。我们首先实现操作符
extension Expression {
public enum Operator: Int, BinaryOperator {
case multiplication
case addition
var token: Expression.Token {
switch self {
case .multiplication:
return .times
case .addition:
return .plus
}
}
}
}
请注意,我们首先实现了乘法
,然后是加法
。这意味着既然操作符
通过Int
实现RawRepresentable
,那么实际的优先级是先乘法后加法。我们对表达式进行了几个调整
public indirect enum Expression {
case int(Int)
case operation(BinaryOperation<Expression>)
}
并实现了二元运算的成员
extension Expression: MemberOfBinaryOperation {
public init(from operation: BinaryOperation<Expression>) {
self = .operation(operation)
}
}
我们几乎完成了,现在我们只需要更新新的模型解析器
extension Expression: Parsable {
public static let parser: AnyParser<Expression.Token, Expression> = BinaryOperation<Expression>.map(Expression.operation) ||
Int.map(Expression.int)
}
字符串注解
有时你并不期望所有用户的输入都是有效的。也许你希望他们能够将你的语言内联在其他文本中。为此有AnnotatedString
。注解字符串本质上只是一个枚举数组
public enum AnnotationElement<Annotation> {
case text(String)
case annotated(String, Annotation)
}
其中可以是未匹配的普通文本或带有解析值的注解部分。
现在你可以支持一些酷炫的功能,比如解析内联JSON
Hello, here's some JSON: { "Hello": "World" }
这将输出
[
.text("Hello, here's some JSON:"),
.annotated("{ \"Hello\": \"World\" }",
.dictionary(["Hello": .string("World"])),
]
事实上,你甚至不需要完整的解析器架构就能享受这个功能。如果你只想用正则表达式来注解可以表达的内容,那么一个词法分析器就足够了。
例如,如果你正在解析推文中的提及和标签
首先:构建一个词法分析器。
enum Twitter {
enum Token: TokenProtocol {
case mention(String)
case hashtag(String)
}
enum Lexer: GeneratorLexer {
typealias Token = Twitter.Token
static let generators: Generators = [
RegexTokenGenerator(pattern: "@(\\w+)", group: 1).map { .mention($0) },
RegexTokenGenerator(pattern: "#(\\w+)", group: 1).map { .hashtag($0) },
]
}
}
其次:调用注解
let tweet = try Twitter.Lexer.annotate(input) as AnnotatedString<Twitter.Token>
未来主题和已知问题
- 一个类型只能实现一次
可解析的
。并且为一种Token类型。因此,如果你计划编写多个解析器,你不应该让标准库的类型实现Parsable
。
作者
致谢
本项目的实现得益于