← 解析器 · 各路引擎、一门文法与编辑器要的那半边 / 局部修复与 error production 待审核 21 / 25
insert / delete · quick-fix · 声明式恢复

局部修复与 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 修复。

图 1-1 · 两种恢复策略在同一段输入上的并排对照,产物均来自 @vega/parsing/recovery/repair 的真实 parse。中段把 insert 与 delete 修复应用回源码,读数条核对打过补丁的源码是否已无错,并对比两种策略救回的语句数与占位节点数。

2 · repairs 与 errors

recovery 返回 { ast, errors }repair 返回 { ast, repairs }。差别在语义:

errors repairs
内容 哪里错了 替作者改了什么
面向 人(读诊断) 工具(执行编辑)
可执行性 delete 全部可执行,insert 视 expected 而定(见下)

repairs 的可执行性是它的关键价值:每条 insertdelete 都能原样变成一次编辑操作,因此能直接驱动编辑器的 quick-fix。图 1-1 中段把全部修复应用回源码,得到的文本就是「parser 认为作者本来想写的东西」,读数条核对它是否已经无错。

「可执行」这件事在实现里是有条件的,条件藏在 expected 字段的性质上。insert 修复记的是「该位置期望什么」,而那句期望有时是字面 token、有时是给人读的描述。x 1;insertexpected'=',剥掉外层单引号即可插回源码,重新解析得零修复。x = 1 +; 报的 expected 却是「操作数(数字 / 变量 / 括号)」,这是一句中文诊断措辞,插回源码只会得到一段更长的乱码。所以可执行的实际范围是:全部 delete、以及 expected 为单个标点的那部分 insert

位置字段也不统一,写通用的应用逻辑要按 action 分支取:insertposdeleteerrorProductionstart / end。混合情形实测可以正常往返,x 1; y = = 2; 的一条 insert 加一条 delete 应用后残余修复为零。

建议 · 把修复应用回源码后重新解析,若仍有错则该修复方案不可靠。这条自检在实现容错解析器时很值得内置:它把「修复是否合理」变成一个可自动判定的性质,不必人工审阅每条修复的措辞。应用修复时须从后往前改,否则前面的编辑会移动后面所有偏移。想让这条自检真正管用,expected 一类字段就得区分「可插入的字面量」与「给人看的描述」,否则不可执行的修复会伪装成可执行的。

3 · error production:把错误写进文法

有些错误无法用「补一个」或「删一个」修好:整句从头就不像任何合法语句。@#$ x = 1; 里那段乱码不对应任何产生式的任何位置。

这时的办法是把错误本身作为一条产生式写进文法:

stmt -> ... | <任意 token 串> ';'

匹配到它就产出一个 error 语句节点,把那段内容整体吸收。这种做法称为 error production,它是声明式的:恢复行为由文法规定,而非由控制流里的补丁实现。yaccLALRPOPerror 记号就是这个机制:文法里写 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 · 参考文献

  1. 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.
  2. 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)。
  3. 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.