迷你拉取式解析器
主线位置:块主线和行内主线的缩小版。块主线缩成「每次
next发现队列空了,就读一个块」;行内主线缩成「把块里的文字切成文本、代码段和分隔符,再用分隔符栈配对」。输出主线不做,停在事件上。每一步都对应前面讲真实实现的某一页,本页讲思路和关键片段,并说明 pulldown-cmark 在同一步多做了什么。
flowchart TB
nx["next"] --> q{"队列空了"}
q -->|否| pop["弹出一个事件"]
q -->|是| pb["parse_block"]
pb --> h["ATX 标题"]
pb --> p["段落"]
h --> pi["parse_inline"]
p --> pi
pi --> cut["切成节点"]
cut --> pe["process_emphasis"]
pe --> fill["事件进队列"]
fill --> pop
代码在 exp-pulldown-cmark/design-mini:src/lib.rs 共 335 行(去掉空行和注释 294 行),没有任何依赖;pulldown-cmark =0.13.5 只作为 dev-dependency 出现在测试里。本页摘关键片段,完整实现放在对应课程里。平台 M2 Max、macOS 27、rustc 1.99.0。
它支持的语法:
- 块:段落、ATX 标题(
#到######,可带结尾的#串)。其他一切非空行都算段落的一部分。 - 行内:
*/**强调、代码段、反斜杠转义、软换行、硬换行(行尾两个以上空格,或反斜杠加换行)。_当普通文字。
事件
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tag {
Paragraph,
Heading(u8),
Emphasis,
Strong,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event<'a> {
Start(Tag),
End(Tag),
Text(Cow<'a, str>),
Code(Cow<'a, str>),
SoftBreak,
HardBreak,
}
和 pulldown-cmark 的 Event 同一个形状:成对的 Start / End,中间是叶子事件,文本借用输入。两处简化:End 直接带 Tag,不像上游另有一个 2 字节的 TagEnd(事件模型);字符串用标准库的 Cow<'a, str>,拥有的那一支是 24 字节的 String,放不下内联的短串(CowStr)。这个子集里只有跨行的代码段需要拥有字符串。
块:一次读一个块
pub struct Parser<'a> {
src: &'a str,
pos: usize,
queue: VecDeque<Event<'a>>,
}
impl<'a> Iterator for Parser<'a> {
type Item = Event<'a>;
fn next(&mut self) -> Option<Event<'a>> {
if self.queue.is_empty() && !self.parse_block() {
return None;
}
self.queue.pop_front()
}
}
Parser::new 什么也不做。第一次 next 时队列是空的,parse_block 从 pos 开始读一个块,把这个块的全部事件(Start、行内事件、End)推进队列,后面的 next 依次弹出,弹空了再读下一个块:
fn parse_block(&mut self) -> bool {
while let Some((line, next)) = line_at(self.src, self.pos) {
if is_blank(line) {
self.pos = next;
continue;
}
if let Some((level, content)) = atx_heading(line) {
self.pos = next;
self.push_block(Tag::Heading(level), content);
return true;
}
// A paragraph runs until a blank line or a heading.
let start = self.pos + (line.len() - line.trim_start_matches([' ', '\t']).len());
let mut end = self.pos + line.len();
self.pos = next;
while let Some((line, next)) = line_at(self.src, self.pos) {
if is_blank(line) || atx_heading(line).is_some() {
break;
}
end = self.pos + line.len();
self.pos = next;
}
let content = self.src[start..end].trim_end_matches([' ', '\t']);
self.push_block(Tag::Paragraph, content);
return true;
}
false
}
段落的内容是原文里的一段连续切片,续行的缩进留在里面,交给行内那一步跳过;这样文本事件能一直借用输入。标题的识别在 atx_heading 里:至多 3 个空格缩进、1 到 6 个 #、后面是空格、制表符或行尾;结尾的 # 串前面必须有空白才算收尾(# foo# 的标题文字是 foo#)。规则对应 CommonMark 4.2 节,pulldown-cmark 的实现见叶子块。
这里和 pulldown-cmark 最大的不同是:块级也是按需的。 pulldown-cmark 在 Parser::new_ext 里就跑完了整份文档的第一遍(一行一行),因为链接引用定义可以写在后面、列表的松紧要看到末尾(为什么是 pull parser)。这个子集里没有这两样,所以不需要先扫完全文。代价和收益量在设计取舍:同一份 1.38 MB 的文档,第一个事件 0.0003 ms 对 6 ms,全部事件 12.8 ms 对 13.3 ms。
问: 既然是「拉取式」,为什么还要把一个块的事件先攒进 VecDeque,不能扫到哪吐到哪吗?
答: 强调要看到后面才能定。*foo bar* 里第一个 * 是不是强调的开始,要等扫到第二个 * 才知道;开分隔符可能要到下一行甚至更后面才配上(spec 例 405 的 *foo\nbar*)。行内语法不跨块,所以以块为单位攒是最小的单位。pulldown-cmark 也是这样:handle_inline 一次处理当前块剩下的整条兄弟链(按需解析),只不过它把结果写回树上的节点,不另开队列。
行内:先切成节点
parse_inline 扫一遍块的内容,切出四种东西:文本、代码段、换行、* 串。前三种直接是事件,* 串先当成「分隔符」节点,等配对完再决定它变成标签还是文字:
struct Delim<'a> {
run: &'a str,
count: usize,
used_left: usize,
can_open: bool,
can_close: bool,
ends: Vec<Tag>,
starts: Vec<Tag>,
}
enum Node<'a> {
Event(Event<'a>),
Delim(Delim<'a>),
}
扫描是一个按字节的 match,这里摘反引号、星号和换行三支:
b'`' => {
let n = run_len(b, i, b'`');
if let Some(close) = find_backticks(b, i + n, n) {
push_text(s, text_start, i, &mut nodes);
nodes.push(Node::Event(Event::Code(code_content(&s[i + n..close]))));
i = close + n;
text_start = i;
} else {
i += n; // no closer: the backticks stay in the text
}
}
b'*' => {
let n = run_len(b, i, b'*');
push_text(s, text_start, i, &mut nodes);
let (can_open, can_close) = flanking(s[..i].chars().next_back(), s[i + n..].chars().next());
nodes.push(Node::Delim(Delim {
run: &s[i..i + n],
// ...
}));
i += n;
text_start = i;
}
b'\n' => {
let mut end = i;
while end > text_start && b[end - 1] == b' ' {
end -= 1;
}
push_text(s, text_start, end, &mut nodes);
let brk = if i - end >= 2 { Event::HardBreak } else { Event::SoftBreak };
nodes.push(Node::Event(brk));
i = skip_indent(b, i + 1);
text_start = i;
}
- 代码段先于强调。 遇到 n 个反引号就向后找恰好 n 个的一串,找到了整段就是
Code,中间的*不再参与配对(spec 例 341*foo`*`)。code_content把换行变成空格,两头各有一个空格(且不全是空格)时各去掉一个。找不到就把反引号留在文本里继续扫。 - 换行。 行尾的空格不进文本;两个以上是
HardBreak,否则是SoftBreak。下一行开头的缩进跳过,这就是段落续行去掉缩进的地方。 - 反斜杠。 后面是 ASCII 标点时,反斜杠本身不输出,被转义的字节作为下一段文本的开头,所以
\*不会成为分隔符。pulldown-cmark 同样把Text在这里切开(CowStr)。
find_backticks 每次都从当前位置往后线性找。一行里有很多配不上的反引号串时这是平方级的,pulldown-cmark 用 CodeDelims 按长度分桶记下所有反引号串的位置(代码段、HTML、实体)。
分隔符栈
一个 * 串能不能开、能不能闭,看它两边的字符(CommonMark 6.2 节的 left-flanking / right-flanking):
fn is_punct(c: char) -> bool {
c.is_ascii_punctuation() || (!c.is_ascii() && !c.is_alphanumeric() && !c.is_whitespace())
}
/// (left-flanking, right-flanking); the text edges count as whitespace.
fn flanking(prev: Option<char>, next: Option<char>) -> (bool, bool) {
let ws = |c: Option<char>| c.map_or(true, char::is_whitespace);
let punct = |c: Option<char>| c.is_some_and(is_punct);
let left = !ws(next) && (!punct(next) || ws(prev) || punct(prev));
let right = !ws(prev) && (!punct(prev) || ws(next) || punct(next));
(left, right)
}
* 的规则最简单:左侧翼就能开,右侧翼就能闭。_ 多了词内规则,这里没有做。标点用「ASCII 标点,或非 ASCII 里既不是字母数字也不是空白」近似;规范要求的是 Unicode 的 P 和 S 两大类,pulldown-cmark 为此生成了一张表(puncttable.rs)。全角的 ,、。 在两种判断下都是标点,所以 *强调*。 能正确闭合。
配对照 CommonMark 附录「process emphasis」写,只是省掉了它的 openers_bottom 优化:
fn process_emphasis(nodes: &mut [Node]) {
let mut stack: Vec<usize> = Vec::new();
for ci in 0..nodes.len() {
if !matches!(nodes[ci], Node::Delim(_)) {
continue;
}
while delim(nodes, ci).can_close && delim(nodes, ci).count > 0 {
let (c_run, c_open) = (delim(nodes, ci).run.len(), delim(nodes, ci).can_open);
let found = stack.iter().rposition(|&oi| {
let o = match &nodes[oi] {
Node::Delim(o) => o,
Node::Event(_) => return false,
};
// The "multiple of 3" rule.
let sum = o.run.len() + c_run;
!((o.can_close || c_open) && sum % 3 == 0 && !(o.run.len() % 3 == 0 && c_run % 3 == 0))
});
let Some(si) = found else { break };
let oi = stack[si];
let n = if delim(nodes, oi).count >= 2 && delim(nodes, ci).count >= 2 { 2 } else { 1 };
let tag = if n == 2 { Tag::Strong } else { Tag::Emphasis };
let opener = delim(nodes, oi);
opener.count -= n;
opener.starts.insert(0, tag); // later matches wrap the earlier ones
let opener_done = opener.count == 0;
let closer = delim(nodes, ci);
closer.count -= n;
closer.used_left += n;
closer.ends.push(tag);
stack.truncate(si + 1); // delimiters in between become text
if opener_done {
stack.pop();
}
}
let d = delim(nodes, ci);
if d.can_open && d.count > 0 {
stack.push(ci);
}
}
}
- 栈里只放能开的分隔符的下标。 从左到右遇到一个能闭的,就从栈顶往下找第一个能配的开分隔符。两边都至少剩 2 个就配成
Strong,否则Emphasis;配不完的剩余继续找下一个。 - 3 的倍数规则。 只要有一方「既能开又能闭」,两串的原始长度之和是 3 的倍数(且不是两者都为 3 的倍数)就不能配。它让
*foo**bar*成为一个<em>,而不是*foo加一个<strong>。 - 中间的分隔符变文字。 配上以后,栈里夹在两者之间的开分隔符全部丢掉,它们剩下的
*最后作为文本输出。 - 谁包谁。 一个分隔符可能被配多次(
***foo***先配**,再配*)。先配上的在内层,所以开分隔符把新标签插到starts前面,闭分隔符把新标签追加到ends后面。闭分隔符从左端消耗,开分隔符从右端消耗,剩下的中间一段是文本。输出时每个分隔符依次吐出:ends里的End、剩余的*、starts里的Start。
spec 例 413 和 417 是这几条的检验,两边的事件流(合并相邻文本之后,见下一节):
"***foo** bar*\n"
ours [Start(Paragraph), Start(Emphasis), Start(Strong), Text("foo"), End(Strong), Text(" bar"), End(Emphasis), End(Paragraph)]
theirs [Start(Paragraph), Start(Emphasis), Start(Strong), Text("foo"), End(Strong), Text(" bar"), End(Emphasis), End(Paragraph)]
"foo******bar*********baz\n"
ours [Start(Paragraph), Text("foo"), Start(Strong), Start(Strong), Start(Strong), Text("bar"), End(Strong), End(Strong), End(Strong), Text("***baz"), End(Paragraph)]
theirs [Start(Paragraph), Text("foo"), Start(Strong), Start(Strong), Start(Strong), Text("bar"), End(Strong), End(Strong), End(Strong), Text("***baz"), End(Paragraph)]
pulldown-cmark 的 InlineStack 是同一套规则,多了按字符、能否两用、长度模 3 分的 9 个下界:一次没找到,就记下「这一类的闭分隔符以后不用再往下找」。这个迷你版没有,于是每个配不上的闭分隔符都把栈扫到底;设计取舍里构造了这样的输入,128 KB 时它要 213 ms,pulldown-cmark 1.4 ms。规则和分桶的细节见强调与分隔符栈。
和 pulldown-cmark 对照
测试 tests/spec.rs 按 build.rs 的方式把 tag 上的 third_party/CommonMark/spec.txt 拆成 652 个例子(编号和上游 tests/suite/spec.rs 的 spec_test_N 一致),同时记下每个例子所在的小节。两边的事件先映射到同一个类型再比:
fn from_pd(e: pd::Event<'_>) -> Event<'static> {
let tag = |t: pd::TagEnd| match t {
pd::TagEnd::Paragraph => Some(Tag::Paragraph),
pd::TagEnd::Heading(l) => Some(Tag::Heading(l as u8)),
pd::TagEnd::Emphasis => Some(Tag::Emphasis),
pd::TagEnd::Strong => Some(Tag::Strong),
_ => None,
};
match e {
pd::Event::Start(t) => match tag(t.to_end()) {
Some(t) => Event::Start(t),
None => Event::Text(Cow::Owned(format!("<unsupported {t:?}>"))),
},
// ...
other => Event::Text(Cow::Owned(format!("<unsupported {other:?}>"))),
}
}
/// Merges adjacent Text events and drops empty ones: both parsers split text
/// at different places (escapes, leftover delimiters).
fn merge(events: impl Iterator<Item = Event<'static>>) -> Vec<Event<'static>> {
let mut out: Vec<Event> = Vec::new();
for e in events {
match (out.last_mut(), e) {
(_, Event::Text(t)) if t.is_empty() => {}
(Some(Event::Text(prev)), Event::Text(t)) => prev.to_mut().push_str(&t),
(_, e) => out.push(e),
}
}
out
}
Start(Tag::Heading { level, .. })只保留级别;pulldown-cmark 的id、classes、attrs在不开ENABLE_HEADING_ATTRIBUTES时都是空的。- 这个子集以外的事件(
CodeBlock、Link、Html等)映射成一段带<unsupported前缀的文字,保证一定比不上。 - 相邻的
Text合并、空Text丢掉。两边切文本的位置不同:pulldown-cmark 在反斜杠转义、实体、没配上的方括号处切开(事件模型);迷你版在反斜杠转义和剩余的分隔符处切。合并以后比的是「文字内容和结构」。
从 ATX 标题、段落、代码段、强调与加粗、硬换行五个小节里挑 30 条,覆盖每一种支持的语法和几条容易错的边角:
const CHOSEN: [usize; 30] = [
// ATX headings
62, 63, 64, 66, 68, 71, 75, 76,
// Paragraphs
219, 220, 222, 226,
// Code spans
328, 329, 335, 338, 341, 347,
// Emphasis and strong emphasis
350, 351, 355, 369, 411, 413, 417, 418, 445, 478,
// Hard line breaks
633, 634,
];
另一个测试 survey 把相关六个小节的每一条都跑一遍,分三类:一致;pulldown-cmark 的事件里有本子集没有的东西或输入含 _(「unsupported」);剩下的才是真正的不一致,要求为 0。cargo test --test spec -- --nocapture 的输出:
running 3 tests
test show ... ignored
ATX headings: 18 examples, 16 match, 2 use unsupported syntax, 0 real mismatches []
test chosen_examples_match_pulldown_cmark ... ok
Paragraphs: 8 examples, 7 match, 1 use unsupported syntax, 0 real mismatches []
Code spans: 22 examples, 20 match, 2 use unsupported syntax, 0 real mismatches []
Emphasis and strong emphasis: 132 examples, 87 match, 45 use unsupported syntax, 0 real mismatches []
Hard line breaks: 15 examples, 13 match, 2 use unsupported syntax, 0 real mismatches []
Soft line breaks: 2 examples, 2 match, 0 use unsupported syntax, 0 real mismatches []
test survey ... ok
test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.01s
挑的 30 条全部一致;六个小节 197 条里 145 条一致,其余 52 条都用了子集以外的语法。看几条落在「unsupported」里的,正好是缺的东西:
- 例 69
# foo:pulldown-cmark 是缩进代码块,迷你版当成段落。 - 例 77
****单独一行:pulldown-cmark 是分隔线。 - 例 344
<a href="`">`:pulldown-cmark 先认出行内 HTML,反引号在属性值里;迷你版没有 HTML,把后面一段当成了代码段。 - 例 419
*foo [*bar*](/url)*:强调里套链接。
这组测试本身也要能红。把 3 的倍数规则那一行改成恒为 true 再跑,chosen_examples_match_pulldown_cmark 和 survey 都失败。
把 process_emphasis 里 rposition 的条件换成 true,其余不动。答案在文末。
少了什么
每一行都是前面某一页的主题:
| 缺的东西 | pulldown-cmark 怎么做 | 讲它的页 |
|---|---|---|
| 先建整棵块树 | 第一遍扫完全文,Tree<Item> 放在一个 Vec 里 | 扁平数组上的树、一行一行 |
| 其他叶子块 | Setext 标题、缩进与围栏代码块、HTML 块、分隔线;段落会被它们打断 | 叶子块 |
| 容器块与惰性续行 | 引用块、列表、紧凑与松散;每行先 scan_containers | 容器块、一行一行 |
| 制表符 | LineStart 按 4 列制表位展开缩进 | 一行一行 |
| 扩展 | 表格、脚注、删除线、数学等,由 Options 位打开 | 表格与脚注、怎么读这一卷 |
_ 强调、删除线 | 同一个 InlineStack,_ 有词内规则,~ 只和等长的配 | 强调与分隔符栈 |
| 分隔符栈的下界 | 9 个 lower_bounds,配不上时记下,避免平方 | 强调与分隔符栈、设计取舍 |
| Unicode 标点表 | puncttable.rs 生成的表,按规范的 P、S 类 | 强调与分隔符栈 |
| 链接、图片、引用定义 | LinkStack、refdefs、标签的大小写折叠 | 链接与图片 |
| 代码段的位置索引 | CodeDelims 按长度分桶,避免向后线性找 | 代码段、HTML、实体 |
| 实体、行内 HTML、自动链接 | 第一遍解实体,第二遍扫 HTML 和 <...> | 代码段、HTML、实体 |
| 智能标点、数学 | ENABLE_SMART_PUNCTUATION、ENABLE_MATH | 数学与智能标点 |
| 字节范围 | into_offset_iter 给每个事件配原文范围 | 事件模型 |
| 内联短字符串 | CowStr::Inlined 放 22 字节以内的串,不分配 | CowStr |
| 输出 | push_html 与三种转义 | HTML 写出器、转义 |
| 防病态输入的上限 | 链接括号深度、展开量、表格补齐等 | 设计取舍 |
动脑筋答案
把条件换成 { let _ = sum; true } 再跑 cargo test --test spec,输出里的这几行:
thread 'chosen_examples_match_pulldown_cmark' (7467206) panicked at tests/spec.rs:119:3:
mismatch: [411]
...
Emphasis and strong emphasis: 132 examples, 83 match, 45 use unsupported syntax, 4 real mismatches [411, 412, 415, 429]
...
test result: FAILED. 0 passed; 2 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.01s
- 挑的 30 条里只有 411(
*foo**bar**baz*)。中间的**两边都是字母,既能开又能闭;没有这条规则,它会和开头的*配上(1 + 2 = 3),结构就乱了。有了规则,**bar**自己配成<strong>,外层再配<em>。 - 六个小节里一共 4 条:411、412、415、429(
*foo**bar*、*foo**bar***、**foo*bar*baz**),都在「强调与加粗」里,都有一串夹在字母中间、能两用的分隔符,和长度不同的另一串加起来正好是 3。 - 417(
foo******bar*********baz)不在其中:6 + 9 = 15 是 3 的倍数,但 6 和 9 本身也都是 3 的倍数,规则的例外条款放行,去不去掉规则结果一样。 - 测试用的 30 条只抓到 4 条里的 1 条。想让这条规则有更多防护,可以把 412、415、429 也加进
CHOSEN;survey已经覆盖了它们。
小结
- 335 行、无依赖的
Parser实现Iterator<Item = Event>:队列空了读一个块,块内先切成文本、代码段、换行和*串,再按 CommonMark 的「process emphasis」配对,事件进队列后逐个弹出。 - 子集里没有链接定义和列表,所以块级也能按需,第一个事件不用等全文;pulldown-cmark 必须先跑完第一遍。行内仍以块为单位攒,和 pulldown-cmark 一样。
- 挑的 30 条 spec 例子事件流和 pulldown-cmark 0.13.5 完全一致(合并相邻文本后比较);相关六个小节 197 条里 145 条一致,其余都用了子集外的语法,没有真正的不一致。去掉 3 的倍数规则,30 条里有 1 条变红,六节里 4 条。
- 少的东西逐项对应前面的页:块树与容器、其他叶子块、
_和扩展、链接与实体、字节范围、内联短串,以及分隔符栈的下界和CodeDelims这类防平方的结构。