5.5 语法分析器错误恢复
真实源码会处于编辑中、尚未完成,也会出现多处错误。第一个异常词法单元(token)就停止的语法分析器(parser)容易实现,却无法很好服务 IDE、批处理编译或学习者;任意猜测的语法分析器虽能继续,却可能虚构出误导性的程序。错误恢复是在两者之间的严格方案:在最早可靠的位置发现失败,带上下文报告它,有边界地前进到安全点,并保留足够结构以便后续诊断,同时不假装程序正确。
恢复不是后加的补丁,它会影响词法单元 API、AST 类型、语法分析器控制流、测试方式,以及用户对编译器消息的信任。
在预期最明确的地方发现错误
最好的 parse error 应靠近被违反的文法义务:
let total = 1 + ;
^ expected expression after '+'这比后来才出现的“unexpected }”好得多。语法分析器知道自己刚消费了 +,下一步应解析表达式。把这种上下文编码进 expect 与表达式 parselet。
高质量诊断通常包括:
- 主要源码范围,最好指向意外词法单元或缺失词法单元的插入位置。
- 用语言概念而非内部非终结符名描述的预期内容。
- 有帮助时说明实际遇到的词法单元。
- 置信度足够高时给一个很小的修复建议或 note。
应把错误收集进列表,而不是第一条就丢掉全部状态。但要限制总诊断数量,并保证每个恢复循环至少消费一个词法单元。在同一游标上反复“恢复”的错误处理,本质上是一个换了好听名字的无限循环。
在文法边界同步
panic-mode recovery 丢弃词法单元,直到遇到一个可能结束当前损坏结构或开启新结构的同步词法单元。只要选择依赖文法,它简单且可靠。
对语句导向语言,可以先消费至少一个词法单元,然后寻找 ;、}、EOF,或能开启新语句的 let、if、while、print:
synchronizeStatement() {
advance(); // 错误后保证前进
while (!at(EOF)) {
if (previous().kind == SEMICOLON) return;
if (at(RBRACE) || startsStatement(peek().kind)) return;
advance();
}
}这个函数必须依赖上下文。在调用内解析表达式时,遇到 , 或 ) 就同步往往合理;若一直丢到 ;,可能会毁掉剩余实参,并制造一串器声错误。FIRST/FOLLOW 集可以提供有原则的候选:失败非终结符的 FOLLOW 词法单元可能是返回控制的位置,外层兄弟的 FIRST 词法单元可能开启一个新结构。
panic mode 不应是唯一工具。phrase-level recovery 可以做局部、明确的修复:在 } 前插入缺失的 ; 而不消费该 brace,或在上下文几乎确定时删除一个多余 ,。这些修复必须很窄、记录在诊断中,绝不能悄悄改变语义。
保留有用的部分 AST
后续前端(frontend)阶段需要知道这里已经发生过错误。语法分析器可以返回携带源码范围与恢复信息的 ErrorExpr、ErrorStmt 或缺失词法单元节点。这样它还能构造外围代码块或声明,同时阻止类型检查器基于虚构表达式继续制造无意义错误。
let x = 1 + ;
print x;
Block([
LetStmt(x, Binary(Number(1), +, ErrorExpr(at:semicolon))),
PrintStmt(Name(x))
])目标不是完美 AST,而是让树足够稳定,以服务工具链,并让诊断始终锚定最初的语法问题。下游趟(pass)应识别错误节点,避免继续报“unknown type”“undefined variable”这类没有新行动价值的连锁消息。
用恶意输入测试恢复:缺少分隔符、重复运算符、嵌套错误、构造中途 EOF,以及每个错误后的有效声明。既要断言诊断的位置,也要断言语法分析器确实向前推进。恢复质量是可观察行为,编译器测试应把它当成正式契约。
恢复要保住下一个有用边界
对 let x = 1 + ; print x;,先报 + 后缺表达式,再在 ; 同步,仍可解析 print x;。但在 f(1, , 3) 中,若跳到 ; 会毁掉调用上下文,, 或 ) 才是更好的表达式级边界。同步词法单元由当前文法上下文决定,不是一张全局名单。
error node 应带失败 range 并让外围 tree 存在;下游 pass 必须识别它、压制无价值的连锁噪声。目标是每个根因给一条可行动消息,不是虚构完美修复后的程序。