Rust in Everything
基于 pulldown-cmark 0.13.5

扁平数组上的树

主线位置:块主线的数据结构。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 项(含占位)。

📝NOTE
冷知识:从 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 从哪来见容器块。

📝NOTE
没有蠢问题

问: 第一遍建完树,第二遍只是读它吧?

答: 不是,第二遍也写树。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 里消失,只是没人再指向它。

❗IMPORTANT
动脑筋:开 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 字节,ItemBody 16 字节,上游有测试守着这两个数。
  • 建树只有 append / push / pop;块的 end 在 FirstPass::pop 时补上。节点不存文本,只存字节范围,额外数据存成 Allocations 里的下标。
  • 第二遍 reset 后用同一个 spine 当显式栈:Start 压栈,End 弹栈,无递归;TightParagraph 在这里被跳过。行内解析会就地改写当前块的孩子。
  • remove_node、truncate_siblings、truncate_to_parent 各只有一两个调用点;截掉的节点留在 Vec 里,只是不再可达。

💬 关联讨论

发起讨论
加载中...