事件模型
主线位置:三条主线之间的接口。块主线建出一棵
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) | 标签 | FootnoteDefinition | ENABLE_FOOTNOTES |
DefinitionList、DefinitionListTitle、DefinitionListDefinition | 无 | 同名 | ENABLE_DEFINITION_LIST |
Table(Vec<Alignment>) | 每列的对齐 | Table | ENABLE_TABLES |
TableHead、TableRow、TableCell | 无 | 同名 | ENABLE_TABLES |
Emphasis、Strong | 无 | 同名 | 无 |
Strikethrough | 无 | Strikethrough | ENABLE_STRIKETHROUGH |
Superscript | 无 | Superscript | ENABLE_SUPERSCRIPT |
Subscript | 无 | Subscript | ENABLE_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。
End 曾经带着整个 Tag0.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("&"))
源文是 &,事件里的 & 却不是从输入里切出来的:命名实体查的是 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 | 整个实体 | 源文 &,文本是 & |
| 反斜杠转义 | 反斜杠不在任何范围里 | 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 & c"
文本 7 个字节,范围 12 个字节,范围里多了反斜杠和 amp;。[X] 表示合并出来的字符串是 Boxed:合并一定新分配一个 String,见一次解析分配了什么。上游自己的单元测试也写着这一点:缩进代码块两行合并后范围是 5..32,包含了第二行行首的四个空格。
a [b] c & 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 节点,& 成一个 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决定),CowStr24 字节,带范围的一项 96 字节。 - 字符串默认借用输入(实体借用静态表);
into_static把 22 字节以内的拷进Inlined、更长的复制到堆上,本站语料上多出约 15 万次分配。 into_offset_iter返回节点的字节区间:End与Start范围相同;实体、转义、合并后的文本,范围和文本长度对不上。