系统指南
这是 Lezer 解析器系统的指南。它提供了系统功能的文字描述。有关其接口的逐项文档,请参阅参考手册。
概览
Lezer 是一个用 JavaScript 编写的解析器系统。它可以根据语法的形式描述生成一组解析表。这些表提供了一个描述,解析器系统可以使用它有效地为给定的文本构建语法树,描述文本的结构是如何根据语法组织的。
这些表是由 @lezer/generator 工具生成的,这是一个构建工具,它接受本指南后面描述的格式的文件,并输出一个大型、大多数情况下难以阅读的 JavaScript 代码块,代表了解析表。这是离线发生的事情,作为语法包的构建过程的一部分。
@lezer/lr 包提供了运行时解析系统。结合由生成器构建的解析器,它为你提供了一个可以接受源文件并返回树的解析器对象。
这些树,由 @lezer/common 包中的数据结构表示,比你可能在其他上下文中看到的抽象语法树更有限。它们并不是很抽象。每个节点只存储一个类型、起始和结束位置,以及一系列子节点。在编写语法时,你选择哪些产生式存储为节点——其他的树根本不出现。
这意味着树在内存中非常紧凑,构建起来成本低廉。但这确实使得对其进行精细分析有些笨拙。引导此库设计的用例是编辑器系统,它保持编辑文档的语法树,并用它来做诸如语法高亮和智能缩进之类的事情。
为了支持这种用例,解析器还有一些其他有趣的属性。它可以增量使用,这意味着它可以有效地重新解析与之前版本相比略有变化的文档,给出了旧版本的解析。它还内置了错误恢复,这意味着即使输入不符合语法,它仍然可以为它生成一些合理的语法树。
这个系统的方法受到 tree-sitter(一个用 C 和 Rust 编写的类似系统)的极大影响,以及 Tim Wagner 和 Susan Graham 关于增量解析的几篇论文(1, 2)。它之所以作为一个不同的系统存在,是因为它与 tree-sitter 有不同的优先级——作为 JavaScript 系统的一部分,它用 JavaScript 编写,库和解析器表的大小相对较小。它还生成了更紧凑的内存树,以避免对用户机器造成过多压力。
解析器算法
Lezer 基于 LR 解析,这是 Donald Knuth 在 1965 年发明的一种算法,通过预先分析语法,可以为某些语法派生出完全确定性(因此高效)的解析器。
大致来说,这种算法抽象地解释语法,记录解析器可以处于的各种状态,并为每个状态创建一个表,映射终端符号(令牌)到在该状态下看到该令牌时应采取的动作。如果在给定状态下对于给定令牌有多于一个的动作要采取,则该语法无法用此算法解析。这样的问题通常称为“移入-规约”或“规约-规约”冲突。稍后会有更多关于这一点的讨论。
为 LR-based 工具编写语法时,对这种算法有一个大致的感觉是有帮助的。上面链接的维基百科文章是一个很好的入门读物。对于更深入的处理,我推荐这本书的第9章(PDF)。
歧义
许多语法要么不可能,要么非常笨拙地表示为纯粹的 LR 语法。如果一个元素的句法角色只有在解析的后期才变得清晰(例如 JavaScript 的 (x = 1) + 1 与 (x = 1) => x,其中 (x = 1) 可以是表达式或参数列表),纯粹的 LR 往往不是很实用。
GLR 是解析算法的一个扩展,它允许在一个模糊点上解析“分裂”,通过对给定令牌应用多于一个动作。然后,备选解析并行继续。当一个解析不能再取得任何进展时,它就被放弃。只要至少有一个解析继续,我们就可以得到我们的语法树。
GLR 在处理局部歧义时可能极为有用,但如果盲目应用,很容易导致并行解析的爆炸,当语法实际上是模棱两可的,多个解析无限期地继续,以至于你实际上在同时多次解析文档。这完全破坏了使 LR 解析有用的属性:可预测性和效率。
Lezer 允许 GLR 解析,但要求你在语法中明确标注允许发生的地方,这样你就可以用它来解决原本困难的问题,但它不会在你的语法中偶然发生。
Lezer 中的解析状态分裂经过了优化,所以虽然比只做线性解析更昂贵,但你可以在语言中常见的构造中有歧义,仍然拥有一个快速的解析器。
错误恢复
尽管解析器有严格模式,但默认情况下,它会处理任何文本,无论它与语法的匹配程度有多差,并最终生成一个树。
为此,它使用 GLR 机制尝试各种恢复技巧(忽略当前令牌或跳过到与当前令牌匹配的位置),并行观察哪种方式在接下来的几个令牌中获得最好的结果。
被忽略的令牌被添加到树中,包裹在一个错误节点中。类似地,解析器跳过的位置也用一个错误节点标记。
增量解析
为了避免重复工作,解析器允许你提供一个树片段的缓存,它保存了前一次解析产生的树的信息,并注明了此后发生的文档变化。在可能的情况下,解析器会重用此缓存中的节点,而不是重新解析它们覆盖的文档部分。
因为语法树将重复操作符(在语法表示中用 + 和 * 指定)的匹配序列表示为平衡子树,所以重新匹配未更改部分的文档的成本很低,即使是大型文档,你也可以快速创建新树。
不过,这并不是万无一失的——即使是很小的文档变化,如果它改变了之后内容的含义,也可能需要重新解析大部分文档。例如,添加或移除块注释的开头标记。
上下文化词法分析
在传统的解析技术中,词法分析器(将输入分割成一系列原子令牌的工具)和解析器之间有严格的分离。
但这种分离有时会带来问题。有时,文本的含义取决于上下文,例如 JavaScript 的正则表达式符号和除法操作符的歧义。在其他情况下,语法中使用的子语言(比如字符串的内容)对于什么是令牌的概念与语法的其余部分不同。
Lezer 支持上下文化的令牌读取。它允许你声明的令牌重叠(匹配相同的输入),只要这样的令牌在语法中的任何地方都不会同时出现。
你还可以定义外部词法分析器,这将导致解析器调用你的代码来读取令牌。这样的词法分析器再次只在它们产生的令牌在当前位置适用时被调用。
甚至空白(解析器隐式跳过的令牌类型)也是上下文化的,你可以有不同的规则跳过不同的内容。
编写语法
Lezer 的解析器生成器定义了自己的语法表示法。你可以查看 JavaScript 语法的例子。
语法是规则的集合,它定义了术语。术语可以是令牌,在这种情况下它们直接匹配输入文本的一部分;或者是非终结符,匹配由其他术语组成的表达式。令牌和非终结符都使用类似的语法定义,但它们是明确区分的(令牌必须出现在 @tokens 块中),并且令牌在它们可以匹配的内容上更有限制——例如,它们不能包含任意递归。
从形式上讲,令牌必须匹配一个正则语言(大致是基本正则表达式可以匹配的东西),而非终结符可以表示一个上下文无关的语言。
一个简单语法的示例:
lessCopy code
@top Program { expression }
expression { Name | Number | BinaryExpression }
BinaryExpression { "(" expression ("+" | "-") expression ")" }
@tokens {
Name { @asciiLetter+ }
Number { @digit+ }
}
这可以匹配像 (a+1) 或 (100-(2+4)) 这样的字符串。它不允许令牌之间有空格,二元表达式周围的括号是必需的,否则它会因为歧义而报错。
@top 定义了语法的入口点。这是用于匹配整个输入的规则。它通常包含某种重复的内容,如 statement+。
你会注意到示例中有些术语以小写字母开头,有些则以大写字母开头。这种区别是重要的。大写的规则会在解析器产生的语法树中显示为节点,小写规则则不会。
(如果你在没有大小写的脚本中编写规则名称,可以在名称开头使用下划线以指示该规则不应出现在树中。)
运算符
语法表示法支持重复运算符 *(表示前面的内容的任意次数重复)、+(一次或多次重复)和 ?(可选,零次或一次重复)。这些具有高优先级,只适用于它们前面的直接元素。
管道 | 字符用于表示选择,匹配其两侧的任一表达式。当然,它可以重复以表示超过两种选择(x | y | z)。选择具有所有运算符中最低的优先级,如果没有括号,它将适用于左右两边的整个表达式。
上下文无关语法中的选择是可交换的,这意味着 a | b 与 b | a 完全等价。
括号可用于分组,如 x (y | z)+。
事物的序列通过简单地将它们放在一起来表示。a b 意味着 a 后面跟着 b。
令牌
命名令牌在 @tokens 块中定义。你也可以在令牌块之外使用像 "+" 这样的字符串字面量作为令牌。这些会自动定义一个新令牌。字符串字面量使用与 JavaScript 字符串相同的转义规则。
令牌规则内的字符串字面量的工作方式不同。它们可以与其他表达式组合以形成更大的令牌。所有令牌规则(和字面令牌)都被编译为一个确定性有限自动机,然后可以用它们有效地匹配文本流。
因此,非终结规则中的表达式 "a" "b" "c" 是三个令牌的序列。在令牌规则中,它与字符串 "abc" 完全等价。
你可以使用集合表示法来表达字符集,有点类似于正则表达式中的方括号表示法。$[.,] 表示句号或逗号(在此表示法中,. 没有特殊含义,转义字符如 \s 也是)。$[a-z] 匹配 a、z 以及 Unicode 字符代码顺序中介于它们之间的任何字符。要创建一个反向字符集,仅匹配未在集合中提及的字符,你在括号前写一个感叹号而不是美元符号。因此 ![x] 匹配任何非 x 的字符。
解析器生成器定义了一些内置字符集,可以通过 @ 表示法访问:
@asciiLetter 匹配 $[a-zA-Z] @asciiLowercase 匹配 $[a-z] @asciiUppercase 等价于 $[A-Z] @digit 匹配 $[0-9] @whitespace 匹配 Unicode 标准定义的任何空白字符。 @eof 匹配输入的结尾 令牌规则不能引用非终结规则。但它们可以相互引用,只要引用不形成非尾递归循环。即规则 x 不能直接或间接包含对 x 的引用,除非该引用出现在规则的最末尾。
跳过表达式
我们最初的示例不允许令牌之间有任何空白。几乎所有真实语言都定义了某种特殊令牌,通常涵盖空格和注释,它们可能出现在其他令牌之间,并在匹配语法时被忽略。
要支持空白,你必须在你的语法中添加一个 @skip 规则。
@skip { space | Comment }
你可以像这样定义 space 和 Comment 令牌:
@tokens {
space { @whitespace+ }
Comment { "//" ![\n]* }
// ...
}
跳过规则将在其他令牌之间匹配零次或多次。因此,上面的规则也处理了长序列的注释和空白。
跳过的令牌可能是大写的(如 Comment),在这种情况下,它们将出现在语法树中。允许跳过表达式匹配比单个令牌更复杂的东西。
当你的语法需要多于一种空白时,例如当你的字符串不是简单的令牌而需要它们自己的内部结构,但你不希望在字符串片段之间匹配空白和注释时,你可以创建一个跳过块,如下所示:
@skip {} {
String {
stringOpen (stringEscape | stringContent)* stringClose
}
}
初始大括号包含跳过表达式——在这个例子中我们想跳过的内容为空,所以它是空的——第二对大括号包含使用此跳过表达式的规则。
解析状态只能与它们关联一组跳过表达式(否则在该状态下不清楚该跳过什么)。这意味着,如果像上面这样具有自定义跳过表达式的规则在另一个跳过上下文中使用,必须在两侧明确界定。例如,它不能在末尾有一个可选或重复的术语,因为在该术语可能跟随或不跟随的点上不清楚该跳过什么。
------------ 后续更新