Markdown 不是“替换几个符号”:从语法歧义到 AST,解析器到底做了什么
Markdown 看起来像一种可以用正则表达式完成的格式:把 **text** 换成 <strong>text</strong>,把 # title 换成 <h1>title</h1>,再处理一下链接和列表,似乎就结束了。
这个思路对几十行的玩具实现有效,却很难成为可靠的解析器。真正的问题并不是“认识多少种符号”,而是:当同一个符号在不同位置承担不同角色时,解析器如何根据上下文得到唯一结构?
本文不介绍 Markdown 入门语法,而是从几个可复现实例出发,拆解 Markdown 从源文本到抽象语法树(AST),再到 HTML 的完整过程。
一段 Markdown,为什么不能从左到右直接替换
先看一个只有 14 个字符的例子:
***foo** bar*
这里有三组互相接触的 *。它至少会让人想到几种解释:
- 外层是斜体,
foo是粗体; - 外层是粗体,内部还有斜体;
- 某些星号只是普通字符;
- 从最先遇到的两个星号开始匹配,剩下的再处理。
按照 CommonMark 0.31.2,这段文本应当生成:
<p><em><strong>foo</strong> bar</em></p>
也就是下面这棵简化后的树:
document
└─ paragraph
└─ emphasis
├─ strong
│ └─ text("foo")
└─ text(" bar")
这里最重要的一点是:解析器不是把星号直接替换成标签,而是先判断每组星号能否作为开始符、结束符,或同时承担两种角色,再建立嵌套结构。
CommonMark 的强调规则不仅区分分隔符左右两边是空白、标点还是普通字符,还需要处理“三的倍数规则”、重叠优先级以及最小嵌套等问题。规则复杂并不是规范设计者故意炫技,而是因为 * 和 _ 在自然文本里本来就可能是标点、单词组成部分或格式标记。
例如:
foo_bar_baz
foo*bar*baz
a * foo bar*
下划线通常不会在单词内部触发强调,星号却可以;而最后一行中,开始星号后紧跟空格,因此它不能打开强调范围。仅靠寻找“下一枚相同字符”的算法无法稳定处理这些情况。
Markdown 解析通常分成两个阶段
CommonMark 给出了一种很有代表性的解析策略:先确定块级结构,再处理块内结构。
第一阶段:识别块级结构
解析器逐行读取输入,识别:
- 段落;
- ATX 或 Setext 标题;
- 引用块;
- 有序与无序列表;
- 缩进或围栏代码块;
- HTML 块;
- 链接引用定义。
块级标记通常比行内标记拥有更高优先级。下面的输入不是“一段含有反引号的文字”,而是两个列表项:
- `one
- two`
原因是每一行开头的 - 先被块级解析器识别为列表项边界。等行内解析开始时,块的范围已经确定,反引号不能再跨越两个列表项重写外部结构。
列表尤其容易暴露简单解析器的问题。列表项的内容缩进并不是一个永远固定的数字,它取决于列表标记本身、标记后的空格数量以及后续块的相对位置。解析器必须维护“当前打开了哪些容器”的状态,而不是只看这一行以几个空格开头。
再看一个更隐蔽的例子:
上一段没有空行
2. 这是不是列表?
在 CommonMark 中,这两行组成同一个段落。一个有序列表要在没有空行的情况下打断段落,其起始数字必须是 1。因此,把 /^\d+\. / 匹配到的每一行都当成列表项,会改变原文结构。
第二阶段:识别行内结构
块级结构稳定后,解析器才处理段落和标题内部的:
- 普通文本;
- 强调与加粗;
- 代码片段;
- 链接和图片;
- 自动链接;
- 软换行与硬换行;
- 行内 HTML。
强调符和链接括号通常会进入“分隔符栈”。解析器扫描到 *、_、[ 或 
浙公网安备 33010602011771号