Skip to content

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,而且很难自己搞清楚。

缓解措施(这些是决策的一部分,不是可选项)

  1. 一致性测试进 CI 必跑:CommonMark 官方 spec 全集 + GFM 扩展用例, 两个解析器各跑一遍,比对块级结构树。不一致即失败。

    已实现packages/export/test/parser-consistency.test.ts), 但欠了从 M3 到 M4.5 整整三档。结果:652 例中 2 例不一致,占 0.31%, 且两条都是 Lezer 与规范不符、remark 正确。 这个数字回答了本 ADR 当初悬着的那个问题 —— 双解析器的风险是真的, 但它有界,而且现在被盯住了。做法与踩到的坑见 07 §1.1。

  2. 共享语法扩展定义:自定义语法必须提供「三件套」(03 §2), Lezer 扩展与 mdast 桥接写在同一个文件里、由同一个人维护、共用同一份测试用例。 这样至少扩展语法不会两边跑偏。

  3. 已知差异显式登记:确实无法消除的差异写进 docs/known-divergences.md, 每条都要有回归用例,并在文档里对用户可见地说明。

    docs/known-divergences.md 已建立,登记了那 2 条。 回归用例是那个测试里的 KNOWN_DIVERGENCES 表,它两个方向都卡: 新差异要失败,已消失的旧差异也要失败(上游修好了就该删掉那一条)。

  4. 导出预览:导出前提供预览,让用户在结果不对时能立刻发现, 而不是发出去之后才知道。

    ❌ 未实现。导出直接落盘,没有预览步骤。

演进

首次实测(M4.5 之后):652 例中 2 例不一致,0.31%。 这个数字比当初担心的低得多, 所以下面这条退路暂时不必启动 —— 但它成立的前提是那条比对测试一直跑着。

如果一致性问题在实践中反复咬人(比如半年内出现 10 个以上的相关 issue), 候选方案是:用 Lezer 树作为唯一真相,写一个 Lezer→mdast 的完整桥接, 把 remark 降级为「只负责 mdast 之后的转换与渲染」,不再让它解析原始文本。 这样解析只有一份,生态还能用。工作量中等,是有序退路。

MIT 许可发布。与 Typora 无关联,是一个独立实现。