Rust in Everything
基于 pulldown-cmark 0.13.5

一份文档

主线位置:三条主线全程。用怎么读这一卷里的例子文档,从 Parser::new_ext 到 HTML 走一遍:块主线建出什么样的树,行内主线什么时候、在哪里跑,输出主线怎么把事件写成标签。每一步只讲到看清全貌,细节链到 02 块解析、03 行内解析 和 05 输出。

sequenceDiagram
  participant U as 你的代码
  participant P as Parser
  participant F as FirstPass
  participant T as Tree
  participant H as HtmlWriter
  U->>P: new_ext text opts
  P->>F: run_first_pass
  F->>T: 逐行建块与占位
  F-->>P: tree 与 allocs
  U->>H: push_html parser
  H->>P: next
  P->>T: 走到下一个节点
  P->>P: 遇到占位 handle_inline
  P-->>H: Event
  H->>H: start_tag end_tag 转义

程序

例子文档(21 行,502 字节):

# 拉取式解析:*pull* parser

pulldown-cmark 把 Markdown 变成**事件流**,
规则见 [CommonMark 规范][spec]。

- 第一遍:扫行,建块树
- 第二遍:`Parser::next` 按需解析行内

> 引用块里的 _强调_ 和 A&B,
惰性续行也算引用块。

```rust
let parser = Parser::new_ext(text, Options::ENABLE_TABLES);
```

| 遍 | 产出 |
| :-- | --: |
| 第一遍 | `Tree<Item>` |
| 第二遍 | `Event` |

[spec]: https://spec.commonmark.org/0.31.2/ "CommonMark Spec"

实验 intro-mainline(pulldown-cmark =0.13.5,release)打印带字节范围的事件流和 HTML:

use pulldown_cmark::{html, Options, Parser};

fn main() {
  let path = std::env::args().nth(1).unwrap_or_else(|| "../mainline.md".into());
  let doc = std::fs::read_to_string(&path).expect("read");
  let opts = Options::ENABLE_TABLES;
  for (ev, range) in Parser::new_ext(&doc, opts).into_offset_iter() {
    println!("{range:?} {ev:?}");
  }
  let mut out = String::new();
  html::push_html(&mut out, Parser::new_ext(&doc, opts));
  println!("---- html");
  print!("{out}");
}

一共 72 个事件。树要另想办法看:Tree 是 crate 私有的,Parser 的 Debug 也不打印它。实验 intro-tree 依赖一份 0.13.5 源码的本地拷贝,只加了两个方法:intro_debug_tree 返回 format!("{:?}", self.tree)(用的是 tree.rs 里现成的 Debug,按缩进打印节点),intro_tree_len 返回节点数组的长度;另在 handle_inline 开头加了一行可选的打印。解析逻辑一行没动。

块主线:构造函数里就建完了树

Parser::new_ext 转到 new_with_broken_link_callback,后者第一行就是 run_first_pass。FirstPass::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 每次从一行的开头进来,返回处理到哪里。它先做的是:

        let i = scan_containers(&self.tree, &mut line_start, self.options, true);
        for _ in i..self.tree.spine_len() {
            self.pop(start_ix);
        }

spine 是从根到当前位置的一串打开的节点。scan_containers 沿着它逐层看这一行能不能继续:引用块要有 >,列表项要有足够的缩进;在第 i 层停下,之上的全部关掉。然后才识别这一行新开的容器(列表标记、>、脚注定义),最后决定叶子块。逐行的细节见一行一行。

第一遍结束时树里有 70 项(第 0 项是哑根节点,实际 69 个节点)。intro-tree 打印的前半部分:

== after first pass: 70 nodes (index 0 is a dummy)
Item { start: 0, end: 34, body: Heading(H1, None) }
  Item { start: 2, end: 20, body: Text { backslash_escaped: false } }
  Item { start: 20, end: 21, body: MaybeEmphasis(1, true, false) }
  Item { start: 21, end: 25, body: Text { backslash_escaped: false } }
  Item { start: 25, end: 26, body: MaybeEmphasis(1, false, true) }
  Item { start: 26, end: 33, body: Text { backslash_escaped: false } }
Item { start: 35, end: 125, body: Paragraph }
  Item { start: 35, end: 69, body: Text { backslash_escaped: false } }
  Item { start: 69, end: 70, body: MaybeEmphasis(2, true, true) }
  Item { start: 70, end: 71, body: MaybeEmphasis(1, true, true) }
  Item { start: 71, end: 80, body: Text { backslash_escaped: false } }
  Item { start: 80, end: 81, body: MaybeEmphasis(2, false, true) }
  Item { start: 81, end: 82, body: MaybeEmphasis(1, false, true) }
  Item { start: 82, end: 85, body: Text { backslash_escaped: false } }
  Item { start: 85, end: 86, body: SoftBreak }
  Item { start: 86, end: 96, body: Text { backslash_escaped: false } }
  Item { start: 96, end: 97, body: MaybeLinkOpen }
  Item { start: 97, end: 114, body: Text { backslash_escaped: false } }
  Item { start: 114, end: 115, body: MaybeLinkClose(true) }
  Item { start: 115, end: 116, body: MaybeLinkOpen }
  Item { start: 116, end: 120, body: Text { backslash_escaped: false } }
  Item { start: 120, end: 121, body: MaybeLinkClose(true) }
  Item { start: 121, end: 124, body: Text { backslash_escaped: false } }
Item { start: 126, end: 208, body: List(Cell { value: ListFlags(IS_TIGHT) }, 45, 0) }
  Item { start: 126, end: 159, body: ListItem(2) }
    Item { start: 128, end: 159, body: TightParagraph }
      Item { start: 128, end: 158, body: Text { backslash_escaped: false } }
  Item { start: 159, end: 208, body: ListItem(2) }
    Item { start: 161, end: 207, body: TightParagraph }
      Item { start: 161, end: 173, body: Text { backslash_escaped: false } }
      Item { start: 173, end: 174, body: MaybeCode(1, false) }
      Item { start: 174, end: 186, body: Text { backslash_escaped: false } }
      Item { start: 186, end: 187, body: MaybeCode(1, false) }
      Item { start: 187, end: 206, body: Text { backslash_escaped: false } }
Item { start: 208, end: 281, body: BlockQuote(None) }
  Item { start: 210, end: 281, body: Paragraph }
    Item { start: 210, end: 226, body: Text { backslash_escaped: false } }
    Item { start: 226, end: 227, body: MaybeEmphasis(1, true, false) }
    Item { start: 227, end: 233, body: Text { backslash_escaped: false } }
    Item { start: 233, end: 234, body: MaybeEmphasis(1, false, true) }
    Item { start: 234, end: 240, body: Text { backslash_escaped: false } }
    Item { start: 240, end: 245, body: SynthesizeText(CowIndex(0)) }
    Item { start: 245, end: 249, body: Text { backslash_escaped: false } }
    Item { start: 249, end: 250, body: SoftBreak }
    Item { start: 250, end: 280, body: Text { backslash_escaped: false } }
Item { start: 282, end: 353, body: FencedCodeBlock(CowIndex(1)) }
  Item { start: 290, end: 350, body: Text { backslash_escaped: false } }
Item { start: 355, end: 439, body: Table(AlignmentIndex(0)) }
  Item { start: 355, end: 372, body: TableHead }
...
    Item { start: 399, end: 413, body: TableCell }
      Item { start: 400, end: 401, body: MaybeCode(1, false) }
      Item { start: 401, end: 405, body: Text { backslash_escaped: false } }
      Item { start: 405, end: 406, body: MaybeHtml }
      Item { start: 406, end: 411, body: Text { backslash_escaped: false } }
      Item { start: 411, end: 412, body: MaybeCode(1, false) }
...

从这棵树里能读出第一遍做了什么、没做什么:

  • 只存字节范围。 每个节点是 Item { start, end, body },文字不复制;Text 节点就是原文的 start..end。节点在一个 Vec 里,用「第一个孩子、下一个兄弟」两个下标连起来,见扁平数组上的树。
  • 块已经全部定好。 标题、段落、列表、引用块、代码块、表格都在。第 9、10 行的引用块只有第一行有 >,第二行是惰性续行(lazy continuation),已经归进同一个 Paragraph(210..281)。
  • 列表的松紧已经定了。 List 的第一个字段是 ListFlags(IS_TIGHT),45 是标记字符 - 的 ASCII 码,0 是起始序号(只对有序列表有意义);每项里的段落是 TightParagraph,输出时不包 <p>。松紧要看完整个列表才知道,这正是必须先扫一遍的原因之一,见容器块。
  • 链接定义不在树里。 最后一行 [spec]: ... 没有对应的节点,它被 Allocations 的 refdefs 收走了,见表格与脚注。
  • 实体已经解出来。 &amp;(240..245)在第一遍就变成 SynthesizeText(CowIndex(0)),指向 Allocations 里的一个 CowStr。代码块的语言 rust 是 CowIndex(1),表格的对齐是 AlignmentIndex(0):变长的数据放在树外,节点只存下标,所以节点能保持定长。
  • 行内语法只做了标记。 *、**、_、`、[、]、< 都变成了 Maybe* 占位,带着能否开、能否闭的判断(MaybeEmphasis(1, true, false) 是「一个分隔符、能开、不能闭」)。**事件流** 的两个 * 被记成两个节点:先是长度为 2 的那一串,再是剩下的 1 个。表格单元格里 `Tree<Item>` 的 < 也记成了 MaybeHtml,此刻还不知道它在代码段里。
📝NOTE
冷知识:这棵树里的 ListFlags 是 0.13.5 才有的

上面那行 List(Cell { value: ListFlags(IS_TIGHT) }, 45, 0) 在 0.13.4 里是 List(bool, u8, u64),第一个字段只是 is_tight。改它的是 #1148(main 上 2098dd9 合入,2026-09-28;摘进分支的提交是 a126502,首次发布于 0.13.5)。提交说明讲了原来的问题:「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, so each blank line cost time proportional to the nesting depth.」修法照搬了 cmark 的同类修复:列表多记一位 LAST_LINE_EMPTY,连续第二个空行时 scan_containers 直接返回 spine_len(),不再逐层走;因为要在只读借用的树上改这一位,字段换成了 Cell。块主线开头那行调用多出来的 true 就是这次加的 update_flags 参数,第一遍里其余几处调用传 false。

用提交说明里的输入(N 层嵌套列表加 2N 个空行),分别在 0.13.4 和 0.13.5 上跑 Parser::new(&doc).count()(release,各一次):

== intro-quad4
N=   800 bytes=   3201 events=  3201 time=    3.294 ms
N=  8000 bytes=  32001 events= 32001 time=  316.609 ms
N= 16000 bytes=  64001 events= 64001 time= 1271.188 ms
N= 32000 bytes= 128001 events=128001 time= 5139.655 ms
== intro-quad5
N=   800 bytes=   3201 events=  3201 time=    0.129 ms
N=  8000 bytes=  32001 events= 32001 time=    0.656 ms
N= 16000 bytes=  64001 events= 64001 time=    1.392 ms
N= 32000 bytes= 128001 events=128001 time=    2.577 ms
0.13.4 上输入翻倍、时间翻四倍,128 KB 要 5 秒;0.13.5 上是线性的,同样的输入 2.6 毫秒。

块主线到这里结束。new_with_broken_link_callback 把树的游标 reset 回根,此后一切都发生在 next 里。

❗IMPORTANT
动脑筋:把 72 个事件全部取完之后,树里有多少项?

第一遍结束时是 70 项。行内解析要把 **事件流** 变成 Strong 包着一个 Text,把 [CommonMark 规范][spec] 变成一个 Link。新结构是追加新节点,还是改写旧节点?答案在文末。

行内主线:走到占位才解析

Parser::next 沿树做深度优先遍历:tree.cur() 有节点就产出它(Start 类的事件之后 push 进孩子,其余的移到下一个兄弟);没有就 pop 回父节点、产出 End。在产出之前多一个判断:

                    if self.tree[cur_ix].item.body.is_maybe_inline() {
                        self.handle_inline();
                    }

handle_inline 只有两行:先 handle_inline_pass1(代码段、行内 HTML、自动链接、链接和图片),再 handle_emphasis_and_hard_break(分隔符栈配对强调)。它从当前节点开始,处理到这条兄弟链的末尾,把占位改写成最终的节点。

在 handle_inline 开头加一行打印,用 into_offset_iter 跑例子文档,事件和调用交错出现(intro-tree 的 trace 模式,节选):

0..34 Start(Heading { level: H1, id: None, classes: [], attrs: [] })
2..20 Text(Borrowed("拉取式解析:"))
  [handle_inline: block Heading(H1, None) 0..34, starting at node 20..21 MaybeEmphasis(1, true, false)]
20..26 Start(Emphasis)
21..25 Text(Borrowed("pull"))
20..26 End(Emphasis)
26..33 Text(Borrowed(" parser"))
0..34 End(Heading(H1))
35..125 Start(Paragraph)
35..69 Text(Borrowed("pulldown-cmark 把 Markdown 变成"))
  [handle_inline: block Paragraph 35..125, starting at node 69..70 MaybeEmphasis(2, true, true)]
69..82 Start(Strong)
71..80 Text(Borrowed("事件流"))
69..82 End(Strong)
82..85 Text(Borrowed(","))
85..86 SoftBreak
86..96 Text(Borrowed("规则见 "))
96..121 Start(Link { link_type: Reference, dest_url: Borrowed("https://spec.commonmark.org/0.31.2/"), title: Borrowed("CommonMark Spec"), id: Borrowed("spec") })
97..114 Text(Borrowed("CommonMark 规范"))
96..121 End(Link)
121..124 Text(Borrowed("。"))
35..125 End(Paragraph)
126..208 Start(List(None))
126..159 Start(Item)
128..158 Text(Borrowed("第一遍:扫行,建块树"))
126..159 End(Item)
159..208 Start(Item)
161..173 Text(Borrowed("第二遍:"))
  [handle_inline: block TightParagraph 161..207, starting at node 173..174 MaybeCode(1, false)]
173..187 Code(Borrowed("Parser::next"))
187..206 Text(Borrowed(" 按需解析行内"))
159..208 End(Item)
126..208 End(List(false))
208..281 Start(BlockQuote(None))
210..281 Start(Paragraph)
210..226 Text(Borrowed("引用块里的 "))
  [handle_inline: block Paragraph 210..281, starting at node 226..227 MaybeEmphasis(1, true, false)]
226..234 Start(Emphasis)
227..233 Text(Borrowed("强调"))
226..234 End(Emphasis)
234..240 Text(Borrowed(" 和 A"))
240..245 Text(Borrowed("&"))
245..249 Text(Borrowed("B,"))
249..250 SoftBreak
250..280 Text(Borrowed("惰性续行也算引用块。"))
210..281 End(Paragraph)
208..281 End(BlockQuote(None))
282..353 Start(CodeBlock(Fenced(Borrowed("rust"))))
290..350 Text(Borrowed("let parser = Parser::new_ext(text, Options::ENABLE_TABLES);\n"))
282..353 End(CodeBlock)
...

后面表格的两个数据行各有一次,全文 6 次。几件事:

  • 行内解析发生在块内第一个占位处,不在块的开头。 标题的 Text("拉取式解析:") 先产出了,handle_inline 才跑;第一个列表项、代码块、表头没有占位,一次也不调。
  • 一次调用处理整条链。 段落里的 **、[、] 在同一次调用里全部解决,之后的 next 直接产出改写好的节点。
  • 链接在这里查定义。 [CommonMark 规范][spec] 在第二遍才被认成引用链接,dest_url 和 title 来自第一遍收好的 refdefs。事件里的 id: Borrowed("spec") 是引用标签的原文。
  • 事件的文本大多是借用。 除了 Code 和实体,Text 都是 Borrowed,指向输入字符串;实体那个 Text("&") 也是 Borrowed,因为 & 借的是实体表里的 &'static str,不是输入。Code(Borrowed("Parser::next")) 的范围是 173..187,包括两个反引号,而文本不包括。范围是怎么定的、Start 和 End 为什么范围相同,见事件模型;行内各趟的细节见按需解析、强调与分隔符栈、链接与图片。
  • 紧凑列表的段落不产出事件。 树里的 TightParagraph 在 next 里被跳过(「tight paragraphs emit nothing」),所以 Start(Item) 后面直接是 Text。

输出主线:事件变成标签

push_html 建一个 HtmlWriter,run 逐个取事件:Start 交给 start_tag,End 交给 end_tag,文本经过转义写出。例子文档的输出:

<h1>拉取式解析:<em>pull</em> parser</h1>
<p>pulldown-cmark 把 Markdown 变成<strong>事件流</strong>,
规则见 <a href="https://spec.commonmark.org/0.31.2/" title="CommonMark Spec">CommonMark 规范</a>。</p>
<ul>
<li>第一遍:扫行,建块树</li>
<li>第二遍:<code>Parser::next</code> 按需解析行内</li>
</ul>
<blockquote>
<p>引用块里的 <em>强调</em> 和 A&amp;B,
惰性续行也算引用块。</p>
</blockquote>
<pre><code class="language-rust">let parser = Parser::new_ext(text, Options::ENABLE_TABLES);
</code></pre>
<table><thead><tr><th style="text-align: left">遍</th><th style="text-align: right">产出</th></tr></thead><tbody>
<tr><td style="text-align: left">第一遍</td><td style="text-align: right"><code>Tree&lt;Item&gt;</code></td></tr>
<tr><td style="text-align: left">第二遍</td><td style="text-align: right"><code>Event</code></td></tr>
</tbody></table>
  • SoftBreak 写成换行。 段落里的软换行原样输出 \n,浏览器显示成一个空格;中文排版里这会在两行之间多出空格,渲染器要不要这样处理,是调用方的事。
  • 实体解出来又转义回去。 第一遍把 &amp; 变成 &,写出时 escape_html_body_text 再把 & 变回 &amp;;Tree<Item> 的 < 和 > 也是在这里变成 &lt;、&gt;。
  • 表格的 <tbody> 是写出器补的。 事件里只有 TableHead 和若干 TableRow,没有表体的事件;HtmlWriter 用 table_state 记着自己在表头还是表体,End(TableHead) 时一次写出 </tr></thead><tbody>,End(Table) 时写 </tbody></table>。单元格的对齐来自 Start(Table([Left, Right])),写出器把它存下来,按列号给每个单元格加 style。
  • 紧凑列表不写 <p>。 因为根本没有 Paragraph 事件,写出器不需要知道列表是否紧凑。

写出器的状态、三种转义的区别,见 HTML 写出器 和转义。本站不走这一步,直接把事件转成 RSX,见 08 在本站中。

动脑筋答案

还是 70 项。intro-tree 先打印第一遍后的树,再用 parser.by_ref().count() 取完全部事件,再打印一次:

== after draining 72 events: 70 nodes
Item { start: 0, end: 34, body: Heading(H1, None) }
  Item { start: 2, end: 20, body: Text { backslash_escaped: false } }
  Item { start: 20, end: 26, body: Emphasis }
    Item { start: 21, end: 25, body: Text { backslash_escaped: false } }
  Item { start: 26, end: 33, body: Text { backslash_escaped: false } }
Item { start: 35, end: 125, body: Paragraph }
  Item { start: 35, end: 69, body: Text { backslash_escaped: false } }
  Item { start: 69, end: 82, body: Strong }
    Item { start: 71, end: 80, body: Text { backslash_escaped: false } }
  Item { start: 82, end: 85, body: Text { backslash_escaped: false } }
  Item { start: 85, end: 86, body: SoftBreak }
  Item { start: 86, end: 96, body: Text { backslash_escaped: false } }
  Item { start: 96, end: 121, body: Link(LinkIndex(0)) }
    Item { start: 97, end: 114, body: Text { backslash_escaped: false } }
  Item { start: 121, end: 124, body: Text { backslash_escaped: false } }
...
  • 节点数没变,结构变了:开分隔符那个节点被改写成 Emphasis、Strong,范围扩成整段(20..26、69..82),中间的节点挂到它下面成为孩子,闭分隔符节点从兄弟链上摘掉。** 原来是四个 MaybeEmphasis 节点,现在只剩一个 Strong。
  • 链接同理:MaybeLinkOpen 变成 Link(LinkIndex(0)),dest_url 和 title 存进 Allocations,[spec] 那一段节点直接被跳过。
  • 被摘掉的节点仍在 Vec 里,只是不再被任何下标指到。第二遍以改写和摘除为主,节点数组基本不再增长;parse.rs 里调 create_node 追加新节点的只有三处:自动链接和 wikilink,这个例子都没用到。

小结

  • 块主线在 Parser::new_ext 里跑完:FirstPass::run 循环 parse_block,每行先 scan_containers 关掉断开的容器;例子文档得到 69 个节点。
  • 第一遍不止定块:链接定义收进 refdefs、实体解成 SynthesizeText、行内语法记成 Maybe* 占位;变长数据放在 Allocations,节点只存下标。
  • 行内主线在 next 里:走到块内第一个占位才 handle_inline,一次处理整条兄弟链;例子文档共 6 次,没有占位的块一次不调。
  • 第二遍原地改写节点,节点数从头到尾是 70;紧凑段落不产出事件,Text 大多借用输入。
  • 输出主线只是 next 的消费者:实体在写出时重新转义,<tbody> 由写出器补上,对齐从 Start(Table) 里取。
  • 0.13.5 摘进来的 #1148 让「深嵌套列表加连续空行」从平方变成线性:128 KB 的输入从 5.1 秒降到 2.6 毫秒。

💬 关联讨论

发起讨论
加载中...