扁平数组上的树
主线位置:块主线的数据结构。
Parser::new_ext一进来就调run_first_pass,第一遍把整份文档建成一棵Tree<Item>;之后Parser::next沿着这棵树一个节点一个节点地产出事件。这一页讲这棵树长什么样、怎么长出来、第二遍怎么走它。第一遍按什么顺序往树里放节点,见一行一行。
flowchart TB root["0 Root 占位"] h["1 Heading"] -->|next| p["7 Paragraph"] p -->|next| list["24 List"] list -->|next| bq["35 BlockQuote"] bq -->|next| code["46 FencedCode"] code -->|next| table["48 Table"] h -->|child| t2["2 Text"] list -->|child| li1["25 ListItem"] li1 -->|next| li2["28 ListItem"] li1 -->|child| tp["26 TightParagraph"]
图里的编号是块主线例子文档(一份文档用的那 21 行)建出来的真实下标。所有节点都在同一个 Vec 里,节点之间只用下标相连:child 指向第一个孩子,next 指向下一个兄弟。
tree.rs 只有 298 行,类型和方法都是 pub(crate),外面拿不到。为了把树打出来,实验 exp-pulldown-cmark/blocka-vendor 从 cargo 缓存里复制了 0.13.5 的 src/,加了约 60 行打印代码(逐项列出节点数组、跑一遍 Tree 自己的 Debug、记录每次 parse_block 前后的 spine),这不是上游代码;blocka-dump 调用它。机器是 M2 Max、macOS 27、rustc 1.99.0。
三个字段
#[derive(Debug, Eq, PartialEq, Copy, Clone, PartialOrd)]
pub(crate) struct TreeIndex(NonZeroUsize);
#[derive(Debug, Clone, Copy)]
pub(crate) struct Node<T> {
pub child: Option<TreeIndex>,
pub next: Option<TreeIndex>,
pub item: T,
}
/// A tree abstraction, intended for fast building as a preorder traversal.
#[derive(Clone)]
pub(crate) struct Tree<T> {
nodes: Vec<Node<T>>,
spine: Vec<TreeIndex>, // indices of nodes on path to current node
cur: Option<TreeIndex>,
}
Tree的nodes按创建顺序存所有节点。注释说得明白:它是为「按先序遍历的顺序快速建树」设计的,节点总是追加在末尾。spine是从根到当前位置的路径,只存下标。第一遍里它就是「还开着的块」的栈:引用块、列表、列表项,以及正在填内容的那个叶子块。cur是当前层最后一个节点,新节点要接在它的next上;当前层还没有节点时是None,新节点接到父节点的child上。
Node 是经典的「第一个孩子、下一个兄弟」表示:一个节点不管有多少孩子,都只占两个下标。树的节点类型是 parse.rs 里的 Item:start、end 两个字节偏移加一个 ItemBody。ItemBody 既有块(Paragraph、List、FencedCodeBlock …),也有第一遍留下的行内占位(MaybeEmphasis、MaybeCode、MaybeLinkOpen …)和第二遍解析出的行内结果(Emphasis、Link …):块和行内元素放在同一棵树里,行内节点是块节点的孩子。占位怎么变成结果,见按需解析。
节点里不存文本,只存字节范围;要文本时回到输入里切。ItemBody 里需要额外数据的变体(围栏代码块的 info string、标题属性、表格对齐、链接)也只存一个下标(CowIndex、HeadingIndex、AlignmentIndex、LinkIndex),数据在 Allocations 的几张表里(一次解析分配了什么)。这样 ItemBody 只有 16 字节。实验 blocka-dump 的 sizes 在本机上打印:
TreeIndex 8 bytes
Option<TreeIndex> 8 bytes
Item 32 bytes
ItemBody 16 bytes
Node<Item> 48 bytes
Option<usize> 16 bytes
enum Nil|Valid(NZ) 8 bytes
Node<Item> 48 字节 = 两个下标 16 + Item 32。上游在 parse.rs 里有两个单元测试 node_size 和 body_size,在 64 位目标上断言 48 和 16:谁给 ItemBody 加了一个大变体,测试就会失败。
下标从 1 开始
TreeIndex 包的是 NonZeroUsize,0 不是合法值,编译器就把 0 留给 None:Option<TreeIndex> 和 TreeIndex 一样是 8 字节,而 Option<usize> 要 16 字节。每个节点两个可空下标,省下的是每节点 16 字节,也就是 Node<Item> 的四分之一。
代价是第 0 个位置不能用。Tree::with_capacity 先往 nodes 里推一个占位节点,注释给了理由:
// Indices start at one, so we place a dummy value at index zero.
// The alternative would be subtracting one from every TreeIndex
// every time we convert it to usize to index our nodes.
pub(crate) fn with_capacity(cap: usize) -> Tree<T> {
let mut nodes = Vec::with_capacity(cap);
nodes.push(Node {
child: None,
next: None,
item: <T as Default>::default(),
});
// ...
}
占位节点的 item 是 ItemBody::default(),即 Root,注释写着「Dummy node at the top of the tree - should not be used otherwise!」。它不是树根:顶层的块之间用 next 串起来,第一个顶层块就是下标 1。Tree::reset 把 cur 设成 1,第二遍从这里开始。
容量是 run_first_pass 估的:max(128, text.len() / 32),注释自称「a very naive heuristic」。例子文档 502 字节,估 128,实际用了 70 项(含占位)。
NIL = !0 到 Option<TreeIndex>tree.rs 最早用 usize 当下标,空指针是常量 pub const NIL: usize = !0;。提交 190fc9d(「First effort at replacing usize tree indices by TreeIndex indices」,随 PR #184 合入,首次发布于 0.3.0)把它换成 enum TreeIndex { Nil, Valid(NonZeroUsize) },同时加上了下标 0 的占位节点和上面那段注释。这个枚举后来改名 TreePointer,直到 PR #450 的提交 0e535c9(「Remove TreePointer in favor of Option<TreeIndex>」,首次发布于 0.7.2)删掉它、改用标准库的 Option<TreeIndex>,提交说明是「This makes it easier for new contributors to understand the code by reducing the number of abstractions that need to be learned.」。这一步不是为了省内存:上面的实验里手写的 enum Nil|Valid(NZ) 也是 8 字节,两种写法的布局相同,改动只为少一个自造的类型。
append、push、pop
建树只用三个操作。Tree::append 在当前层末尾加一个节点:
pub(crate) fn append(&mut self, item: T) -> TreeIndex {
let ix = self.create_node(item);
let this = Some(ix);
if let Some(ix) = self.cur {
self[ix].next = this;
} else if let Some(&parent) = self.spine.last() {
self[parent].child = this;
}
self.cur = this;
ix
}
Tree::push 把 cur 压进 spine,往下走一层,cur 变成它的第一个孩子(新建的节点没有孩子,所以是 None);Tree::pop 从 spine 弹出父节点,cur 回到父节点,后面的 append 就接在父节点之后:
pub(crate) fn push(&mut self) -> TreeIndex {
let cur_ix = self.cur.unwrap();
self.spine.push(cur_ix);
self.cur = self[cur_ix].child;
cur_ix
}
pub(crate) fn pop(&mut self) -> Option<TreeIndex> {
let ix = Some(self.spine.pop()?);
self.cur = ix;
ix
}
第一遍里每个块都是这个节奏:append 一个块节点,push 进去,往里 append 孩子,最后 pop 出来。弹出的时候才知道块在哪里结束,所以 FirstPass::pop 在 tree.pop() 之后补上 item.end,块节点 append 时写的 end: 0 都带着注释「will get set later」。
孩子只能追加在当前层末尾,已经 pop 出来的块不能再往里加东西。这正好对上 CommonMark 的规则:附录「A parsing strategy」说「The last child of a block is normally considered open」,只有最后一个孩子可能还开着,块一旦关闭就不会再被修改。
例子文档建完之后的节点数组(blocka-dump 输出,- 表示 None,最后一列是这个范围对应的原文,长的截断)。这一段是文档开头的标题和段落,以及中间的列表:
0 child=- next=- 0..0 Root
1 child=2 next=7 0..34 Heading(H1, None) "# 拉取式解析:*pu...
2 child=- next=3 2..20 Text { backslash_escaped: false } "拉取式解析:"
3 child=- next=4 20..21 MaybeEmphasis(1, true, false) "*"
4 child=- next=5 21..25 Text { backslash_escaped: false } "pull"
5 child=- next=6 25..26 MaybeEmphasis(1, false, true) "*"
6 child=- next=- 26..33 Text { backslash_escaped: false } " parser"
7 child=8 next=24 35..125 Paragraph "pulldown-cmark 把 Mark...
...
24 child=25 next=35 126..208 List(Cell { value: ListFlags(IS_TIGHT) }, 45, 0) "- 第一遍:扫行,...
25 child=26 next=28 126..159 ListItem(2) "- 第一遍:扫行,...
26 child=27 next=- 128..159 TightParagraph "第一遍:扫行,建...
27 child=- next=- 128..158 Text { backslash_escaped: false } "第一遍:扫行,建...
28 child=29 next=- 159..208 ListItem(2) "- 第二遍:`Parser::...
...
先序:父节点总在孩子前面,一个块的所有后代在数组里连成一段(标题是 1 到 6,列表是 24 到 34)。顶层的六个块 1、7、24、35、46、48 靠 next 串起来。列表的两项 25 和 28 之间隔着 25 的后代 26、27。
第二遍用同一个 spine 走树
Parser 构造完就调 tree.reset():cur 指向 1,spine 清空。之后 Parser::next 每次只做一步:
fn next(&mut self) -> Option<Event<'a>> {
match self.tree.cur() {
None => {
let ix = self.tree.pop()?;
// ...
let tag_end = body_to_tag_end(&self.tree[ix].item.body);
self.tree.next_sibling(ix);
Some(Event::End(tag_end))
}
Some(cur_ix) => {
// ...
if self.tree[cur_ix].item.body.is_maybe_inline() {
self.handle_inline();
}
let item = self.tree[cur_ix].item.clone();
let event = item_to_event(&item, self.text, &mut self.allocs);
if let Event::Start(ref _tag) = event {
self.tree.push();
} else {
self.tree.next_sibling(cur_ix);
}
Some(event)
// ...
}
}
}
产出 Start 就 push 下去,产出叶子事件就移到下一个兄弟;当前层走完(cur 为 None)就 pop 回父节点,产出它的 End。spine 在这里是「已经发了 Start、还没发 End 的标签」的栈,End 事件就是弹栈。整个遍历没有递归,也没有额外的栈分配:第一遍留下的 spine 已经空了,第二遍接着用同一个 Vec。遍历进度全在 cur 和 spine 两个字段里,spine 的长度就是当前的嵌套深度。
紧凑列表里的段落(TightParagraph)在这里被跳过:遇到它直接 push 进去产出孩子,pop 出来时不发 End。所以「紧凑列表的 <li> 里没有 <p>」是解析器决定的,事件流里本来就没有这层 Paragraph;TightParagraph 从哪来见容器块。
问: 第一遍建完树,第二遍只是读它吧?
答: 不是,第二遍也写树。handle_inline 解析一个块的行内元素时,直接把占位节点的 body 改掉(MaybeEmphasis 改成 Emphasis,MaybeHtml 改成 Link 等),改 next 把一段兄弟挂到新的父节点下面,必要时用 Tree::create_node 新建节点(例如自动链接里的文字),追加在 nodes 末尾。改动只发生在当前块的孩子上,而且发生在产出这些孩子的事件之前,所以「先建完再按需改」不会和已经发出去的事件冲突。
删和截
节点只增不删是常态,但有三个方法会改结构,各只有一两个调用点:
Tree::remove_node:弹出 spine,同时nodes.pop()删掉数组最后一项。它假设被删的正是最后建的节点,只在parse_atx_heading里用:标题去掉结尾的#和空格之后什么都不剩(### ###),就删掉那个空的文本节点。Tree::truncate_siblings:从父节点的第一个孩子往后走,把结束位置不早于给定字节偏移的孩子截短或断开。唯一的调用点在parse_setext_heading,开了ENABLE_HEADING_ATTRIBUTES且标题末尾有{#id .class}时,把已经当成文本追加进去的属性块切掉。Tree::truncate_to_parent:只在定义列表收尾的fixup_end_of_definition_list里用,见容器块。
上游 guide 的 dev/block-parsing.md 把 truncate_siblings() 描述成「End open blocks at a certain point」。代码里它不关闭任何块,只截当前父节点下面的孩子,调用点也只有上面这一个。
被「截掉」的节点并不从 Vec 里消失,只是没人再指向它。
ENABLE_HEADING_ATTRIBUTES 解析 Foo *bar* {#top}\n---\n(一个带属性的 Setext 标题),第一遍结束后 nodes 里有几项?从下标 1 出发顺着 child / next 能走到几项?用 blocka-dump 打印节点数组,答案在文末。
动脑筋答案
== nodes ==
0 child=- next=- 0..0 Root
1 child=2 next=- 0..21 Heading(H2, Some(HeadingIndex(1))) "Foo *bar* {#top}\n---\n"
2 child=- next=3 0..4 Text { backslash_escaped: false } "Foo "
3 child=- next=4 4..5 MaybeEmphasis(1, true, false) "*"
4 child=- next=5 5..8 Text { backslash_escaped: false } "bar"
5 child=- next=- 8..9 MaybeEmphasis(1, false, true) "*"
6 child=- next=- 9..16 Text { backslash_escaped: false } " {#top}"
== tree ==
Item { start: 0, end: 21, body: Heading(H2, Some(HeadingIndex(1))) }
Item { start: 0, end: 4, body: Text { backslash_escaped: false } }
Item { start: 4, end: 5, body: MaybeEmphasis(1, true, false) }
Item { start: 5, end: 8, body: Text { backslash_escaped: false } }
Item { start: 8, end: 9, body: MaybeEmphasis(1, false, true) }
7 项(含占位),能走到的是 5 项(1 到 5)。第一行还是段落时,parse_line 已经把 {#top} 当文本追加成了节点 6;第二行是 ---,段落变成二级标题,extract_and_parse_heading_attribute_block 解析出 {#top},内容在第 9 字节结束。truncate_siblings(9) 从第一个孩子往后找:节点 5 正好在 9 结束,于是把它的 next 设成 None。节点 6 还在数组里,只是从 5 那里断开了,Tree 自己的 Debug(下半段,按 child / next 递归)就看不到它。属性存进了 Allocations,标题体里只留一个 HeadingIndex(1)。
小结
Tree是三个字段:按创建顺序存放的Vec<Node>、从根到当前位置的spine、当前层最后一个节点cur。节点用「第一个孩子、下一个兄弟」两个下标相连,先序存放。TreeIndex包NonZeroUsize,Option<TreeIndex>8 字节;下标 0 放占位节点。实测Node<Item>48 字节,ItemBody16 字节,上游有测试守着这两个数。- 建树只有
append/push/pop;块的end在FirstPass::pop时补上。节点不存文本,只存字节范围,额外数据存成Allocations里的下标。 - 第二遍
reset后用同一个 spine 当显式栈:Start压栈,End弹栈,无递归;TightParagraph在这里被跳过。行内解析会就地改写当前块的孩子。 remove_node、truncate_siblings、truncate_to_parent各只有一两个调用点;截掉的节点留在Vec里,只是不再可达。