一份文档
主线位置:三条主线全程。用怎么读这一卷里的例子文档,从
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收走了,见表格与脚注。 - 实体已经解出来。
&(240..245)在第一遍就变成SynthesizeText(CowIndex(0)),指向Allocations里的一个CowStr。代码块的语言rust是CowIndex(1),表格的对齐是AlignmentIndex(0):变长的数据放在树外,节点只存下标,所以节点能保持定长。 - 行内语法只做了标记。
*、**、_、`、[、]、<都变成了Maybe*占位,带着能否开、能否闭的判断(MaybeEmphasis(1, true, false)是「一个分隔符、能开、不能闭」)。**事件流**的两个*被记成两个节点:先是长度为 2 的那一串,再是剩下的 1 个。表格单元格里`Tree<Item>`的<也记成了MaybeHtml,此刻还不知道它在代码段里。
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
块主线到这里结束。new_with_broken_link_callback 把树的游标 reset 回根,此后一切都发生在 next 里。
第一遍结束时是 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&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<Item></code></td></tr>
<tr><td style="text-align: left">第二遍</td><td style="text-align: right"><code>Event</code></td></tr>
</tbody></table>
SoftBreak写成换行。 段落里的软换行原样输出\n,浏览器显示成一个空格;中文排版里这会在两行之间多出空格,渲染器要不要这样处理,是调用方的事。- 实体解出来又转义回去。 第一遍把
&变成&,写出时escape_html_body_text再把&变回&;Tree<Item>的<和>也是在这里变成<、>。 - 表格的
<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 毫秒。