← 解析器 · 各路引擎、一门文法与编辑器要的那半边 / 把错误画成报告 待审核 22 / 25
caret 下划线 · 挂行排版 · 编辑距离

把错误画成报告

前面各页的 parser 都会给出带位置的错误:容错解析的 pos、JSON Schema 校验的 span、calc 的出错节点区间。但它们都停在同一处:手上有一个错误对象,而人要看的是一份报告。

这一步比看起来麻杂。要把区间画成下划线,需要 offset 到行列的映射、行号边栏的宽度对齐、同一行多个标注的排版、跨行区间的拆分。这些与具体语言无关,值得单独成一层。

1 · 目标形态

error[E001]: 期望 ')'
  ┌─ demo.mini:1:11
  │
1 │ x = (1 + 2;
  │     ~     ^
  │     │     │
  │     │     期望 ')'
  │     未闭合的括号在这里
  │
  = help: 在 '2' 后补一个 ')'

这个形态由 Rust 的 codespan-reportingariadne 确立,rustc 自身的诊断子系统也是同一路数。它的信息层次是:标题给结论,边栏定位到文件与行列,下划线指出证据,脚注给出下一步。

图 1-1 · 诊断渲染的三组样例:单行多 label 的挂行排版、跨行 span、以及基于编辑距离的拼写建议。渲染由 @vega/parsing/diagnosticsrender 完成;中段直接接上 recovery 一次解析报出的全部错误,逐条渲染。

2 · 渲染器的组成

offset 到行列。诊断里的 span 是字符偏移,而报告要显示行号与列号。做法是预先扫一遍源码记下每行起点,之后任一偏移用二分查找定位,单次 O(logn)O(\log n)。列号按 code point 计:按 UTF-16 code unit 计会让含 emoji 的行列号偏移,这是诊断实现里的常见缺陷。

caret 下划线。primary 标注用 ^,secondary 用 ~,两者共用同一行。跨行的 span 要拆成每行一段,且首末两行的截断位置不同。

下划线这一行还藏着第三种字符计数。列号按 code point 计是对的,render.ts 的行列映射实测无误:x = "🎈" + ; 里分号的字符偏移是 11,报出的位置是 1:11,即已按 code point 折算过。但下划线那一行是用 fill 往定长数组里按索引填空格的,索引与终端的显示宽度并不等价:emoji 与 CJK 字符各占两个终端列,而它们只算一个索引。上面那条诊断的 caret 在终端里于是落在 ; 左边一格。要对齐得引入 East Asian Width 表,按每个字符的显示宽度而非个数来填充。三层计数各有各的用途,混用任意两层都会错:offset 用于切片,code point 用于报列号,显示宽度用于画下划线。

同一行多个标注的排版。两个标注落在同一行时,它们的消息不能都写在下划线后面,那样会重叠。经典解法是「挂行」:按列位置从右到左,逐行下移地把消息挂出来,用竖线连回各自的下划线位置。图 1-1 第一个样例即此形态。

3 · 与语言无关是设计约束

渲染器只接受三样东西:源码文本、span、消息。它不认识文法、不认识 AST、不知道这是什么语言。

这条约束的收益在图 1-1 中段直接可见:容错解析报出的 { message, pos } 列表,逐条包装成 Diagnostic 后即可渲染,渲染器一行都不用改。同一层还能服务 JSON Schema 的校验错误、cron 表达式的字段错误、以及任何以后新增的格式。

定义 3.1(Diagnostic) 一条诊断由严重级别、消息、可选错误码、一组 label(每个是 span 加可选消息与主次样式)、以及一组脚注构成。所有模块的解析与求值错误都汇聚成这一种形状,而非各自定义 *Error 类型。

统一诊断类型的价值不只是省代码。它让「有多少种错误形状」这个问题的答案恒为一,于是渲染器、语言服务器协议的转换层、以及命令行的输出格式各只需写一次。反过来,各模块各自定义错误类型的项目里,每加一个模块就要在多处适配。

4 · 建议从哪来

「是不是想输入 forEach?」这类建议的底层是字符串相似度。最常用的度量是 Levenshtein 编辑距离,即把一个串变成另一个所需的最少单字符插入、删除、替换次数。

图 1-1 末段列出候选与距离:forEchforEach 距离 1,到 filter 距离 5。建议函数取距离最小且不超过阈值的候选,阈值随名字长度放宽(短名字容不下几处编辑,长名字可以)。

注 · 编辑距离只是起点。真实实现还要处理几件事:候选集怎么来(当前作用域的全部符号,可能上千个,故需先按首字符或长度粗筛)、相邻键位的替换该不该算更近(tehthe 是转置,Damerau-Levenshtein 把它算作一次操作)、以及大小写差异该不该罚分。rustc 的实现还会区分「拼写相近」与「类型相符」两类建议,后者往往更有用。

5 · 好诊断的判据

位置要指向原因,不是症状。 未闭合括号的报错位置应当是那个左括号,而不是文件末尾。这要求 parser 在下潜时记住「谁开的括号」,是 parser 的责任而非渲染器的。

消息要说该怎么办。 「syntax error」是症状,「期望 )」是原因,「在 2 后补一个 )」是行动。三者的信息量递增,而第三种往往能直接变成 quick-fix(见 局部修复 一页)。

多个错误要能分辨主次。 一处笔误常引出一串级联错误。抑制策略是必需的,局部修复 一页的注记说明了为什么来自错误恢复的节点上不该再报语义诊断。

6 · 参考文献

  1. Levenshtein, V. I. (1966). Binary codes capable of correcting deletions, insertions, and reversals. Soviet Physics Doklady, 10(8), 707–710.
  2. Barrett, B. (2018). codespan-reporting: Beautiful diagnostic reporting for text-based programming languages. Rust crate documentation.
  3. Traver, V. J. (2010). On compiler error messages: What they say and what they mean. Advances in Human-Computer Interaction, 2010, Article 602570.