Rust in Everything
基于 pulldown-cmark 0.13.5

一行一行

主线位置:块主线的主体。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」一节):

  1. 从根往下,逐个检查还开着的块,看这一行是否满足它们继续的条件(引用块要 >,段落要非空行)。没匹配上的块「cannot close ... yet, because we may have a lazy continuation line」。
  2. 吃掉这些续行标记后,找新块的开头(>、列表标记),遇到时才关闭第 1 步没匹配上的块。
  3. 剩下的内容交给最深的那个开着的块(段落、代码块、标题、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&amp;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.40.13.5
100030022.470 ms0.063 ms
200060029.916 ms0.124 ms
40001200239.262 ms0.249 ms
800024002157.520 ms0.496 ms
1600048002621.472 ms0.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。

📝NOTE
冷知识:一个从 cmark 抄来的修复,只进了 0.13.5

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 的后半段是一串按顺序的判断,第一个成立的就把这一行交给对应的函数,函数读完整个块后返回:

  1. 空行:记 last_line_blank,返回下一行的开头。
  2. 缩进 4 列或以上:parse_indented_code_block。
  3. 缩进为 0 且开了元数据选项:scan_metadata_block 成功就 parse_metadata_block。
  4. 行首是 <:依次试 HTML 块的第 1 到 5 类、第 6 类、第 7 类。
  5. scan_hrule:分隔线。
  6. scan_atx_heading:ATX 标题。
  7. scan_code_fence:围栏代码块。
  8. 链接引用定义:能连着解析几个就解析几个,存进 refdefs。
  9. 以上都不是: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 里有一段相同的内联代码。

❗IMPORTANT
动脑筋:> 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 是扫一行行内标记的函数,不是分派。

💬 关联讨论

发起讨论
加载中...