容器块
主线位置:块主线里
FirstPass::parse_block的前半段。scan_containers先确认上一行留下的容器哪些还在(见一行一行),然后进入「Process new containers」循环,在同一行上开出新的引用块、列表项、定义和脚注;循环结束后剩下的部分才交给叶子块(见叶子块)。列表是紧凑还是松散,要等整张列表出栈时才定下来。
flowchart TB
line["一行的剩余部分"] --> ind{"缩进 4 列以上"}
ind -->|是| leaf["交给叶子块"]
ind -->|否| fn{"脚注定义"}
fn -->|是| push["开容器 push"]
fn -->|否| lm{"列表标记"}
lm -->|是| item["List 加 ListItem"]
item --> task{"任务标记"}
task -->|是| para["直接解析段落"]
task -->|否| push
lm -->|否| dl{"冒号定义标记"}
dl -->|是| push
dl -->|否| bq{"大于号"}
bq -->|是| push
bq -->|否| leaf
push --> ind
本页的输出都来自实验 crate exp-pulldown-cmark/blockb-probe(M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5,debug 构建):它按 into_offset_iter 打印事件和字节范围,缩进表示嵌套,--html 时再打印 push_html 的结果。对照用的是仓库 third_party/commonmark.js/ 里的 commonmark.min.js(node 22 运行)。规则的出处是 tag 里的 third_party/CommonMark/spec.txt(CommonMark 0.31.2)、third_party/GitHub/*.txt 和 specs/*.txt。
一行能开几个容器
FirstPass::parse_block 处理完已打开的容器后,进入一个 loop。每一轮先用 scan_space_upto(4) 量一下缩进,到 4 列就退出(相对当前容器缩进 4 列,那是缩进代码块),否则按固定顺序试四种容器标记:
| 顺序 | 条件 | 节点 | 扫描函数 |
|---|---|---|---|
| 1 | ENABLE_FOOTNOTES(旧风格也包含这一位) | FootnoteDefinition | parse_footnote,见表格与脚注 |
| 2 | 总是 | List + ListItem | scan_list_marker_with_indent |
| 3 | ENABLE_DEFINITION_LIST,且当前节点是段落或定义 | DefinitionListDefinition | scan_definition_list_definition_marker_with_indent |
| 4 | 总是 | BlockQuote | scan_blockquote_marker |
命中一个就 tree.append 再 tree.push,然后 continue,在同一行剩下的字节上再试一轮。所以 > - [ ] x 这一行会依次开出 BlockQuote、List、ListItem,再在列表项里放一个任务标记。四个都不中时,line_start 退回本轮开头,循环结束,剩下的交给叶子块的判断。
开出来的容器在后续行上靠 scan_containers 续上。它沿 spine 从外往里,对每种容器各有一条规则:
| 容器 | 后续行要满足 |
|---|---|
BlockQuote | 至多 3 个空格后跟 >(> 后的一个空格一起吃掉) |
ListItem(indent) | 有 indent 列空白,或者是空行 |
DefinitionListDefinition(indent) | 同上 |
FootnoteDefinition | GFM 风格:4 列空白或空行;旧风格:不检查,总是通过 |
List、DefinitionList | 不检查,总是通过(只更新一个标志位,见下文冷知识) |
第一个不满足的容器连同它里面的都要关掉。不满足时这一行还可能是惰性续行(lazy continuation),那由段落自己判断,见一行一行。
引用块
scan_blockquote_marker 只做一件事:吃掉 >,再试着吃掉一个空格(scan_space(1),对 tab 只吃掉一列)。CommonMark 0.31.2 的「Block quotes」一节把这个空格算作引用标记的一部分。开引用块之前先调 finish_list:如果 spine 顶上正好是一个 List(而不是列表项),这行 > 说明列表到头了。
例子文档(一份文档用的 mainline.md)里的引用块第二行没有 >,是惰性续行,事件里它仍在引用块的段落里:
208..281 Start(BlockQuote(None))
210..281 Start(Paragraph)
210..226 Text(Borrowed("引用块里的 "))
...
249..250 SoftBreak
250..280 Text(Borrowed("惰性续行也算引用块。"))
210..281 End(Paragraph)
208..281 End(BlockQuote(None))
BlockQuote(None) 里的 None 是提示块的种类,没开 ENABLE_GFM 时永远是 None。
GFM 提示块
开了 ENABLE_GFM,> 之后会多试一次 LineStart::scan_blockquote_tag:
pub(crate) fn scan_blockquote_tag(&mut self) -> Option<BlockQuoteKind> {
let saved_ix = self.ix;
let tag = if self.scan_ch(b'[') && self.scan_ch(b'!') {
let tag = if self.scan_case_insensitive(b"note") {
Some(BlockQuoteKind::Note)
} else if self.scan_case_insensitive(b"tip") {
// ...
} else {
None
};
if tag.is_some() && self.scan_ch(b']') {
if let Some(nl) = scan_blank_line(&self.bytes[self.ix..]) {
self.ix += nl;
tag
} else {
None
}
} else {
None
}
} else {
None
};
// ...
}
规则由此可读出来:[! 紧跟在引用标记后面,> 和它之间至多一个空格(那个空格已被 scan_blockquote_marker 吃掉,实测 > [!NOTE] 两个空格就不认);五个词 note、tip、important、warning、caution,ASCII 大小写不敏感;] 之后这一行只能是空白。认出来以后,这一行的换行也被吃掉,BlockQuote 节点带上 Some(kind)。
标记行被整行消费,后面的正文从下一行开始。parse_block 在这里多做了一步:用 scan_containers 看下一行,如果下一行不会打断段落,就把它当作「假想段落」的惰性续行,直接在同一次调用里接着解析(源码注释:「blockquote tags act as if they were nested in a paragraph」)。这样 > [!NOTE] 后面跟一行不带 > 的文字,和不开 ENABLE_GFM 时一样留在引用块里:
> [!NOTE]
> 第一行
惰性续行
== 不开 ENABLE_GFM
0..35 Start(BlockQuote(None))
2..35 Start(Paragraph)
2..3 Text(Borrowed("["))
3..8 Text(Borrowed("!NOTE"))
8..9 Text(Borrowed("]"))
9..10 SoftBreak
12..21 Text(Borrowed("第一行"))
21..22 SoftBreak
22..34 Text(Borrowed("惰性续行"))
2..35 End(Paragraph)
0..35 End(BlockQuote(None))
== ENABLE_GFM
0..35 Start(BlockQuote(Some(Note)))
12..35 Start(Paragraph)
12..21 Text(Borrowed("第一行"))
21..22 SoftBreak
22..34 Text(Borrowed("惰性续行"))
12..35 End(Paragraph)
0..35 End(BlockQuote(Some(Note)))
标记本身不产生任何事件,段落从第二行的 12 开始。几个边界情况(同样开 ENABLE_GFM):
| 输入 | 结果 |
|---|---|
> [!note] 换行 > a | BlockQuote(Some(Note)),小写也认 |
> [!NOTE] same line | BlockQuote(None),] 后面还有字,整行是普通段落 |
> [!TODO] 换行 > b | BlockQuote(None),不在五个词里 |
>[!TIP](只有这一行) | BlockQuote(Some(Tip)),里面没有子节点 |
push_html 对五种提示块只输出一个类名,如 <blockquote class="markdown-alert-note">,不生成标题,图标和标题文字要调用方自己加。这几条规则写在 pulldown-cmark 自己的 specs/blockquotes_tags.txt 里;tag 里的 third_party/GitHub/spec.txt(GFM 0.28)没有提示块这一节。
问: ENABLE_GFM 叫「GFM」,开了它表格、任务列表、删除线是不是都有了?
答: 没有。git grep ENABLE_GFM v0.13.5 -- pulldown-cmark/src 在解析代码里只命中一处,就是 firstpass.rs 第 237 行决定要不要调 scan_blockquote_tag 的那个 if;lib.rs 里它的文档写的也是「Misc GitHub Flavored Markdown features」,下面只列了提示块。只开 ENABLE_GFM 跑一段含表格、~~del~~、- [ ] t 的文档,三者都按普通文本输出。表格、删除线、任务列表、脚注各有自己的位。GFM 的扩展自动链接(裸写的 www.example.com)和 tagfilter 在 0.13.5 里根本没有实现,几个位全开也一样:https://example.com 和 <script> 原样输出。
列表与列表项
列表标记
scan_list_marker_with_indent 认两类标记:
-、+、*:先用scan_hrule排除分隔线(* * *是分隔线不是列表),后面必须是一个空格或行尾。- 数字加
.或):循环条件ix - start_ix < 10让数字最多 9 位。数值以u64存下。
返回的 indent 是「内容从第几列开始」:标记宽度加上其后 1 到 4 个空格;如果标记后有 5 个及以上空格,只算 1 个,剩下的属于内容(内容是缩进代码块)。这个数存进 ItemBody::ListItem(indent),后续行就按它判断还在不在这个列表项里。
然后 continue_list 决定新开一张列表还是接着用:
fn continue_list(&mut self, start: usize, ch: u8, index: u64) {
self.finish_empty_list_item();
if let Some(node_ix) = self.tree.peek_up() {
if let ItemBody::List(ref list_flags, existing_ch, _) = self.tree[node_ix].item.body {
if existing_ch == ch {
if self.last_line_blank {
list_flags.set(list_flags.get() & !ListFlags::IS_TIGHT);
self.last_line_blank = false;
}
return;
}
}
// TODO: this is not the best choice for end; maybe get end from last list item.
self.finish_list(start);
}
self.tree.append(Item {
start,
end: 0, // will get set later
body: ItemBody::List(Cell::new(ListFlags::IS_TIGHT), ch, index),
});
self.tree.push();
self.last_line_blank = false;
}
比的是 ch:无序列表存的是 -、+、* 本身,有序列表存的是 . 或 ),所以换一个符号就是一张新列表,和 CommonMark「Lists」一节「Changing the bullet or ordered list delimiter starts a new list」一致。起始编号是第一项的数字,存在 List 的第三个字段,item_to_event 转成 Tag::List(Some(start));后面各项的数字不看。
3. a -> 0..11 Start(List(Some(3))) 两项,7 被忽略
7. b
123456789) nine -> 16..33 Start(List(Some(123456789)))
1234567890. ten -> 38..54 Start(Paragraph) 10 位,不是列表
- a / + b / 1. c / 1) d -> 四张列表
前三段在同一个输入里,中间用 --- 隔开(上面省略了),最后一行是另一个输入。CommonMark「List items」一节:「ordered list start numbers must be nine digits or less」。
什么时候能打断段落
一行列表标记能不能结束上面的段落,由 scan_paragraph_interrupt_no_table 里的这一条决定:
|| scan_listitem(bytes).map_or(false, |(ix, delim, index, _)| {
! current_container ||
tree.is_in_table() ||
// we don't allow interruption by either empty lists or
// numbered lists starting at an index other than 1
(delim == b'*' || delim == b'-' || delim == b'+' || index == 1)
&& (scan_blank_line(&bytes[ix..]).is_none())
})
在当前容器里,只有无序标记或编号为 1 的有序标记、而且这一项不是空的,才能打断段落。CommonMark「Lists」一节解释了原因:硬换行的段落里可能出现 14. The number of doors is 6. 这样的行,所以「we allow only lists starting with 1 to interrupt paragraphs」;「List items」一节另有「an empty list item cannot interrupt a paragraph」。
! current_container 那一支说的是惰性续行的位置:这一行没能续上所有容器时,任何列表标记都会结束段落,不再当作续行。
para / 2. two -> <p>para\n2. two</p>
para / 1. one -> <p>para</p><ol><li>one</li></ol>
para / * -> <p>para\n*</p> 空项不能打断
> q / 2. x -> <blockquote><p>q</p></blockquote><ol start="2">…
最后一行 2. x 不在引用块里,不能当 q 的惰性续行,引用块结束,另起一张从 2 开始的列表。commonmark.js 对这四个输入的输出与此相同。
紧凑还是松散
CommonMark「Lists」一节的定义:列表项之间隔着空行,或者某个列表项直接包含的两个块之间隔着空行,列表就是松散(loose)的,否则是紧凑(tight)的。区别只在 HTML 里段落有没有 <p>。
这件事要等整张列表读完才知道:前十项都紧挨着,第十一项前面空了一行,整张列表就是松散的,前十项的段落也要带 <p>。pulldown-cmark 的做法是先假定紧凑,在两处把它改成松散,在出栈时一次性改树。
列表节点是 ItemBody::List(Cell<ListFlags>, u8, u64),ListFlags 有两位:IS_TIGHT 和 LAST_LINE_EMPTY。新建列表时 IS_TIGHT 置位。清掉它的地方:
continue_list:同一张列表来了新的一项,而上一行是空行(上面代码里的self.last_line_blank)。这是「项与项之间隔着空行」。finish_list:每开一个叶子块或引用块之前都会调它。
fn finish_list(&mut self, ix: usize) {
self.finish_empty_list_item();
if let Some(node_ix) = self.tree.peek_up() {
if let ItemBody::List(_, _, _) | ItemBody::DefinitionList(_) =
self.tree[node_ix].item.body
{
self.pop(ix);
}
}
if self.last_line_blank {
if let Some(node_ix) = self.tree.peek_grandparent() {
if let ItemBody::List(ref list_flags, _, _)
| ItemBody::DefinitionList(ref list_flags) = self.tree[node_ix].item.body
{
list_flags.set(list_flags.get() & !ListFlags::IS_TIGHT);
}
}
self.last_line_blank = false;
}
}
前半段:spine 顶上是 List 本身,说明 scan_containers 已经关掉了列表项,新块不属于任何一项,列表到此结束,出栈。后半段:上一行是空行,而 spine 是「…、List、ListItem」(祖父是列表),说明新块开在同一个列表项里、和前一个块隔着空行,这是「项内两个块隔着空行」。顺序很要紧:列表在前半段已经出栈的话,后半段的祖父就不是它了,所以列表末尾的空行不会让它变松散。
出栈时 pop 检查 IS_TIGHT,还在就调 surgerize_tight_list,把每个列表项直接子节点里的 Paragraph 改成 TightParagraph。第二遍的 Parser::next 遇到它不产生事件,直接进到它的子节点:
None => {
let ix = self.tree.pop()?;
let ix = if matches!(self.tree[ix].item.body, ItemBody::TightParagraph) {
// tight paragraphs emit nothing
self.tree.next_sibling(ix);
return self.next();
} else {
ix
};
所以紧凑列表的「没有 <p>」不是 HTML 写出器的决定:html.rs 里没有任何关于紧凑列表的状态,事件流里本来就没有 Start(Paragraph)。Tag::List 也不带紧凑标志,调用方想知道列表紧不紧,只能看列表项里有没有段落事件。例子文档里的列表就是紧凑的:
126..208 Start(List(None))
126..159 Start(Item)
128..158 Text(Borrowed("第一遍:扫行,建块树"))
126..159 End(Item)
...
126..208 End(List(false))
几个对照(End(List(false)) 里的布尔值表示是否有序,不是紧凑标志):
| 输入 | 结果 | 原因 |
|---|---|---|
- a - b 空行 - c | 三项都有 <p> | continue_list 看到空行 |
- a 空行 a2 - b | 两项都有 <p> | finish_list 的祖父是 List |
- a - b 两个空行 after | 紧凑,后面是 <p>after</p> | 列表先出栈,空行不算 |
- a - x 空行 - y - b | 外层紧凑,内层松散 | 空行在内层两项之间 |
这四个输入 commonmark.js 的输出与 pulldown-cmark 一致。
ListFlags 是 0.13.5 才有的0.13.4 里列表节点还是 ItemBody::List(bool, u8, u64),那个 bool 就是紧凑标志。问题出在 scan_containers:它每行都要沿 spine 走一遍,而空行能「续上」任何列表项,于是 N 层嵌套的列表后面跟 N 个空行,每个空行都要走 N 层,总共平方级。PR #1148 的提交 38b7e8a 写的是「Deeply nested lists followed by blank lines parsed in quadratic time」,修法照搬 cmark 的 PR 520(原话「The fix is shamelessly "borrowed" from」那个 PR):给列表加一个 LAST_LINE_EMPTY 位,连续第二个空行走到这张列表时,scan_containers 直接返回 tree.spine_len(),不再往里走。scan_containers 拿的是 &Tree,要在只读借用下改这个位,于是 bool 换成了 Cell<ListFlags>,IS_TIGHT 也搬了进去,并多了一个 update_flags 参数,只有 parse_block 开头那次调用传 true。这个 PR 在 main 上合并,以 a126502「cherry-pick pull request #1148 commits into 0.13 branch」进入 0.13.5。
用 PR 里基准的形状("- " * N + "a\n" 后接 N 个空行)在本机测了一下,release 构建,Options::empty(),push_html,每个 N 跑 5 次取最小值,测时负载约 6:
| N | 输入字节 | 0.13.4 | 0.13.5 |
|---|---|---|---|
| 1000 | 3002 | 2.52 ms | 0.08 ms |
| 4000 | 12002 | 39.72 ms | 0.32 ms |
| 16000 | 48002 | 630.13 ms | 1.27 ms |
N 每翻 4 倍,0.13.4 的时间涨约 16 倍,0.13.5 涨 4 倍。把空行换成 > 再套一层引用块,数字几乎一样(0.13.4 在 N=16000 时 656.57 ms)。
空列表项
finish_empty_list_item 管的是 CommonMark「List items」里的一条:列表项至多以一个空行开头。标记后面直接是行尾时,parse_block 把位置记进 begin_list_item;下一个块到来时,如果中间又隔了空行,就把这个列表项以 begin_list_item 为终点关掉:
- / 空行 / foo -> <ul><li></li></ul><p>foo</p>
- / foo -> <ul><li>foo</li></ul>
- a
- b
- c
Options::empty(),看 push_html 的输出里哪些项带 <p>。答案在文末。
任务列表标记
任务标记只在一个地方被认:parse_block 刚开出一个列表项、标记后面不是行尾的时候,开了 ENABLE_TASKLISTS 就试 scan_task_list_marker:
if self.options.contains(Options::ENABLE_TASKLISTS) {
let task_list_marker =
line_start.scan_task_list_marker().map(|is_checked| Item {
start: after_marker_index,
end: start_ix + line_start.bytes_scanned(),
body: ItemBody::TaskListMarker(is_checked),
});
if let Some(task_list_marker) = task_list_marker {
if let Some(n) = scan_blank_line(&bytes[task_list_marker.end..]) {
let end = task_list_marker.end;
self.tree.append(task_list_marker);
self.begin_list_item = Some(end + n);
return end + n;
} else {
line_start.scan_all_space();
let ix = start_ix + line_start.bytes_scanned();
return self.parse_paragraph(ix, Some(task_list_marker));
}
}
}
扫描规则:至多 3 个空格,[,一个空白字符(不含换行)或 x / X,],然后必须是空白(换行也算)。认出来以后不再回到容器循环,而是直接 parse_paragraph,并把标记节点作为段落的第一个子节点。所以 - [ ] # 不是标题 输出的是文字 # 不是标题,而 - # 是标题 是标题。这对应 GFM「Task list items (extension)」一节(third_party/GitHub/gfm_tasklist.txt):任务列表项是「first block in it is a paragraph which begins with a task list item marker」。
- [ ] todo -> TaskListMarker(false),然后 Text("todo")
- [X] -> TaskListMarker(true),没有文字
- [ ]no-space -> 文字 "[ ]no-space",] 后不是空白
- [] empty -> 文字,括号里必须有一个字符
- text [ ] later -> 文字,只认紧跟标记的位置
1. [x] ordered -> 有序列表也认
事件里没有单独的「任务列表」标签,只有一个 Event::TaskListMarker(bool)。它挂在段落里,紧凑列表的段落不出事件,于是它看上去是列表项的直接子节点;列表松散时,它在 Start(Paragraph) 之后:
0..9 Start(Item)
6..8 Start(Paragraph)
2..5 TaskListMarker(false)
6..7 Text(Borrowed("a"))
6..8 End(Paragraph)
0..9 End(Item)
注意这里的字节范围:段落从 6 开始(parse_paragraph 的起点在标记之后),标记却是 2..5,子事件的范围落在父事件的范围之外。按范围嵌套关系还原结构的代码会在这里出错。
定义列表
ENABLE_DEFINITION_LIST 打开的是 commonmark-hs 风格的定义列表(specs/definition_lists.txt):
术语
: 定义一
: 定义二
它没有自己的开头标记。: 行被认出时,前面那个段落已经解析完了,所以容器循环的第 3 步看的是 tree.cur(),即刚刚结束的那个兄弟节点:
- 它是
Paragraph:就地把它改成DefinitionList(紧凑),新建一个DefinitionListTitle节点,把原段落的子节点(文字)挂过去,再push进列表。 - 它是
DefinitionListDefinition:同一张列表里再来一条定义。 - 它是
MaybeDefinitionListTitle:改成DefinitionListTitle。
之后在列表里、紧跟一条定义出现的段落,parse_paragraph 先开成 MaybeDefinitionListTitle,因为要等下一行才知道它后面有没有 :。列表出栈时 fixup_end_of_definition_list 把末尾那个没等到定义的 MaybeDefinitionListTitle 改回 Paragraph,再用 truncate_to_parent 把它从列表里摘出来,挂到列表后面。紧凑与松散和普通列表共用 ListFlags,一处空行就让整张定义列表的定义都带 <p>。
0..45 Start(DefinitionList)
0..7 Start(DefinitionListTitle)
0..6 Text(Borrowed("术语"))
0..7 End(DefinitionListTitle)
7..19 Start(DefinitionListDefinition)
9..18 Text(Borrowed("定义一"))
7..19 End(DefinitionListDefinition)
19..32 Start(DefinitionListDefinition)
21..30 Text(Borrowed("定义二"))
19..32 End(DefinitionListDefinition)
0..45 End(DefinitionList)
32..45 Start(Paragraph)
32..44 Text(Borrowed("不是术语"))
32..45 End(Paragraph)
这是在上面的输入后加一个空行和一行 不是术语。段落被正确地摘到了列表外面,但 DefinitionList 的范围仍是 0..45,把后面的段落 32..45 也包了进去:列表的 end 在出栈时按当前位置设好,摘出段落后没有回调。
动脑筋答案
<ul>
<li>
<p>a</p>
<ul>
<li>b</li>
</ul>
</li>
<li>
<p>c</p>
</li>
</ul>
外层松散,内层紧凑。空行出现在 - b 之后、- c 之前。读到 - c 时,它连 2 列缩进都没有,scan_containers 在外层列表项 a 那里停下,a 和它里面的内层列表、列表项 b 一起出栈,spine 顶上是外层 List;continue_list 看到同一个 - 且 last_line_blank 为真,清掉的是外层的 IS_TIGHT。内层列表出栈时还是紧凑的,b 的段落被改成 TightParagraph。按 CommonMark 的定义也是这样:空行隔开的是外层的两项 a 和 c,内层只有一项。commonmark.js 输出相同。
小结
- 容器在
parse_block的循环里一行可以开好几个,顺序是脚注、列表标记、定义标记、>;缩进到 4 列就停。后续行由scan_containers按每种容器的规则续上。 ENABLE_GFM在 0.13.5 只多做一件事:>后试[!NOTE]等五种标记,标记行之后的正文仍可惰性续行。表格、任务列表、删除线、脚注各有自己的位,扩展自动链接没有实现。- 列表标记:9 位以内的数字,换符号就换列表,起始编号只看第一项;在当前容器里只有无序标记和
1能打断段落,空项不能。 - 紧凑与松散:先假定紧凑,
continue_list和finish_list两处看到空行就清IS_TIGHT,出栈时surgerize_tight_list把段落改成TightParagraph,第二遍不为它产生事件。ListFlags是 0.13.5 为修平方复杂度引入的(PR #1148)。 - 任务标记只在列表项第一行、紧跟列表标记处认,认完直接进段落;松散列表里它的范围在段落范围之外。
- 定义列表靠
:行回头改写前一个段落;MaybeDefinitionListTitle等到出栈才定,未配上定义的会被摘回列表外,但列表的结束位置不随之调整。