Rust in Everything
基于 pulldown-cmark 0.13.5

容器块

主线位置:块主线里 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 列,那是缩进代码块),否则按固定顺序试四种容器标记:

顺序条件节点扫描函数
1ENABLE_FOOTNOTES(旧风格也包含这一位)FootnoteDefinitionparse_footnote,见表格与脚注
2总是List + ListItemscan_list_marker_with_indent
3ENABLE_DEFINITION_LIST,且当前节点是段落或定义DefinitionListDefinitionscan_definition_list_definition_marker_with_indent
4总是BlockQuotescan_blockquote_marker

命中一个就 tree.append 再 tree.push,然后 continue,在同一行剩下的字节上再试一轮。所以 > - [ ] x 这一行会依次开出 BlockQuote、List、ListItem,再在列表项里放一个任务标记。四个都不中时,line_start 退回本轮开头,循环结束,剩下的交给叶子块的判断。

开出来的容器在后续行上靠 scan_containers 续上。它沿 spine 从外往里,对每种容器各有一条规则:

容器后续行要满足
BlockQuote至多 3 个空格后跟 >(> 后的一个空格一起吃掉)
ListItem(indent)有 indent 列空白,或者是空行
DefinitionListDefinition(indent)同上
FootnoteDefinitionGFM 风格: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] 换行 > aBlockQuote(Some(Note)),小写也认
> [!NOTE] same lineBlockQuote(None),] 后面还有字,整行是普通段落
> [!TODO] 换行 > bBlockQuote(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)没有提示块这一节。

📝NOTE
没有蠢问题

问: 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 置位。清掉它的地方:

  1. continue_list:同一张列表来了新的一项,而上一行是空行(上面代码里的 self.last_line_blank)。这是「项与项之间隔着空行」。
  2. 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 一致。

📝NOTE
冷知识: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.40.13.5
100030022.52 ms0.08 ms
40001200239.72 ms0.32 ms
1600048002630.13 ms1.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>
❗IMPORTANT
动脑筋:下面这段 Markdown 有两张列表。哪一张是松散的?
- 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 等到出栈才定,未配上定义的会被摘回列表外,但列表的结束位置不随之调整。

💬 关联讨论

发起讨论
加载中...