局部修复与 error production
上一页的 panic-mode 有一个粗糙之处:它的恢复单位是「丢到下一个同步点」。x 1; 只是漏了一个等号,却会让整条语句作废。
更细的策略是在失配的那一点上动手:假设作者少写了或多写了一个 token,替他补上或删掉,然后继续解析。这类做法称为 phrase-level 修复,对标 rustc 与 Roslyn 的诊断修复机制。
1 · insert 与 delete
失配时只需判断一件事:删掉当前 token 能不能对上。
insert:当前 token 是后续语法需要的,缺的是它前面那个。例如 x 1; 在读到 1 时期望 =,而 1 本身是合法的表达式起始,于是虚构插入一个 =,记一条 insert 修复。
delete:当前 token 多余,删掉之后就能对上。例如 x = = 1; 的第二个 =:删掉它,后面的 1 刚好是期望的操作数,记一条 delete 修复。
@vega/parsing/recovery 与 /repair 的真实 parse。中段把 insert 与 delete 修复应用回源码,读数条核对打过补丁的源码是否已无错,并对比两种策略救回的语句数与占位节点数。2 · repairs 与 errors
recovery 返回 { ast, errors },repair 返回 { ast, repairs }。差别在语义:
| errors | repairs | |
|---|---|---|
| 内容 | 哪里错了 | 替作者改了什么 |
| 面向 | 人(读诊断) | 工具(执行编辑) |
| 可执行性 | 否 | delete 全部可执行,insert 视 expected 而定(见下) |
repairs 的可执行性是它的关键价值:每条 insert 或 delete 都能原样变成一次编辑操作,因此能直接驱动编辑器的 quick-fix。图 1-1 中段把全部修复应用回源码,得到的文本就是「parser 认为作者本来想写的东西」,读数条核对它是否已经无错。
「可执行」这件事在实现里是有条件的,条件藏在 expected 字段的性质上。insert 修复记的是「该位置期望什么」,而那句期望有时是字面 token、有时是给人读的描述。x 1; 报 insert 且 expected 为
'=',剥掉外层单引号即可插回源码,重新解析得零修复。x = 1 +; 报的 expected 却是「操作数(数字 / 变量 / 括号)」,这是一句中文诊断措辞,插回源码只会得到一段更长的乱码。所以可执行的实际范围是:全部 delete、以及 expected 为单个标点的那部分
insert。
位置字段也不统一,写通用的应用逻辑要按 action 分支取:insert 用 pos,delete 与 errorProduction 用 start / end。混合情形实测可以正常往返,x 1; y = = 2; 的一条 insert 加一条 delete 应用后残余修复为零。
建议 · 把修复应用回源码后重新解析,若仍有错则该修复方案不可靠。这条自检在实现容错解析器时很值得内置:它把「修复是否合理」变成一个可自动判定的性质,不必人工审阅每条修复的措辞。应用修复时须从后往前改,否则前面的编辑会移动后面所有偏移。想让这条自检真正管用,expected
一类字段就得区分「可插入的字面量」与「给人看的描述」,否则不可执行的修复会伪装成可执行的。
3 · error production:把错误写进文法
有些错误无法用「补一个」或「删一个」修好:整句从头就不像任何合法语句。@#$ x = 1; 里那段乱码不对应任何产生式的任何位置。
这时的办法是把错误本身作为一条产生式写进文法:
stmt -> ... | <任意 token 串> ';'
匹配到它就产出一个 error 语句节点,把那段内容整体吸收。这种做法称为 error production,它是声明式的:恢复行为由文法规定,而非由控制流里的补丁实现。yacc 与 LALRPOP 的 error 记号就是这个机制:文法里写
error 即表示「此处允许出现错误,吸收到某个同步 token 为止」。
它与前两种修复的区别在于不猜作者的意图。insert 与 delete 都是假设「作者少写/多写了一个 token」,而 error production 只是划出一块区域标为损坏,不产生可执行的编辑,只产生一个 error 节点。图 1-1 的修复列表把这一条标成不可执行。
4 · 按错误规模分层
三者并非替代关系,它们按错误的规模分层:
- 句内单 token 失配 → insert / delete,保住整条语句的结构;
- 句内多处或无法定位 → panic-mode 丢到同步点,牺牲这一句;
- 整句无法起头 → error production,把这段声明式地吸收成一个节点。
真实实现通常三者都要,按上述顺序尝试。rustc 的诊断系统在此之上还有一层:它维护一组针对常见笔误的启发式规则(把 = 误写成 ==、忘记 &、类型名拼错),并把建议以「是不是想写……」的形式给出,那部分的相似度计算见
把错误画成报告 一页的编辑距离。
5 · 修复不该改变语义判断
一条容易踩的坑:修复只应影响解析,不应影响后续的语义判断。
若 parser 替作者补了一个 =,那么类型检查器在这条语句上报出的任何错误都可能是虚构的:它检查的是 parser 编出来的代码,而非作者写的代码。工程上的处理是给修复过的节点打标记,语义阶段遇到标记就跳过或降级报告。
警示 · 忽略这一点会产生「级联幻觉」:一处笔误引出十几条毫不相关的类型错误,把真正的问题埋掉。TypeScript 与 rustc 都有专门的抑制机制:一旦某个节点来自错误恢复,其子树上的语义诊断一律不报。判断标准很直接:这条诊断的证据里是否包含 parser 虚构出来的 token。
6 · 参考文献
- Burke, M. G., & Fisher, G. A. (1987). A practical method for LR and LL syntactic error diagnosis and recovery. ACM Transactions on Programming Languages and Systems, 9(2), 164–197.
- Aho, A. V., Lam, M. S., Sethi, R., & Ullman, J. D. (2006). Compilers: Principles, Techniques, and Tools (2nd ed.). Addison-Wesley. §4.8.3(error production)。
- de Jonge, M., Kats, L. C. L., Visser, E., & Söderberg, E. (2012). Natural and flexible error recovery for generated modular language environments. ACM Transactions on Programming Languages and Systems, 34(4), Article 15.