一行一行
主线位置:块主线的主体。
Parser::new_ext调run_first_pass,后者反复调FirstPass::parse_block,直到读完整份文档;每次调用先用scan_containers匹配还开着的容器,再看这一行开了什么新容器、是什么叶子块。结果是扁平数组上的树和Allocations。叶子块各自怎么读见叶子块,容器的细节见容器块。
flowchart TB
start["parse_block 起点"] --> sc["scan_containers"]
sc --> popu["弹出没匹配的容器"]
popu --> newc{"有新容器标记"}
newc -->|有| app["append 再 push"]
app --> newc
newc -->|没有| blank{"空行"}
blank -->|是| mark["记 last_line_blank"]
blank -->|否| ind{"缩进至少 4 列"}
ind -->|是| icode["缩进代码块"]
ind -->|否| leaf["按序试叶子块"]
leaf --> refdef["链接引用定义"]
refdef --> para["parse_paragraph"]
firstpass.rs 在 tag 上 2849 行,最后约 270 行是只在 x86_64 开 simd 时编译的 mod simd(含它的测试);行首扫描用的 LineStart 和各种 scan_* 在 scanners.rs(1566 行),scan_containers 在 parse.rs。实验在 exp-pulldown-cmark/blocka-*(M2 Max、macOS 27、rustc 1.99.0):blocka-dump 用复制出来的 0.13.5 源码(加了打印,不是上游代码,见扁平数组上的树)记录每次 parse_block 处理了哪段文字、之后 spine 上还剩什么;blocka-run / blocka-run134 分别依赖 crates.io 上的 0.13.5 和 0.13.4。
规范里的三步
CommonMark 规范的附录「A parsing strategy」把块解析写成逐行的三步(「Phase 1: block structure」一节):
- 从根往下,逐个检查还开着的块,看这一行是否满足它们继续的条件(引用块要
>,段落要非空行)。没匹配上的块「cannot close ... yet, because we may have a lazy continuation line」。 - 吃掉这些续行标记后,找新块的开头(
>、列表标记),遇到时才关闭第 1 步没匹配上的块。 - 剩下的内容交给最深的那个开着的块(段落、代码块、标题、HTML)。
pulldown-cmark 的骨架是同一个思路,组织方式不同:外层循环按「块」走,叶子块自己把后续的行读完;惰性续行也因此放在段落的循环里处理,第 1 步没匹配上的容器可以立刻关闭。
入口:run_first_pass
run_first_pass 建好状态,FirstPass::run 就是一个循环:
pub(crate) fn run_first_pass(text: &str, options: Options) -> (Tree<Item>, Allocations<'_>) {
// This is a very naive heuristic for the number of nodes
// we'll need.
let start_capacity = max(128, text.len() / 32);
let lookup_table = &create_lut(&options);
let first_pass = FirstPass {
text,
tree: Tree::with_capacity(start_capacity),
begin_list_item: None,
last_line_blank: false,
allocs: Allocations::new(),
options,
lookup_table,
brace_context_next: 0,
brace_context_stack: Vec::new(),
};
first_pass.run()
}
fn run(mut self) -> (Tree<Item>, Allocations<'a>) {
let mut ix = 0;
while ix < self.text.len() {
ix = self.parse_block(ix);
}
while self.tree.spine_len() > 0 {
self.pop(ix);
}
(self.tree, self.allocs)
}
parse_block 收一个字节偏移,返回它处理到的位置。文档读完后 spine 上可能还留着容器(文档以列表项结尾,没有空行),最后统一 pop,end 都设成文档末尾。
FirstPass 的状态不多:tree、allocs、选项,两个列表用的标志 begin_list_item / last_line_blank(上一行是不是空行,列表的紧凑与松散靠它,见容器块),lookup_table 是 parse_line 用的「哪些字节可能是行内标记」表,brace_context_* 给数学公式数花括号。
parse_block 一次处理一个块
FirstPass::parse_block 的注释是「Returns offset after block」。它从一行的开头进入,但不一定只吃一行:段落、代码块、HTML 块的解析函数会自己往下读,直到块结束才返回。例子文档 21 行,blocka-dump 记录到的调用(引号里是这次调用吃掉的原文,spine after 是返回时 spine 上的块):
parse_block 0..34 "# 拉取式解析:*pull* parser\n" spine after: []
parse_block 34..35 "\n" spine after: []
parse_block 35..125 "pulldown-cmark 把 Markdown 变成**事件流**,\n规则见 [CommonMark 规范][spec]。\n" spine after: []
parse_block 125..126 "\n" spine after: []
parse_block 126..159 "- 第一遍:扫行,建块树\n" spine after: [List, ListItem]
parse_block 159..207 "- 第二遍:`Parser::next` 按需解析行内\n" spine after: [List, ListItem]
parse_block 207..208 "\n" spine after: [List, ListItem]
parse_block 208..281 "> 引用块里的 _强调_ 和 A&B,\n惰性续行也算引用块。\n" spine after: [BlockQuote]
parse_block 281..282 "\n" spine after: []
parse_block 282..354 "```rust\nlet parser = Parser::new_ext(text, Options::ENABLE_TABLES);\n```\n" spine after: []
parse_block 354..355 "\n" spine after: []
parse_block 355..439 "| 遍 | 产出 |\n| :-- | --: |\n| 第一遍 | `Tree<Item>` |\n| 第二遍 | `Event` |\n" spine after: []
parse_block 439..440 "\n" spine after: []
parse_block 440..502 "[spec]: https://spec.commonmark.org/0.31.2/ \"CommonMark Spec\"\n" spine after: []
(实际输出里每行 parse_block 前还有一行 scan_containers matched i of n,这里删掉了,下一节再看。)
- 21 行、14 次调用。两行的段落、三行的代码块、四行的表格、带惰性续行的引用块,各一次调用。空行单独一次。
- 叶子块进去、出来时 spine 不变:它们在自己的函数里
push、pop。留在 spine 上跨调用存在的只有容器(列表、列表项、引用块)。 - 最后一行是链接引用定义,吃掉了却没有留下节点:它进了
allocs.refdefs(表格与脚注)。
上游 guide 的 dev/block-parsing.md 说「The block parser operates line-by-line」。按字节看,整份文档确实是一行接一行顺序读的,没有回退;按调用看,外层循环的单位是块。
LineStart:行首的游标
每一行开头的缩进和容器标记由 LineStart 扫。它是一个可以 clone 的小游标:试着扫一个标记,失败就恢复到存档。
pub(crate) struct LineStart<'a> {
bytes: &'a [u8],
ix: usize,
// ...
tab_start: usize,
// ...
spaces_remaining: usize,
// no thematic breaks can occur before this offset.
// this prevents scanning over and over up to a certain point
min_hrule_offset: usize,
}
难点是制表符。规范 2.2 节「Tabs」规定:在决定块结构的地方,制表符按 4 列的制表位展开成空格,内容里的制表符原样保留。一个制表符可能只被用掉一部分,比如列表项要求缩进 2 列,行首是一个制表符(4 列),用掉 2 列,剩下 2 列属于内容。LineStart::scan_space_inner 把没用完的列数记在 spaces_remaining:
b'\t' => {
let spaces = 4 - (self.ix - self.tab_start) % 4;
self.ix += 1;
self.tab_start = self.ix;
let n = spaces.min(n_space);
n_space -= n;
// Record the unscanned portion of the tab.
self.spaces_remaining = spaces - n;
}
剩下的列数在 remaining_space() 里交给叶子块。代码块和 HTML 块需要把这几列还原成空格写进内容,而输入里并没有这几个空格字节,于是 append_code_text 往树里插一个 SynthesizeText 节点,内容是 " " 的前几个字符:事件流里会多出一个不来自原文切片的 Text。min_hrule_offset 记住「这个位置之前不可能有分隔线」,scan_list_marker_with_indent 判断 -、* 是列表标记还是分隔线时不必每次从头扫。
scan_containers:哪些容器还开着
parse_block 一进来就做规范的第 1 步:
let i = scan_containers(&self.tree, &mut line_start, self.options, true);
for _ in i..self.tree.spine_len() {
self.pop(start_ix);
}
scan_containers 从根往下沿 spine 走,每个容器检查自己的续行条件,返回连续匹配上了几个:
for &node_ix in &mut spine_iter {
match tree[node_ix].item.body {
// ... (列表的空行快速路径,见下一节)
ItemBody::BlockQuote(..) => {
let save = line_start.clone();
let _ = line_start.scan_space(3);
if !line_start.scan_blockquote_marker() {
*line_start = save;
break;
}
}
ItemBody::ListItem(indent) => {
let save = line_start.clone();
if !line_start.scan_space(indent) && !line_start.is_at_eol() {
*line_start = save;
break;
}
}
// ... DefinitionListDefinition 同 ListItem;GFM 脚注定义要缩进 4 列
_ => (),
}
i += 1;
}
- 引用块要一个
>(前面最多 3 个空格);列表项要缩进到它记下的indent列,或者是空行。 List节点本身没有条件(_ => ()),总是匹配:列表能不能继续,要等后面看到下一个列表标记才知道,那是continue_list的事。- 一旦有一个容器不匹配就
break,后面的都算没匹配上。
规范说没匹配上的块要等到第 2 步才能关闭,因为这一行可能是某个段落的惰性续行。pulldown-cmark 在 parse_block 开头就把它们 pop 掉,因为走到这里时上一个段落已经结束了:段落的续行(包括惰性续行)全都在 parse_paragraph 自己的循环里消化,parse_block 只会在新块开始的地方被调用。
例子文档的列表之后是引用块,那一次调用的记录是 scan_containers matched 1 of 2:List 匹配,ListItem 不匹配(> 前面没有 2 列缩进),ListItem 被弹出;接着新容器的循环里看到 >,finish_list 再把 List 关掉,然后 append 一个 BlockQuote 并 push。
空行与深层列表:PR #1148
scan_containers 每行都要沿 spine 走一遍。空行时每个列表项都匹配(is_at_eol()),一层都不会提前 break;嵌套 n 层的列表后面跟 n 个空行,就是 n × 2n 次检查。一层列表只要输入里的 2 个字节(- ),一个空行只要 1 个字节,几十 KB 的输入就能让解析花上几百毫秒。
0.13.5 的 scan_containers 多了第四个参数 update_flags,只有 parse_block 开头那一次传 true,firstpass.rs 里其他 15 处(各叶子块读后续行、判断能否打断段落时)都传 false。列表节点的标志从一个 bool 变成了 Cell<ListFlags>,多了一位 LAST_LINE_EMPTY:
// This fix is lifted, almost verbatim,
// from https://github.com/commonmark/cmark/pull/520/changes
ItemBody::List(ref list_flags, _, _) | ItemBody::DefinitionList(ref list_flags)
if update_flags =>
{
// Avoid quadratic behavior caused by deeply nested lists
// for each blank line.
if line_start.is_at_eol() {
if list_flags.get().contains(ListFlags::LAST_LINE_EMPTY)
&& line_start.remaining_space() == 0
{
// Finish early if we encounter multiple blank lines.
// ...
return tree.spine_len();
} else {
list_flags.set(list_flags.get() | ListFlags::LAST_LINE_EMPTY);
}
} else {
list_flags.set(list_flags.get() & !ListFlags::LAST_LINE_EMPTY);
}
}
第一个空行照常走完 spine,沿途给每个列表打上 LAST_LINE_EMPTY;从第二个连续空行起,走到最外层的列表就看到标记,直接返回「全部匹配」。理由在注释里:上一行已经是空行,列表里能继续的只剩靠缩进嵌套的块,空行对它们一律匹配,不必逐个问。遇到非空行时清掉标记,函数末尾的第二个循环把 break 之后没走到的那些列表也清掉。用 Cell 是因为 scan_containers 只拿到 &Tree。
blocka-run(0.13.5)和 blocka-run134(0.13.4)各解析 "- " × n + "a\n" + "\n" × n,Options::empty(),把 Parser 迭代到底,release 构建,每个 n 取 5 次中的最小值;当时机器负载约 9.7(其他写手的实验在并行跑):
| n | 输入字节 | 0.13.4 | 0.13.5 |
|---|---|---|---|
| 1000 | 3002 | 2.470 ms | 0.063 ms |
| 2000 | 6002 | 9.916 ms | 0.124 ms |
| 4000 | 12002 | 39.262 ms | 0.249 ms |
| 8000 | 24002 | 157.520 ms | 0.496 ms |
| 16000 | 48002 | 621.472 ms | 0.994 ms |
0.13.4 的时间随 n 翻倍乘 4,0.13.5 乘 2。把整份文档放进引用块("> " + "- " × n + "a\n" + ">\n" × n)结果一样:n = 8000 时 0.13.4 163.542 ms,0.13.5 0.527 ms。原始输出在 blocka-run/nested.out.txt。
PR #1148(「Skip the container scan on consecutive blank lines」)2026-09 合进 main,又由 a126502(「feat: cherry-pick pull request #1148 commits into 0.13 branch」)带进 0.13 分支,首次发布于 0.13.5。核心提交 38b7e8a 的说明写了来龙去脉:「Deeply nested lists followed by blank lines parsed in quadratic time. scan_containers runs once per line and walked the whole spine, and on a blank line every list item matched without consuming anything」;它和此前的 PR #1145「accomplishes the same thing ... but without allocating more memory」,做法「borrowed from https://github.com/commonmark/cmark/pull/520, which fixes the same bug」,也就是代码注释里那个 cmark 链接。提交说明里贴的修复前数据是 N = 80000 时 34.42 s。同一个 PR 给 bench/ 加了 pathological_blank_lines_in_nested_lists,给 dos-fuzzer 加了回归用例。
新容器
匹配完旧容器,parse_block 进入一个 loop,看这一行接着有没有新容器的标记,有就 append 一个容器节点并 push,然后继续找(> - > 1. 这样一行可以开好几层):
- 缩进达到 4 列就停:那是缩进代码块,不是容器标记。
- 开了脚注选项时,
[^label]:开一个脚注定义。 - 列表标记(
-、+、*、1.、1)):continue_list决定续上旧列表还是开新列表,再加一个ListItem。标记后面是空行时直接返回,空列表项另有规则。 - 开了定义列表选项、且当前层最后一个块是段落(或已有的定义)时,
:把那个段落改成定义列表的标题。 >开引用块;开了ENABLE_GFM时还认[!NOTE]这类提示块标记。
叶子块的分派
新容器扫完,剩下的就是叶子块。parse_block 的后半段是一串按顺序的判断,第一个成立的就把这一行交给对应的函数,函数读完整个块后返回:
- 空行:记
last_line_blank,返回下一行的开头。 - 缩进 4 列或以上:
parse_indented_code_block。 - 缩进为 0 且开了元数据选项:
scan_metadata_block成功就parse_metadata_block。 - 行首是
<:依次试 HTML 块的第 1 到 5 类、第 6 类、第 7 类。 scan_hrule:分隔线。scan_atx_heading:ATX 标题。scan_code_fence:围栏代码块。- 链接引用定义:能连着解析几个就解析几个,存进
refdefs。 - 以上都不是:
parse_paragraph。
除了空行,每个分支在开新块之前都调 finish_list:如果当前层是一个列表(意味着这一行没有列表标记、却出现在列表的层级上),列表到此结束。各叶子块的细节见叶子块。
惰性续行
惰性续行(lazy continuation)是规范 5.1 节「Block quotes」的第 2 条规则「Laziness」:引用块里段落的续行,可以省掉行首的 >;列表项(5.2 节「List items」第 5 条)同理,可以省掉缩进。条件是这一行必须是「paragraph continuation text」,也就是如果它接在段落后面,会被当成段落的一部分。
pulldown-cmark 在 parse_paragraph 的循环里处理。每读完一行,它对下一行再跑一次 scan_containers(这次 update_flags 为 false):
ix = next_ix;
let mut line_start = LineStart::new(&bytes[ix..]);
let tree_position = scan_containers(&self.tree, &mut line_start, self.options, false);
let current_container = tree_position == self.tree.spine_len();
// ...
if !line_start.scan_space(4) {
let ix_new = ix + line_start.bytes_scanned();
if current_container {
// ... Setext 标题下划线
}
// first check for non-empty lists, then for other interrupts
let suffix = &self.text[ix_new..];
if self.scan_paragraph_interrupt(suffix, current_container, tree_position) {
// ...
break;
}
}
line_start.scan_all_space();
if line_start.is_at_eol() {
// ...
break;
}
current_container 为假,说明这一行没能匹配全部容器,按规范的第 1 步本该关掉一些块。但只要它不是空行、也不能打断段落(scan_paragraph_interrupt 为假),循环就不 break,这一行照样追加进段落:这就是惰性续行。容器一个都没关,因为这里根本没调 pop。
scan_paragraph_interrupt 也要 current_container:一行没匹配全部容器、又以列表标记开头时,空列表项和不从 1 开始的有序列表也算打断;只有在同一个容器里,这两种才不能打断段落。实测 > foo\n2. bar\n:pulldown-cmark、comrak、markdown-rs 都在引用块之后开一个 start="2" 的有序列表。Setext 标题的下划线只在 current_container 为真时才认,所以惰性续行不能把引用块里的段落变成标题(叶子块的动脑筋)。
例子文档的引用块正是这样:惰性续行也算引用块。 那一行没有 >,scan_containers 返回 0 而 spine 上有 BlockQuote 和 Paragraph 两项,它不打断段落,于是成了段落的第二行,整个引用块在一次 parse_block 里读完(上面记录里的 208..281)。链接引用定义也表现得像在段落里(源码注释举了 > [foo]: http://example.com 后面接一行 bar 的例子),scan_next_line_or_lazy_continuation 对它后面那一行做同样的判断;GFM 提示块标记那一行在 parse_block 里有一段相同的内联代码。
> foo\nbar\n> baz\n 三行,第一遍里 parse_block 被调用几次?树里有几个 Paragraph?用 blocka-dump 跑,Options::empty(),答案在文末。
parse_line 不是分派
名字容易误会:FirstPass::parse_line 不决定块的种类,它是段落、标题、表格单元格里「扫一行内容」的函数,注释是「Parse a line of input, appending text and items to tree」。它用 iterate_special_bytes 跳到 lookup_table 里标记过的字节(*、_、`、[、]、<、!、&、\、换行;开了扩展再加 |、~、^、$ 等),在那里追加 MaybeEmphasis、MaybeCode、MaybeLinkOpen 这类占位节点,中间的普通字节合并成 Text;行尾决定是 SoftBreak 还是 HardBreak,在表格扫描模式下顺便检查下一行是不是表格分隔行。x86_64 开了 simd feature、运行时检测到 SSSE3 并且要扫的切片不短于 16 字节时,一次查 16 个字节;其他情况(包括本机 aarch64)逐字节查 256 项的 bool 表。占位节点后来怎么变成事件,见按需解析。
动脑筋答案
== nodes ==
0 child=- next=- 0..0 Root
1 child=2 next=- 0..16 BlockQuote(None) "> foo\nbar\n> baz\n"
2 child=3 next=- 2..16 Paragraph "foo\nbar\n> baz\n"
3 child=- next=4 2..5 Text { backslash_escaped: false } "foo"
4 child=- next=5 5..6 SoftBreak "\n"
5 child=- next=6 6..9 Text { backslash_escaped: false } "bar"
6 child=- next=7 9..10 SoftBreak "\n"
7 child=- next=- 12..15 Text { backslash_escaped: false } "baz"
...
== trace ==
scan_containers matched 0 of 0
parse_block 0..16 "> foo\nbar\n> baz\n" spine after: [BlockQuote]
1 次调用,1 个段落。第一行开引用块和段落;第二行 bar 没有 >,scan_containers 匹配 0 个,是惰性续行;第三行 > baz 又匹配上了全部容器,是普通续行,parse_line 从 > 之后的第 12 字节开始扫,> 不进文本。三行在 parse_paragraph 的一个循环里读完,渲染出来是 <blockquote> 里一个 <p>foo\nbar\nbaz</p>。
小结
run_first_pass估好树的容量、建好查找表,FirstPass::run循环调parse_block直到文档结束,再弹出剩下的容器。parse_block一次处理一个块:容器跨调用留在 spine 上,叶子块在自己的函数里读完所有后续行。例子文档 21 行、14 次调用。LineStart按 4 列制表位扫行首,没用完的制表符列数交给代码块,变成SynthesizeText。scan_containers沿 spine 检查每个容器的续行条件;parse_block立刻弹出没匹配上的容器,因为惰性续行已经在parse_paragraph的循环里处理掉了。- PR #1148(0.13.5)让连续空行在最外层列表处提前返回:实测 n = 16000 时 0.13.4 621 ms,0.13.5 1 ms。
- 叶子块的分派是
parse_block后半段的一串按序判断;parse_line是扫一行行内标记的函数,不是分派。