Rust in Everything
基于 pulldown-cmark 0.13.5

事件模型

主线位置:三条主线之间的接口。块主线建出一棵 Tree<Item>,行内主线在 Parser::next 里把树上的节点一个个换成 Event,输出主线(push_html,或本站的 RSX 渲染)只认 Event。这一页讲事件本身:有哪些、带什么数据、多大、字符串从哪来、into_offset_iter 给的字节范围指的是哪一段。

flowchart TB
  item["树节点 Item"] --> conv["item_to_event"]
  allocs["Allocations"] -->|"take 取走"| conv
  conv -->|"块或行内容器"| start["Start(Tag)"]
  conv -->|叶子| leaf["Text Code 等"]
  start --> push["spine 下降一层"]
  push --> fin["孩子走完"]
  fin --> endev["End(TagEnd)"]
  conv --> off["OffsetIter 加范围"]

事件定义都在 lib.rs(743 行),节点到事件的转换在 parse.rs 末尾。本页的输出来自实验 crate exp-pulldown-cmark/events-exp(M2 Max、macOS 27、rustc 1.99.0,release),例子文档是一份文档里那 21 行,选项 ENABLE_TABLES。

十三种事件

Event 的文档注释说得很直接:事件是文档树的先序遍历,每个内部节点的孩子走完以后再补一个 End。Start 和 End 保证成对。

事件携带需要的 Options来源
Start(Tag) / End(TagEnd)见下一节看具体的 Tag块和行内容器
Text(CowStr)一段文本无所有文本
Code(CowStr)行内代码,不含反引号无`code`
InlineMath(CowStr) / DisplayMath(CowStr)公式原文ENABLE_MATH$a$、$$b$$
Html(CowStr)HTML 块里的一行,带换行符无<div> 开头的块
InlineHtml(CowStr)一个行内标签无<span>
FootnoteReference(CowStr)脚注标签ENABLE_FOOTNOTES 或 ENABLE_OLD_FOOTNOTES[^n]
SoftBreak无无段内换行
HardBreak无无行尾两个空格或反斜杠
Rule无无***
TaskListMarker(bool)是否勾上ENABLE_TASKLISTS[x]

Text、Code、Html 这些叶子事件不进栈,产出后游标直接移到下一个兄弟;只有 Start 会让 Parser::next 调 tree.push() 走进孩子,孩子走完 tree.pop() 回来时才产出 End。怎么走树见扁平数组上的树。

紧凑列表里的段落是 ItemBody::TightParagraph,next 遇到它直接跳进孩子,不产出 Start(Paragraph) / End(Paragraph)。所以例子文档的两个列表项里,Item 下面直接就是 Text:

126..208 Start(List(None))
126..159 Start(Item)
128..158 Text(Borrowed("第一遍:扫行,建块树"))
126..159 End(Item)
...

Tag 与 TagEnd

Tag 有 23 个变体,TagEnd 和它一一对应,但只留下不带堆数据的部分:

Tag数据TagEnd需要的 Options
Paragraph无Paragraph无
Heading { level, id, classes, attrs }级别;id、classes、attrs 只在开了属性扩展时才有值Heading(HeadingLevel)属性要 ENABLE_HEADING_ATTRIBUTES
BlockQuote(Option<BlockQuoteKind>)[!NOTE] 这类提示块的种类BlockQuote(Option<BlockQuoteKind>)种类要 ENABLE_GFM
CodeBlock(CodeBlockKind)Indented 或 Fenced(info)CodeBlock无
HtmlBlock无HtmlBlock无
List(Option<u64>)有序列表的起始编号List(bool),true 表示有序无
Item无Item无
FootnoteDefinition(CowStr)标签FootnoteDefinitionENABLE_FOOTNOTES
DefinitionList、DefinitionListTitle、DefinitionListDefinition无同名ENABLE_DEFINITION_LIST
Table(Vec<Alignment>)每列的对齐TableENABLE_TABLES
TableHead、TableRow、TableCell无同名ENABLE_TABLES
Emphasis、Strong无同名无
Strikethrough无StrikethroughENABLE_STRIKETHROUGH
Superscript无SuperscriptENABLE_SUPERSCRIPT
Subscript无SubscriptENABLE_SUBSCRIPT
Link { link_type, dest_url, title, id }链接类型、地址、标题、引用标签Link无;WikiLink 类型要 ENABLE_WIKILINKS
Image { link_type, dest_url, title, id }同上Image无
MetadataBlock(MetadataBlockKind)--- 或 +++ 风格MetadataBlock(MetadataBlockKind)ENABLE_YAML_STYLE_METADATA_BLOCKS 或 ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS

几处要留意的地方,都用实验程序核对过:

  • 表格没有 TableBody。TableHead 结束后紧跟的 TableRow 就是表体;分隔行 |:-:|--:| 不产出任何事件,对齐只出现在 Start(Table(...)) 里。
  • 开了 ENABLE_SUBSCRIPT 后,单个波浪线包起来的文字变成 Subscript,两个波浪线才是 Strikethrough;不开它时,单个和两个波浪线都是 Strikethrough(Options::all() 和「除下标外全开」各跑一次对比)。
  • [!BOGUS] 这种不认识的提示块标记,BlockQuote 的种类是 None,标记本身留在段落里当文本。
  • LinkType 区分 Inline、Reference、Collapsed、Shortcut、Autolink、Email、WikiLink { has_pothole },再加三个 ...Unknown:引用找不到定义、由 BrokenLinkCallback 补上地址时用(链接与图片)。id 是引用链接的标签,例子文档的 [CommonMark 规范][spec] 产出 id: Borrowed("spec")。

TagEnd 只保留后续处理真正用得着的东西:标题级别(写 </h2> 要用)、有序还是无序(写 </ol> 还是 </ul>)、提示块和元数据块的种类。Tag::List 的起始编号、链接地址这些只在开头有用的数据不进 TagEnd。Tag::to_end 做这个投影,解析器自己则用 parse.rs 里的 body_to_tag_end 直接从节点类型算出 TagEnd,不经过 Tag。它的大小有一个编译期断言守着:

/// Make sure `TagEnd` is no more than two bytes in size.
/// This is why it's used instead of just using `Tag`.
#[cfg(target_pointer_width = "64")]
const _STATIC_ASSERT_TAG_END_SIZE: [(); 2] = [(); std::mem::size_of::<TagEnd>()];

两个数组类型长度不同就编译不过,所以 TagEnd 在 64 位目标上必须正好 2 字节:一个字节是变体,一个字节给 HeadingLevel、bool、Option<BlockQuoteKind> 或 MetadataBlockKind。

📝NOTE
冷知识:End 曾经带着整个 Tag

0.10.0 之前,Event::End 的定义是 End(Tag<'a>)。parse.rs 里有一个 item_to_tag,产出 Start 和 End 时各调一次,链接地址、标题都 clone() 一份,表格对齐的 Vec 也复制一份。PR #517(提交 94cee9d「Add separate enum for Tag ends (#517)」,首次发布于 0.10.0)加了 TagEnd,删掉 item_to_tag,换成 body_to_tag_end;同时给 Allocations 加了 take_cow、take_link、take_alignment:Start 把数据从表里取走,End 不再需要它。后来 PR #926 给 TagEnd::BlockQuote 加上提示块种类,提交 64c2df4 顺手加了上面那条静态断言,提交说明写的是「This prevents us from accidentally growing it (it was 2 both before and after adding the BlockQuoteKind to it).」,首次发布于 0.11.1。

标题是个例外:item_to_event 对 ItemBody::Heading(level, Some(ix)) 用的是 allocs.index(heading_ix) 再 clone() id、classes、attrs,没有取走。带属性的标题每产出一次 Start,两个 Vec 就各复制一次。

main 上(下一个大版本)Tag 又多了 Highlight 和 ContainerBlock,0.13.5 里没有。

一个事件多大

实验 sizes 在本机上打印(节选):

Event<'static>                         size  80  align 8
Tag<'static>                           size  80  align 8
TagEnd                                 size   2  align 1
CowStr<'static>                        size  24  align 8
LinkType                               size   1  align 1
Range<usize>                           size  16  align 8
(Event<'static>, Range<usize>)         size  96  align 8
(LinkType, CowStr<'static>, CowStr<'static>, CowStr<'static>) size  80  align 8

Tag 里最大的是 Link / Image:三个 CowStr 72 字节,加 1 字节的 LinkType,对齐到 80。Heading 是一个 Option<CowStr> 加两个 Vec,也是 72 字节加 1 字节。Event 没有比 Tag 更大:Tag 的布局里还有用不到的位模式(niche),编译器拿它们给 Event 的其他变体当判别值,所以同为 80 字节。LinkType 带一个 bool 字段的 WikiLink 也只占 1 字节,原因相同。

一个 Text 事件里真正有用的是 24 字节的 CowStr,其余 56 字节是为 Link 留的。逐个消费事件时这不要紧,事件在寄存器和栈上来回传;把整份文档 collect 成 Vec<Event> 时,每个事件就是实打实的 80 字节,本站 crates/widgets/src/mdx.rs 就是这么做的。into_offset_iter 每项再加 16 字节的范围,是 96 字节。

字符串借自输入

事件里的字符串都是 CowStr:Borrowed(&'a str)、Inlined(InlineStr)、Boxed(Box<str>) 三选一,'a 是输入文本的生命周期。例子文档的 72 个事件里,所有字符串都是 Borrowed,包括这一个:

240..245 Text(Borrowed("&"))

源文是 &amp;,事件里的 & 却不是从输入里切出来的:命名实体查的是 entities.rs 里的静态表,scan_entity 返回的是 CowStr<'static>,借的是那张表里的 &'static str。三个变体各在什么时候出现、什么时候真的分配,见 CowStr;和 serde 借用反序列化的 'de 是同一个思路(生命周期 'de 与借用)。

into_static

借用意味着事件活不过输入。要把事件存起来、跨线程送走,就用 Event::into_static:

    pub fn into_static(self) -> Event<'static> {
        match self {
            Event::Start(t) => Event::Start(t.into_static()),
            Event::End(e) => Event::End(e),
            Event::Text(s) => Event::Text(s.into_static()),
            Event::Code(s) => Event::Code(s.into_static()),
            // ...
            Event::TaskListMarker(b) => Event::TaskListMarker(b),
        }
    }

End 原样返回,这正是 TagEnd 没有生命周期参数的好处。Tag::into_static 和 CodeBlockKind::into_static 逐字段转换,最后都落到 CowStr::into_static:Borrowed 不超过 22 字节就拷进 Inlined,更长的复制成 Boxed,这一步要分配。这组方法来自 PR #967(提交 cdf7beb「pulldown-cmark: impl into_static for all lifetime based pub type」,首次发布于 0.12.2)。

实测:例子文档逐个 into_static 多了 5 次分配,正好是 5 个超过 22 字节的字符串(34 字节的 pulldown-cmark 把 Markdown 变成、35 字节的链接地址、两段 30 字节的中文、60 字节的代码块正文);15 字节的链接标题 CommonMark Spec 进了 Inlined。在本站内容仓库的 1099 篇文档上,分配次数从 8455 涨到 161939,解析时间从 42.73 ms 涨到 54.30 ms(20 次取最小)。详细的计数方法见一次解析分配了什么。

偏移:into_offset_iter

Parser::into_offset_iter 把解析器包成 OffsetIter。它的 next 和 Parser::next 几乎逐行相同,只是把节点的 item.start..item.end 一起返回。End 用的是同一个节点,所以 End 的范围和对应 Start 的范围完全相同,不是「结束标记那几个字节」。

范围就是第一遍、第二遍给节点记下的字节区间。用一份覆盖各种语法的文档(实验 variants)逐个核对,规律是:

事件范围覆盖例子(源文 → 范围对应的源文)
Heading从 # 到行尾换行符# Head 那一整行,含 \n
标题里的 Text只有文字Head
Emphasis、Strong、Strikethrough含两侧定界符*em*、**st**
Code、InlineMath含反引号、美元符`code`
Link、Image整个链接语法[ln](/u "t");引用链接是 [CommonMark 规范][spec]
Paragraph从首行内容到末行换行符引用块里的段落从 > 之后开始,后续行的 > 包含在内
实体 Text整个实体源文 &amp;,文本是 &
反斜杠转义反斜杠不在任何范围里a\*b 产出 a(2..3)和 *b (4..7),第 3 字节没人要
围栏代码块Start 含两道围栏,Text 只含代码行282..353 对 290..350
缩进代码块从缩进之后开始四个空格不在范围里
TableHead表头那一行分隔行没有事件
Item / List含列表标记;列表含末尾空行2. two\n\n
MetadataBlock两道分隔线,末尾换行不含---\ntitle: x\n---
SoftBreak / HardBreak换行符 / 两个空格加换行符\n、 \n
TaskListMarker[ ] 三个字节

所以范围能回答「这个事件来自源文的哪里」,不能拿来还原文本:text[range] 和事件里的字符串只在普通文本、代码块正文这类借用输入的情况下相等。

合并文本之后

TextMergeWithOffset 是带偏移的 TextMergeStream,把连续的 Text 拼成一个。拼接后的范围取第一个的 start 和最后一个的 end(next 里的 offset.end = next_offset.end),中间被跳过的字节也算进去:

    2..14 [X] "a*b & c"   src="a\\*b &amp; c"

文本 7 个字节,范围 12 个字节,范围里多了反斜杠和 amp;。[X] 表示合并出来的字符串是 Boxed:合并一定新分配一个 String,见一次解析分配了什么。上游自己的单元测试也写着这一点:缩进代码块两行合并后范围是 5..32,包含了第二行行首的四个空格。

❗IMPORTANT
动脑筋:a [b] c &amp; d 不开任何选项,产出几个 Text 事件?

没有 [b] 的定义,它不是链接。答案在文末。

动脑筋答案

实验 quiz 的输出:

Text events: 7
  Text(Borrowed("a "))
  Text(Borrowed("["))
  Text(Borrowed("b"))
  Text(Borrowed("]"))
  Text(Borrowed(" c "))
  Text(Borrowed("&"))
  Text(Borrowed(" d"))

7 个。第一遍扫行时,[、] 各成一个 MaybeLinkOpen / MaybeLinkClose 节点,&amp; 成一个 SynthesizeText 节点,其余文字成 Text 节点。第二遍找不到 [b] 的定义,两个方括号节点就地改成 Text,但不会和左右的 Text 合并,所以一句话被切成七段。第一遍的 append_text 只合并字节上紧挨着的普通文本;按事件做搜索替换的代码要么自己处理这种切分,要么先套一层 TextMergeStream。

小结

  • 13 种 Event,Start / End 成对;23 种 Tag,扩展语法的变体只在开了对应 Options 时出现。紧凑列表不产出 Paragraph,表格没有 TableBody。
  • TagEnd 只留级别、有序与否、种类这些小值,静态断言保证 2 字节;PR #517(0.10.0)之前 End 带整个 Tag,每个链接的地址要复制两次。
  • 实测 Event、Tag 都是 80 字节(由 Link 的三个 CowStr 决定),CowStr 24 字节,带范围的一项 96 字节。
  • 字符串默认借用输入(实体借用静态表);into_static 把 22 字节以内的拷进 Inlined、更长的复制到堆上,本站语料上多出约 15 万次分配。
  • into_offset_iter 返回节点的字节区间:End 与 Start 范围相同;实体、转义、合并后的文本,范围和文本长度对不上。

💬 关联讨论

发起讨论
加载中...