ADR-0003 · 编辑解析器与语义解析器分离
- 状态:已接受
- 日期:设计阶段
背景
编辑器需要解析 Markdown 的场景有两类,需求截然不同:
编辑期(每次按键都要跑)
- 必须增量:只重解析受影响的片段,否则大文档输入必卡
- 必须容错:文档在输入过程中大部分时间是语法不完整的
- 需要精确的字符偏移,用于放置装饰
- 不需要语义结构,只需要「这段是什么类型」
语义期(导出、大纲、跨文档分析时跑)
- 需要完整的语义树(列表项的层级、链接的解析后 URL、脚注的定义与引用配对)
- 需要丰富的转换生态(unified/remark 插件)
- 可以慢,可以在后台进程跑
决策
用两个解析器:
- 编辑期:
@lezer/markdown(CodeMirror 6 的 Markdown 语言实现基于它,天然增量、容错) - 语义期:
remark/ mdast(unified 生态,插件最丰富,导出链路成熟)
被否决的选项
单用 Lezer,自己在 Lezer 树上写导出 省一个依赖,但要自己实现 mdast 生态里已有的一切(脚注配对、目录生成、 链接引用解析、mdast→hast 转换、各种 remark 插件的能力)。工作量巨大且长期维护成本高。
单用 remark,每次按键全量解析 1k 行文档全量解析约几毫秒尚可,10k 行就撑不住 16ms 预算了。 且 remark 不给字符级的容错语法树,装饰放置会很别扭。
自己写一个同时满足两边的解析器 理论最优,实际上是把项目的主要精力从「编辑体验」转移到「写解析器」。 CommonMark 的边角情况极多,重写一个能过全部 spec 用例的解析器是数月的工作, 而且要长期跟进规范演进。不值得。
风险:两个解析器可能不一致
这是本决策唯一真正的风险。两者对同一段文本可能给出不同解读, 尤其在 CommonMark 的这些阴暗角落:
- 惰性延续(lazy continuation)的引用块
- 列表项内容的缩进判定
- HTML 块的 7 种结束条件
- 分隔符(
*/_)的左右侧规则 - 表格行与后续段落的边界(GFM)
不一致的具体后果:编辑器把某段显示成引用块(Lezer 判定), 导出的 HTML 里却是普通段落(remark 判定)。用户会认为是 bug,而且很难自己搞清楚。
缓解措施(这些是决策的一部分,不是可选项)
- 一致性测试进 CI 必跑:CommonMark 官方 spec 全集 + GFM 扩展用例, 两个解析器各跑一遍,比对块级结构树。不一致即失败。
- 共享语法扩展定义:自定义语法必须提供「三件套」(03 §2), Lezer 扩展与 mdast 桥接写在同一个文件里、由同一个人维护、共用同一份测试用例。 这样至少扩展语法不会两边跑偏。
- 已知差异显式登记:确实无法消除的差异写进
docs/known-divergences.md, 每条都要有回归用例,并在文档里对用户可见地说明。 - 导出预览:导出前提供预览,让用户在结果不对时能立刻发现, 而不是发出去之后才知道。
演进
如果一致性问题在实践中反复咬人(比如半年内出现 10 个以上的相关 issue), 候选方案是:用 Lezer 树作为唯一真相,写一个 Lezer→mdast 的完整桥接, 把 remark 降级为「只负责 mdast 之后的转换与渲染」,不再让它解析原始文本。 这样解析只有一份,生态还能用。工作量中等,是有序退路。