Rust in Everything
基于 pulldown-cmark 0.13.5

按需解析

主线位置:行内主线的入口。第一遍在每个段落、标题、表格单元格里留下一串节点,其中可能是行内语法的字节记成 Maybe* 占位;Parser::next 走到占位时调 handle_inline,先 handle_inline_pass1(代码段、HTML、自动链接、数学、链接),再 handle_emphasis_and_hard_break(强调、智能引号),把这条兄弟链就地改写成最终节点,然后照常产出事件。

flowchart TB
  fp["第一遍 parse_line"] --> chain["块内兄弟链"]
  chain --> nx["Parser next"]
  nx --> q{"是 Maybe 占位"}
  q -->|否| ev["item_to_event"]
  q -->|是| hi["handle_inline"]
  hi --> p1["pass1 代码 HTML 链接"]
  p1 --> p2["强调与引号"]
  p2 --> ev
  ev --> out["Event"]

实验平台:M2 Max、macOS 27、rustc 1.99.0,release 构建。实验 exp-pulldown-cmark/inline-probe 依赖一份 0.13.5 源码的本地拷贝(inline-vendor),在 handle_inline、两趟的循环和分隔符栈里加了计数器,另有几个开关把优化关掉做对照(补丁脚本 inline-scripts/patch.py,解析逻辑不动);树的打印沿用一份文档里的 Debug 补丁。

第一遍留下的占位

ItemBody 的前 8 个变体就是占位,注释写着「These are possible inline items, need to be resolved in second pass」:

占位来自字段由谁解决
MaybeEmphasis(usize, bool, bool)*、_,开了扩展再加 ~、^剩余长度、能开、能闭强调趟
MaybeSmartQuote(u8, bool, bool)'、"(智能标点)引号字节、能开、能闭强调趟
MaybeMath(bool, bool, u8)$(数学)能开、能闭、花括号上下文pass1
MaybeCode(usize, bool)一串反引号反引号个数、前面是否有反斜杠pass1
MaybeHtml<无pass1
MaybeLinkOpen[无pass1
MaybeImage![无pass1
MaybeLinkClose(bool)]前面一段还能不能当引用标签pass1

占位由 FirstPass::parse_line 追加,它只看 special_bytes 标记过的字节。这张 256 项的表按 Options 生成:固定的 11 个字节是换行两种、*、_、&、\、[、]、<、!、`;开表格加 |,开删除线或下标加 ~,开上标加 ^,开数学加 $、{、},开智能标点加 .、-、"、'。没开的扩展,它的字节连看都不看:不开 ENABLE_MATH 时 $ 只是 Text 中间的一个字节,树里没有任何节点。

哪些东西做成占位,有一条清楚的界线:要配对、要往后看的才留到第二遍,只看邻居就能定的当场定。

  • 一串 * 能不能开、能不能闭(左右侧,left-flanking / right-flanking)只看这串前后各一个字符,第一遍就算好,存进 MaybeEmphasis 的两个 bool(规则见强调与分隔符栈)。它和哪一串配对,要看整条链,留到第二遍。
  • 实体 &amp;、反斜杠转义、软硬换行、智能标点的 ... 和 -- 不需要配对,第一遍就变成 SynthesizeText、SynthesizeChar、Text { backslash_escaped: true }、SoftBreak、HardBreak,不是占位(代码段、HTML、实体)。
  • 一串 ** 记成两个节点:MaybeEmphasis(2, ..) 和 MaybeEmphasis(1, ..),第一个字段是「从这里到串尾还剩几个」。配对时按需从串里切走一个或两个,不用拆节点。

next 走到占位才动手

Parser::next 的「有当前节点」那一支:

            Some(cur_ix) => {
                let maybe_cur_ix =
                    if matches!(self.tree[cur_ix].item.body, ItemBody::TightParagraph) {
                        // tight paragraphs emit nothing
                        self.tree.push();
                        self.tree.cur()
                    } else {
                        Some(cur_ix)
                    };
                if let Some(cur_ix) = maybe_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)
                } else {
                    None
                }
            }

is_maybe_inline 就是对上面 8 个变体的 matches!。handle_inline 返回之后,cur_ix 指的那个节点已经被改写:可能变成 Code、Link、Emphasis,也可能退成 Text。紧接着的 item_to_event 只是按改写后的 ItemBody 翻译成事件,它根本不知道这里刚发生过行内解析。

例子文档的第二个列表项 - 第二遍:`Parser::next` 按需解析行内,在第一遍之后、取完事件之后各打印一次(inline-probe tree):

== after first pass
...
    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 } }
...
== after draining 72 events
...
    Item { start: 161, end: 207, body: TightParagraph }
      Item { start: 161, end: 173, body: Text { backslash_escaped: false } }
      Item { start: 173, end: 187, body: Code(CowIndex(2)) }
      Item { start: 187, end: 206, body: Text { backslash_escaped: false } }
...

next 先产出 Text("第二遍:")(它不是占位),移到 173 那个 MaybeCode,这时才调 handle_inline。开反引号节点被改成 Code,范围扩到 173..187,next 指针跳过中间的文本和闭反引号,代码内容存进 Allocations 的第 2 个 CowStr。被跳过的两个节点还在数组里,只是没人指向它们。

两趟,顺序有讲究

handle_inline 只有两行:

    fn handle_inline(&mut self) {
        self.handle_inline_pass1();
        self.handle_emphasis_and_hard_break();
    }

CommonMark 规定的优先级是:代码段、HTML 标签、自动链接最高,然后是链接,最后是强调。handle_inline_pass1 的文档注释说明了为什么链接也放在第一趟:

This function handles both inline HTML and code spans, because they have the same precedence. It also handles links, even though they have lower precedence, because the URL of links must not be processed.

第一趟从左到右走一遍链:遇到 ` 就往后找同长度的反引号,遇到 < 就试自动链接和 HTML,遇到 [ 压进链接栈,遇到 ] 就试着闭合链接。谁先出现谁先占位,代码段吞掉的 [、* 不再是占位,链接的 (/u) 部分被跳过、不会被当成强调或别的语法。第二趟 handle_emphasis_and_hard_break 只处理第一趟留下的 MaybeEmphasis 和 MaybeSmartQuote,外加一件小事:段落最后一个节点如果是反斜杠硬换行 HardBreak(true),后面没有下一行,就退成字面的 \。

两趟都只沿 next 指针走:第一趟把一段节点挂成链接的孩子之后,第二趟在顶层只看到一个 Link 节点,看不到里面的 *。所以强调不能跨越链接的边界:

*[a*](/u)          →  <p>*<a href="/u">a*</a></p>
[*a*](/u)          →  <p><a href="/u"><em>a</em></a></p>

谁也没被解决的占位最后都变成 Text { backslash_escaped: false }:第一趟里找不到配对的当场改,第二趟结束时 InlineStack::pop_all 把栈里剩下的开分隔符全部改成文本。所以 handle_inline 之后,它走过的这条链上不会再有占位(被挂成链接孩子的那一段除外,见下一节)。

一次调用管一条链

handle_inline 从 tree.cur() 开始,沿 next 走到链尾。第一个占位之前的节点已经产出了,不受影响;第一个占位之后的整条链在这一次调用里全部处理完。之后 next 再走到这些节点时,它们已经不是占位,不会再触发。

链接是例外。下面这个段落有几次 handle_inline?

[*a*](/u) *b*

先猜,再看文末的动脑筋答案。

一段 *a* 重复 1000 次(4000 字节)的段落,计数器读数是 handle_inline=1:1000 对强调在同一次调用里配完,第二趟走了 3999 个节点,分隔符栈的 find_match 调了 1000 次、一共只比较了 1000 个栈元素。

例子文档 11 个能含行内内容的块(标题、段落、紧凑段落、表格单元格)里有 6 个带占位,正好 6 次调用(一份文档里打印过)。换成本站内容仓库的全部 Markdown(1139 个文件,去掉 frontmatter 后 18.6 MB,用本站开的 7 个 Options):

files=1139 bytes=18637073 events=729781 first_pass_nodes=1047555 inline_blocks=78083 placeholders=460605
handle_inline=53381 pass1_nodes=470561 emph_nodes=404816 find_match_calls=8106 find_match_scanned=7347 code_scan_steps=391298 math_scan_steps=2
  • 78083 个能含行内内容的块,handle_inline 一共调了 53381 次(含链接、强调里面的子链);46 万个占位平均每次调用 8.6 个。
  • 第一趟走了 47 万个节点,第二趟 40 万,和第一遍建出的 105 万个节点同一量级:每个节点在第二遍里被看一两次。
  • 分隔符栈 8106 次查找一共只比较了 7347 个元素。找代码段闭合反引号的扫描走了 39 万步,比强调多得多:本站文章的行内代码很多,每个开反引号都要往后找。

两趟用到的栈和表(inline_stack、link_stack、wikilink_stack、code_delims、math_delims)是 Parser 的字段,注释是「used by inline passes. store them here for reuse」。每趟结束时 clear(),容量留着给下一个块用,所以第二遍在稳定之后几乎不再分配(分配的来源见一次解析分配了什么)。

为什么能流式

行内解析只在迭代器走到那个块时才发生,一个块的结果不依赖后面的块。唯一需要「整份文档」的信息是链接引用定义,它在第一遍就收齐了(表格与脚注)。

  • 范围只到块尾。 第一趟开头取 block_end = self.tree[self.tree.peek_up().unwrap()].item.end,所有扫描(代码段的内容、链接地址、HTML 标签)都在 &self.text[..block_end] 里做。一个块里的 ` 不会和下一个段落的 ` 配对。
  • 消费方停下,后面就不做。 Parser::new_ext(text, opts) 返回时第一遍已经跑完,此后每个 next 只做到当前位置所需的行内工作。在 spec.txt(205 KB)上,new_ext 本身 0.278 ms,取到第一个事件 0.274 ms,取完全部 10014 个事件 0.477 ms(各 10 次取最小,inline-run135 lazy):第一个事件只需要等第一遍,剩下的行内工作摊进之后的每个 next。第一遍为什么必须整份做完,见为什么是 pull parser。
  • 改写而不是重建。 第二遍把占位节点就地改成最终节点,摘掉不再需要的节点,基本不追加新节点(例外只有自动链接和 wikilink 的文本节点)。

动脑筋答案

2 次。inline-probe 在 handle_inline 开头打印调用位置,和事件交错:

0..14 Start(Paragraph)
  [handle_inline: block Paragraph 0..14, starting at node 0..1 MaybeLinkOpen]
0..9 Start(Link { link_type: Inline, dest_url: Borrowed("/u"), title: Borrowed(""), id: Borrowed("") })
  [handle_inline: block Link(LinkIndex(0)) 0..9, starting at node 1..2 MaybeEmphasis(1, true, false)]
1..4 Start(Emphasis)
2..3 Text(Borrowed("a"))
1..4 End(Emphasis)
0..9 End(Link)
9..10 Text(Borrowed(" "))
10..13 Start(Emphasis)
11..12 Text(Borrowed("b"))
10..13 End(Emphasis)
0..14 End(Paragraph)
  • 第一次从段落的第一个节点 MaybeLinkOpen 开始。第一趟认出内联链接,把 *a* 那三个节点挂成 Link 的孩子;第二趟在顶层配好了 *b*,但看不到链接里面。
  • next 产出 Start(Link) 之后 push 进孩子,当前节点是 MaybeEmphasis,于是第二次调用,这次的「块」是 Link 节点本身(peek_up 拿到的是它),处理范围是孩子链。
  • 计数器:handle_inline=2 pass1_nodes=12 emph_nodes=8。孩子链在第一次的 pass1 里已经走过一遍,第二次又走了一遍,所以 pass1 一共看了 12 个节点,比链上的 9 个多。

小结

  • 第一遍只把需要配对的语法记成 8 种 Maybe* 占位;能当场判断的(左右侧、实体、转义、换行)当场定。哪些字节会被看,由 Options 生成的 special_bytes 决定。
  • Parser::next 遇到 is_maybe_inline() 的节点才调 handle_inline,之后照常 item_to_event。
  • handle_inline 两趟:pass1 按出现顺序处理代码段、HTML、自动链接、数学和链接(链接放这里是为了不处理 URL),第二趟用分隔符栈配强调和智能引号;没配上的占位全部退成 Text。
  • 一次调用改写一条兄弟链;链接把节点挂成孩子后,孩子链里的强调要等 next 走进去再调一次。
  • 本站语料 1139 个文件:7.8 万个行内块,5.3 万次调用,第二遍每个节点只被看一两次。

💬 关联讨论

发起讨论
加载中...