背景

最近有机会接触了pest,一个优雅的通过使用Parsing Expression Grammar or PEGs 来生成语法解析器,正好借助博客园这个平台,来分享一下自己的学习心得,也希望可以借助这个机会,和同行们互相切磋,互相提高。

 什么是 Parsing Expression Grammar?

Parsing Expression Grammar(PEG)是一种分析性形式文法,它是用 Pest 定义 Rust 解析“规则”的方法之一。 Pest 接受具有此类规则定义的文件的输入,并生成遵循它们的 Rust 解析器。

在编写规则时,我们应该考虑 Pest 和 PEG 的三个定义特征。

第一个特征是贪婪匹配。 Pest 将始终尝试将输入的最大值与规则相匹配。 例如,假设我们编写了如下规则:

match one or more alphabets

在这种情况下,Pest 将消耗输入中的所有内容,直到达到数字、空格或符号。 在此之前它不会停止。

 

第二个特征是交替匹配是有序的。 为了理解这意味着什么,假设我们给出了多个匹配来满足一条规则,如下所示:

rule1 | rule2 | rule3

Pest 将首先尝试匹配规则 1。 当且仅当规则 1 失败时,Pest 才会尝试匹配规则 2,依此类推。 如果第一条规则匹配,Pest 将不会尝试匹配任何其他规则来找到最佳匹配。

因此,在编写此类替代方案时,我们必须将最具体的替代方案放在前面,将最一般的替代方案放在最后。 

 

第三个特征是无回溯,这意味着如果规则无法匹配,解析器将不会回溯。 相反,Pest 会尝试寻找更好的规则或选择最佳的替代匹配。

这与使用普通正则表达式或其他类型的解析器不同,即使没有给出替代选择,它们也可以返回一些标记并尝试找到替代规则。

在 Pest 中使用 PEG 声明 Rust 解析器的规则

在 Pest 中,我们使用类似于正则表达式的语法来定义规则,但也有一些差异。 让我们通过写一些例子来看看实际的语法。

任何规则的基本语法如下:

RULE_NAME = { 规则定义 }
大括号前面可以有 _ 和 @ 等符号。 稍后会解释这些符号的含义。

使用 Pest 中的内置规则匹配单个字符

引号中的任何字符或字符串都与其自身匹配。 例如,“a”将匹配 a 字符,“true”将匹配字符串 true,依此类推。

ANY 是匹配任何 Unicode 字符的规则,包括空格、换行符以及逗号和分号等符号。

ASCII_DIGIT 将匹配数字或数字字符 - 换句话说,以下字符中的任何字符:

0123456789

ASCII_ALPHA 将匹配小写和大写字母字符 - 换句话说,以下字符中的任何字符:

abcdefghijklmnopqrstuvwxyz
ABCDEFGHIJKLMNOPQRSTUVWXYZ

当然,你可以使用 ASCII_ALPHA_LOWER 和 ASCII_ALPHA_UPPER 来专门匹配各自大小写的大写和小写字符。

最后,NEWLINE 将匹配表示换行符的控制字符,例如 \n 、 \n\r 或 \r。

Pest 文档中还解释了其他几个内置规则, 你可以直接去到它的官方文档查询

请注意,上述所有规则仅匹配其类型的单个字符,而不匹配多个字符。 例如,对于输入 abcde,按照如下规则:

my_rule = { ASCII_ALPHA }

该规则将仅匹配 a 字符,输入的其余部分 - bcde - 将被传递下去以进行进一步解析。

使用重复匹配多个字符

刚刚我们介绍了如何匹配单个字符,下面我们再看看如何匹配多个字符。 在pest里有三种重要类型,我们逐一介绍。

加号 + 表示“一个或多个”。 在这里,Rust 解析器将尝试匹配至少一次出现的规则。 如果出现多次,Pest 将匹配所有这些。 如果找不到任何匹配项,则会被视为错误。 例如,我们可以这样定义一个数字:

NUMBER = { ASCII_DIGIT+ }

如果存在非数字字符(例如字母字符或符号),此规则将匹配失败。

星号 * 表示“零个或多个”。 当我们想要允许多次出现,但即使没有错误也不给出任何错误时,这很有用。 例如,当我们定义下面的列表时,第一个值后面可以有零个或多个逗号值对。

LIST = { "[" ~ NUMBER ~ ("," ~ NUMBER)* "]" }

上面的规则规定列表以 [ 开头,然后是一个 NUMBER ,之后可以有零对或多对 , 数字组。 它们被分组在括号中,并在整个括号组上加一个星号 *。 最后,列表以右括号 ] 结尾。

此规则将匹配 [0] 、 [1,2] 、 [1,2,3] 等。 然而,它会在 1 上失败,因为不存在左括号 [ ,以及 [1 因为不存在右括号 ] 。

最后打个问号? 匹配零个或一个,但不超过一个。 例如,要允许在上面的列表中使用尾随逗号,我们可以定义如下规则

LIST = { "[" ~ NUMBER ~ ("," ~ NUMBER)* ","? "]" }

此时,这个规则将允许 [1] 和 [1,] 。

除了这三个选项之外,还有其他指定重复的方法,包括允许重复固定次数、或最多 n 次、或 n 到 m 次之间的方法。 这些详细信息可以在 Pest 文档中找到,就不具体展开讲了。

隐式匹配

我们在之前规则定义中看到的 符号 ~ 是用于表示序列。 例如,A ~ B ~ C 翻译为“匹配规则 A,然后匹配 B,然后匹配 C”。

使用显式 ~ 来表示此序列是有效的,因为 ~ 符号周围存在一些隐式的空白匹配。 这是因为在许多情况下,编码规则和语法会忽略空格。 例如,if ( true) 与 if( true ) 和 if (true ) 相同。

因此,Pest 生成的解析器将自动为我们执行此操作,而不需要pest语法开发人员在每个地方手动检查这一点。 这种隐式匹配在pest语法设计过程中非常的有用。

有序选择

有些时候,你在开发pest语法的时候,可能想要表达允许与定义的规则匹配的多个规则。 例如,在 JSON 中,值可以是字符串、数组或对象,等等。

对于这种“OR”场景,我们可以使用竖线 | 来表达替代选择的想法。 象征。 上面的 JSON 概念可以写成这样的规则:

VALUE = { STRING | ARRAY | OBJECT }

这里请注意,如上所述,在之前我们谈到 PEG 特征时,讲到有序选择会在匹配到第一条规则时,就会结束匹配,不会继续有序选择包含的后续规则。 这里,我们拿个例子来具体讲解下:

RULE = { "cat" | "cataract" | "catastrophe" }

该规则永远不会匹配 cataract 和 catastrophe,因为解析器会将两者的起始 cat- 部分与第一条规则 cat 相匹配,然后它不会尝试匹配任何其他字母。 匹配 cat 后,解析器会将剩余的输入(-aract 和 -astrophe)传递到下一步,在下一步中它可能不会匹配任何内容并导致解析失败。

所以在使用有序选择时,请记住始终在开头指定最具体的规则,在结尾指定最通用的规则。 因此,上面的正确表达方式如下:

RULE = { "catastrophe" | "cataract" | "cat" }

在这里,cataract 和 catastrophe 的顺序并不重要,因为它们不是彼此的子串,并且匹配一个的输入将不会匹配另一个。 我们再看一个示例案例:

all_queues = { "queue" | "que" | "q" }

匹配"queue"及其变体 que 和 q 时使用的顺序在这里很重要,因为后面的字符串是第一个字符串的子字符串。 解析器将首先尝试将输入匹配到队列; 仅当失败时,解析器才会尝试将其与 que 匹配。 如果也失败,解析器最终将尝试匹配 q。

静默和原子规则

下面我们谈谈在pest语法中经常看到的" _" 和 "@" 符号的含义。

在 Pest 生成的解析器中,每个匹配的规则都会生成一个 Pair,其中包含匹配的输入及其匹配的规则。 然而,有时,我们可能想要匹配一些规则以确保遵循语法,但又想忽略这些规则的内容,因为它们并不重要。

在这些情况下,我们可以通过在该规则定义中的左大括号 { 之前放置下划线 _ 来将该规则表示为静默,这样就不会产生pair或者token,也就不会出现在我们的解析结果里。

这个规则最常见的用例是忽略代码中的注释。 尽管我们希望注释遵循语法约定(例如,它们必须以 // 开头并以换行符结尾),但我们不希望它们在处理过程中出现。 因此,为了忽略注释,我们可以这样定义它们:

comments = _{ "//" ~ (!NEWLINE ~ ANY)* ~ NEWLINE }

这将尝试匹配任何注释。 如果存在任何语法错误——例如,如果注释仅以一个/开头——则会产生错误。 但是,如果注释匹配成功,解析器将不会为此规则生成pair。

理论上,注释可以出现在程序的任何地方。 因此,我们需要在每一条规则的后面加上 这样一条规则“comments?”吗?是不是感觉有点太麻烦了。

好在,为了解决这个问题,Pest 提供了两个固定的规则名称——COMMENT 或 WHITESPACE——生成的解析器将自动允许这些表示的注释或空格存在于输入中的任何位置。 如果我们定义任一规则,生成的解析器将隐式检查每个 "~" 和所有重复项。

另一方面,我们并不总是想要这种行为。 例如,当我们编写需要具有特定数量空格的规则时,我们就不能让隐式规则生效。

作为解决方法,我们可以定义原子规则(不执行隐式匹配),方法是在定义规则时在左大括号 { 之前添加 at @ 或 $ 符号。 @ 使规则原子化且静默,而 $ 将使规则原子化并像任何其他规则一样生成pair。

内置输入规则的开始和结束

SOI 和 EOI 是两个特殊的内置规则,它们不匹配任何内容,而是表示输入的开始和结束。 当我们想要确保解析整个输入而不是其中的一部分,或者在规则开头允许空格和注释时,这些非常有用。

以上内容基本上涵盖了编写 PEG 规则的重要基础知识,更多内容,可以在 Pest 官方文档中找到完整的详细信息。 

 

使用 Pest 演示一个简单的解析器

使用 Pest 构建解析器,我们能够定义和解析任何语法,包括 Rust。 下面我就将构建一个解析器,该解析器模拟计算器的功能,能够实现算数的加减乘除,逻辑与,逻辑或,位于,位或等一系列运算。

首先,创建一个新项目并添加 pest 和 pest_derive 作为依赖项。

 

项目结构

 紧接着,就根据我们前面讨论过的pest的规则来定义我们的pest语法文件

 

 一旦pest语法文件生成好了,我们就可以开始通过rust来编写代码实现我们的解析器的功能

 最终我们可以看到解析器的运行结果

 完美的实现了一个计算器的基本运算功能。

作为一个pest的入门者,我从一开始的茫然无措,到逐渐的步入正轨,开始写自己的pest语法文件,到把语法文件通过rust来实现解析器的功能,中间经历了很多困难,也收获了很多的快乐,希望在未来能够和更多的志同道合的朋友们,一起在pest的道路上共同成长。