本文收录于专栏文章「IDE中的魔法」,希望读者能够通过这系列文章,对 IDE 相关技术的实现有一定的认知,同时,通过对语言进行静态分析,能够从编译器的视角,审视语言特性,帮助大家在了解 IDE 的同时,也能更深入的了解语言本身。
我们已经在前文中简单了解了 ANTLR 及其基本的用法,并且已经运用它进行了一个项目的初始化,现在,我们就开始正式的 parser 编写之旅:编译 Excel 的公式,并实现自动补全的功能。不过,我们实现的是稍加改动的公式,Excel 中的公式支持选择单元格的能力,即通过 A1:B2 这样的语法,表达选择了左上角为 A1,右下角为 B2 的单元格,这种能力过于具体(仅在 Excel 这样的工具中),我们实现一个更加普遍的版本,去掉单元格选择的语法,而另外支持在公式和函数调用中传入变量的形式。例如:
// 变量直接参与运算
var1 + 1
// 变量作为函数的参数使用
1 + func(var1, "test")
我们可以不用过于纠结变量从哪来,就单纯假设在我们的运行环境中,我们一定能找到的我们想要的变量,让我们把核心的关注点放在静态分析上,看一看自动补全是怎么做的,当然,第一步要解决的则是, parser 如何实现?
语法规则
常见的语法模式
得益于 ANTLR 的帮助,我们可以直接编写可读性高、易于修改的语法文件,然后通过 ANTLR 的帮助,便能得到 parser。同时,ANTLR 官方示例也提供了非常详尽的例子,包括很多语言的语法规则,阅读一些实例也可以帮助我们快速理解语法。在我们开始了解表达式的语法规则之前,我们先简单看一下几种最常见的语言模式。
序列模式
序列模式的表达就是一列元素,这也是最常见、最通用的语言表达,例如下述示例中,name 需要由 firstname 和 lastname 组成,关键字 「+」代表着一个或多个元素,而「*」则表示零个或者多个。
// name 由 firstname 和 lastname 组成
name: firstname lastname;
// 一行有一个或多个单元格组成
row: cell+;
// 年龄由数字组成,同时也可以不填年龄
age: INT*;
选择模式
一门编程语言不可能只包含一种语句,即使最简单语言也会存在需要备选分支的结构,在 ANTLR 中,我们使用「|」来表示「或者」,它用于分隔多个可选的语法结构,这些备选的语法结构被称作「备选分支」,例如:
// cell 可以由 INT 或者 STRING 组成
cell: INT | STRING;
嵌套模式
嵌套模式指的是一种自相似的语言结构,即他的子词组也遵循相同的结构。表达式语法就是典型的自相似的语言结构,我们可以直接定义递归的规则来描述这样的语言结构,即在规则定义中引用自身。例如:
// 支持加法的表达式
expression: expression '+' expression
| INT
;
处理优先级、左递归和结合性
在大致了解了最基本的语法结构后,我们还需要了解一下在表达式的语法中,有哪些需要我们关注的。我们从最简单的算术表达式语法开始,它就包含了乘法和加法运算,以及整数因子。表达式是自相似的语言结构,自然的,我们便能给出下述规则:
expression: expression '*' expression
| expression '+' expression
| INT
;
但是,这样简单的规则却是有歧义的,对于 1 + 2 * 3 这样的语句,可以有多个满足语法规则的语法树。
在一些其他的语法工具中(例如 Bison),我们需要额外的标记来制定算符优先级,而 ANTLR 则是简单地优先选择靠前的备选分支来解决歧义问题,这隐式地允许我们制定算符优先级。并且在默认的情况下,ANTLR 中所有的 token 都是左结合的,对于一些是右结合的运算,ANTLR 需要额外的标注,即通过 assoc 选项手工指定结合性,例如指数运算是右结合的:
expression: <assoc=right> expression '^' expression
| INT
;
最后需要明确的是,传统的自顶向下的语法分析生成器是无法处理左递归的,不过在 ANTLR4 中,我们可以处理直接左递归,能够编写左递归的语法对于表达式这样自相似的语法比较重要,这会让语法的可读性、可理解性提升很多,否则,表达式的语法看起来会比较冗余。不过,间接的左递归仍然是不行的,例如:
expr: expo; // 间接地左递归调用了 expr 规则
expo: expr '+' expr;
完整的公式语法规则
在我们了解完在 ANTLR 中处理表达式语法的基础性规则后,我们紧接着看一下包含函数调用的完整的公式语法,我们支持加减乘除和乘方,允许取反,也允许括号,参与表达式的因子可以是函数,常量,数字或者变量,函数则是我们熟悉的函数调用的模式,其参数可以是表达式也可以是字符串。完整的公式语法如下:
grammar Formula;
/** entry point */
start: expression EOF;
expression
: '(' expression ')'
| '-' expression
| <assoc=right> expression op='^' expression
| left=expression op=('*' | '/') right=expression
| left=expression op=('+' | '-') right=expression
| atom
;
// #functionAtom 这样的命名,可以让 ANTLR 后续在 visitor 或者 listener 中提供包含 functionAtom 的接口
atom
: function #functionAtom
| constant #constantAtom
| NUMBER #numberAtom
| VARIABLE #variableAtom
;
constant
: PI
| I
;
function
: VARIABLE '(' param (',' param)* ')'
| VARIABLE '(' ')'
;
param
: string
| expression
;
string
: '"' VARIABLE '"'
| ''' VARIABLE '''
;
// 在 ANTLR 中,大写字母开头的表示是词法规则
PI: 'pi';
I: 'i';
NUMBER: ('0' .. '9') + ('.' ('0' .. '9') +)?;
VARIABLE: VALID_ID_START VALID_ID_CHAR*;
VALID_ID_START: [a-zA-Z_];
VALID_ID_CHAR: [a-zA-Z0-9_];
// 舍弃空白字符
WS
: [ \r\n\t] + -> skip
;
调试生成 parse tree
在有了完整的语法规则后,我们只需要执行 antlr4ts -visitor Formula.g4 即可得到能够解析公式语法的 parser。而一些的场景中,我们的语法规则在开发过程中可能存在问题,需要调整,这种情况下,我们希望能够简单调试生成的 parser。最主要的,我们希望能够可视化看到 parse tree,能形象直观看到树的结构,也有助于我们理解我们的程序。
vscode-antlr4 是一个社区提供的调试 ANTLR 语法的工具,它支持可视化 parse tree、语法高亮、自动补全、断点调试等功能。为了调试我们的语法,首先,在 vscode 中搜索「ANTLR4 grammar syntax support」并安装安工具,然后在调试栏,选择「create launch.json」,并输入下述内容:
{
"version" : "2.0.0",
"configurations" : [
{
"name" : "Debug ANTLR4 grammar",
"type" : "antlr-debug",
"request" : "launch",
// 输入文件(实际用户代码)
"input" : "test.txt",
// 语法文件
"grammar" : "./Formula.g4",
// 语法入口规则,我们的公式语法入口规则是 start
"startRule" : "start",
// 是否 打印/可视化 parse tree
"printParseTree" : true,
"visualParseTree" : true
}
]
}
然后我们在 test.txt 中输入「test(var, 'str') + 2」,并调试调试工具栏中的运行,我们就可以看到如下 parse tree 的结构,并且,如果节点过多,在 vscode 中不方便查看,我们还可以把这个图导出成 svg,然后就可以详细查看这个 svg 文件。甚至,我们可以在语法中设置断点,在具体语法进行规约时,能够在断点处暂停,并提供简单上下文信息。
现在我们已经能够可视化我们正在开发的语法,这会让我们更容易发现语法本身的问题,也更容易理解 parse tree 的结构以及 parser 是如何运行的。不过,现在我们看到的都是语法完整的输入,而用户不可能一瞬间编辑完所有代码,在编辑的过程中,一定存在某些场景,此时用户输入的语法是不完整的,这就需要我们能够对错误做出相应的处理。
错误处理与恢复(Error Recovery)
在实际的使用中,用户的输入是可能出现错误的,并且,即使用户最后输入的是语法完整的代码,但是在编辑的过程中,仍然会出现不完整的情况。最常见的就是用户输入「obj.」或者「func(」的情况,而这种情况,也预期需要我们能够给出提示。这个时候就需要我们能够正常地处理错误,能够构造出包含错误信息的语法树,一个遇到错误就直接报错退出的 parser 对我们毫无用处。
错误处理
在处理错误之前,首先我们思考一个问题,不符合我们语法规则的输入到底有多少?如果我们让一只猴子来敲键盘,他敲出来的内容大概率属于不符合我们的语法规则的输入。如果我们将任意输入(猴子敲键盘)当做可接受输入的集合的全集,那符合我们语法的输入只是其中一个很小很小的子集,剩余的绝大部分都是不符合的输入。这告诉我们,Error recovery 是没有银弹的,不能指望它能帮我们处理任意的输入错误,我们也只能在错误能够匹配某些规则和形式时,才能做出相应的处理。同时,对于任意的没有规则的输入,不仅是 parser,用户也不知道这样的输入可以分析出什么结果。
不过好在,ANTLR 自动帮我们处理了大部分错误,并且在错误的场景下,我们可以得到一个包含错误信息的 parse tree。例如我们可以尝试解析「1 + + 」这样简单的语句,我们可以得到下述 parse tree:
可以看到,在两个 + 号后,在语法分析检测到词法符号缺失时,ANTLR 都为我们生成了表示 missing token 的错误节点,这被称作单词法符号补全。 我们还可以尝试解析一下「1 + 2 f」,可以看到下述 parse tree:
在输入中,f 是一个额外的变量,我们的语法并不期望在数字后直接跟一个变量,在这种情况下,ANTLR 能假设多余的词法符号不存在,并且继续完成解析。在生成的 parse tree 中,加入额外的错误节点,这个特性被称作单词法符号移除。
另外常见的错误包括:剩余的输入文本不匹配任何一个备选分支,或者是出现了词法错误,用户输入了无法识别的词法符号等等,ANTLR 也都自动提供了良好的错误处理能力。另一方面,错误处理对应着的配套能力就是错误恢复,在出现语法错误时,语法分析工具没有在遇到错误时就直接结束当前语法的分析(我们能看到带有错误信息的语法节点),而是尽最大可能匹配到一个合法的语法规则,并继续后续的匹配。那么 ANTLR 是如何从错误中恢复到正常的语法规则匹配中呢?
错误恢复
错误恢复是允许语法分析器在发现语法错误后还能继续的机制。简而言之,当语法分析器遇到不能匹配的词法符号时,它不仅需要报告错误,还需要尝试一些策略尽可能从错误状态中恢复,以便后续的词法符号流能够正常进行匹配。由于错误的可能性理论上是无限的,这也是没有银弹的领域,最好的错误恢复的控制来自手工编写的语法分析器中的直接干预。但这是一个非常困难并且枯燥的事情,很多时候必须 case by case 的迭代。好在,ANTLR 提供了一个良好的错误恢复机制,在大部分情况下都可以良好地完成工作。
ANTLR 的错误恢复策略大致如下:在语法分析器遇到无法匹配的词法符号时,它首先执行单词法符号移除和单词法符号补全。如果这些方案不奏效,语法分析器继续向后查找词法符号,直到它遇到一个符合当前规则的后续部分的合理词法符号为止,这个过程被称作同步-返回策略。之后,语法分析器会继续语法分析过程,仿佛什么错误都没有发生过一样。
同步-返回策略(sync and return)
当单词法符号移除和补全不起作用时,语法分析器会向后查找词法符号,直到它认为已经完成重新同步时,他就返回原先被调用的规则。这就是所谓 「同步-返回」。我们可以通过简单的示例来形象理解。
每个 ANTLR 自动产生的规则都被包裹在一个 try-catch 块内,它应对语法错误的措施都是报告错误并执行恢复,它们形似如下代码:
try {
// match lexer
matchToken1()
matchToken2()
matchToken3()
}catch (err) {
reportError(err)
recover(err)
}
在出现语法错误时,比如我们在 matchToken2 时匹配失败,并且单词法符号移除和补全也不成功,此时, matchToken2 抛出异常并被我们捕获。recover 开始尝试恢复,它会持续消费后续的词法符号,直到发现重新同步集合中的词法符号为止。重新同步集合是调用栈中所有规则的后续符号集合(following set)的并集。一条规则的后续符号集合则是指可以无需离开正在匹配的规则,并能立即开始重新匹配的词法符号集合。例如:如果语法的一份备选分支是 expression ';',那么规则引用 expression 的后续符号集合就是 {';'}。可以通过一个更详细的例子理解后续符号集合,例如下述语法规则:
group
: '[' expr ']' // expr 规则引用的后续词法符号集合:{']'}
// 注意左侧是中括号,右侧是大括号
| '[' expr '}' // expr 规则引用的后续词法符号集合:{'}'}
| '(' expr '}' // expr 规则引用的后续词法符号集合:{')'}
expr: atom '+' INT; // atom 规则引用的后续词法符号集合:{'+'}
atom: INT | ID;
INT: [0-9]+;
ID: [a-zA-Z]+;
假设此时我们只输入了一个 "[a",我们看一下此时语法分析器内部是如何工作的,可以尝试构建一下此时的 parse tree 如下图:
后续符号集合就是调用栈([group, expr, atom])内,紧跟在被其调用的规则后面的词法符号的集合,在上图中,就是 atom 的后续符号集合 {'+'}、expr 的后续符号集合 {']'},{'}'} 的并集。注意,由于后面的词法符号我们还不知道,目前 「'[' expr ']'」和 「'[' expr '}'」都是可选的规则,此时我们的后续符号集合就是 {'+', ']', '}'}。
由于语法分析也是一个 token 一个 token 逐个去消费,我们可以形象地用我们继续往后增加输入来比喻这个过程。假设在 [a 的基础上,我们又输入了数字 1,此时我们发现出错了,INT 的输入不满足任何当前可以匹配到的规则,我们开始 recovery,然后我们继续输入,直到我们输入到了 '+',']','}' 这三个符号中的一个,或者输入了文件结尾标识 EOF。此时,我们按照可用的语法规则规约,并插入表示错误的语法节点(中间被丢弃的词法符号)。完成规约后,后续的语法分析就能照常进行。
写在最后
本文主要介绍了 ANTLR 中语法规则的模式和需要注意的问题,同时给出了我们要实现的公式的完整语法规则,最后介绍了错误处理与恢复。本文的侧重点在语法分析与处理,也就是在得到 AST 之前值得我们关注的一些事情。而自动补全所需要的静态分析我们会在后续文章中单独介绍。另外,如果你想要对 ANTLR 有更深入的了解,可以阅读《ANTLR 4 权威指南》,这是 ANTLR 的作者亲自写的,本文中很多地方也参考了这本书。