Rust in Everything
基于 pulldown-cmark 0.13.5

怎么读这一卷

主线位置:三条主线的总览。本页先说明锁的是哪个提交、它和 0.13.4 差在哪;再把块、行内、输出三条主线各拆成几步,标出每一步落在哪个函数、哪一页讲;然后给出全卷共用的例子文档和实验环境;最后核对 Cargo.toml 的 feature 和 Options 的每一位。三条线在例子文档上完整跑一遍,见一份文档。

flowchart TB
  start[从这里开始] --> q{读这一卷为了什么}
  q -->|会用| use1["01 全景 再到 05"]
  q -->|读懂实现| all[00 到 09 按顺序]
  q -->|只关心块| b["一份文档 再到 02"]
  q -->|只关心行内| i["一份文档 再到 03"]

锁定版本

仓库是一个 Cargo 工作区:解析器在 pulldown-cmark/,转义在 pulldown-cmark-escape/,另有 bench、dos-fuzzer、fuzz 三个不发布的成员(见源码地图)。本卷锁 0.13.5:

项本卷说明
tagv0.13.5 → 48a2767(2026-10-21)轻量 tag,直接指向合并 #1181 的提交;crates.io 包里 .cargo_vcs_info.json 记的也是它
pulldown-cmark-escape0.11.0crates.io 上的 0.11.0 发布自 d7632ac(2024-05-15),这个目录到 v0.13.5 一行没改
分支branch_0.13,从 0.13.0(f17d98a,2025-02-12)分出v0.13.5 不是 main 的祖先;查「发版之后改了没有」用 git diff v0.13.5 origin/main -- <路径>,不能用 v0.13.5..origin/main
main分出之后又有 262 个提交,到 29d6cfa(2026-10-28)下一个大版本,本卷不跟;某页讲的代码在 main 上改过,会提一句
本站0.13.4(38e4d08,2026-05-20)见 08 在本站中

源码链接一律指向 tag:https://github.com/pulldown-cmark/pulldown-cmark/blob/v0.13.5/pulldown-cmark/src/<文件>#L<行>,行号是条目开始的那一行。本地仓库的工作区在 main 上,读 tag 上的代码一律 git show "v0.13.5:pulldown-cmark/src/parse.rs"。

0.13.5 比 0.13.4 多了什么

git log v0.13.4..v0.13.5 有 12 个提交:一次版本号提升、一个直接合进分支的 #1101(只加了一条崩溃回归测试),其余 8 个都是「cherry-pick pull request #NNNN commits into 0.13 branch」。摘过来的提交说明只有这一句,原来的 PR 要到 main 上找合并提交:

PRmain 上的合并说明改了哪里
#1021Correctly parse all-spaces line between refdef and paragraphfirstpass.rs 5 行;build.rs 加了定义列表的例子前缀,tests/suite/ 全部重新生成
#1046Fix confusing lifetime syntax in fuzzer只改 fuzz/
#1102Fix: wikilink with pipe separator leaks child node's next chain into sibling contentparse.rs 的 handle_wikilink
#1111Fix subtract-overflow panic in handle_wikilink on malformed inputparse.rs
#1136Fix quadratic parsing with a [^ on every line when footnotes are enabledfirstpass.rs
#1143Fix panic on definition-list offsets inside a combining characterfirstpass.rs、parse.rs
#1148Skip the container scan on consecutive blank linesscan_containers 加了一个参数,List 的第一个字段从 bool 变成 Cell<ListFlags>
#1155Fix quadratic parsing of a line of *a_parse.rs 的分隔符栈

8 个里 3 个修平方复杂度,2 个修 panic(wikilink 里的减法溢出、定义列表在组合字符里的偏移),2 个修解析结果(带竖线的 wikilink、链接定义和段落之间的全空格行),1 个只改 fuzz 目标。wikilink、脚注、定义列表那几个只在开了对应扩展时才碰得到;#1021、#1155、#1148 不需要任何扩展,分别落在链接定义、强调和普通列表上。src/ 的净变化是 firstpass.rs 和 parse.rs 共 231 行增、78 行删。#1148 的实测在一份文档。

例子文档

全卷的块主线和行内主线用同一份文档(21 行,502 字节),各页打印的树、事件流、字节范围都出自它。它覆盖标题、强调、加粗、带引用定义的链接、紧凑列表、行内代码、引用块(第二行是惰性续行)、命名实体、围栏代码块和带对齐的表格:

# 拉取式解析:*pull* parser

pulldown-cmark 把 Markdown 变成**事件流**,
规则见 [CommonMark 规范][spec]。

- 第一遍:扫行,建块树
- 第二遍:`Parser::next` 按需解析行内

> 引用块里的 _强调_ 和 A&amp;B,
惰性续行也算引用块。

```rust
let parser = Parser::new_ext(text, Options::ENABLE_TABLES);
```

| 遍 | 产出 |
| :-- | --: |
| 第一遍 | `Tree<Item>` |
| 第二遍 | `Event` |

[spec]: https://spec.commonmark.org/0.31.2/ "CommonMark Spec"

解析时只开 Options::ENABLE_TABLES:表格是扩展,不开的话最后五行是一个普通段落。中文每个字占 3 个字节,所以事件里的字节范围比字数大;比如第一行的 *pull* 在 20..26。

三条主线

块主线:一份文档建成树

flowchart TB
  newext["Parser new_ext"] --> rfp["run_first_pass"]
  rfp --> run["FirstPass run"]
  run --> pb["parse_block 每行"]
  pb --> sc["scan_containers"]
  sc --> pl["开容器 再开叶子块"]
  pl --> tree["Tree of Item"]
  pl --> allocs["Allocations"]
步骤源码讲它的页
1. Parser::new_ext 转给 new_with_broken_link_callback,第一件事就是跑完第一遍parse.rs一份文档
2. run_first_pass 按 max(128, 文本长度 / 32) 预分配节点,FirstPass::run 循环调 parse_block 直到文本结束firstpass.rs扁平数组上的树、一行一行
3. FirstPass::parse_block 先用 scan_containers 看已打开的引用块、列表项、脚注还有几层能继续,关掉其余的firstpass.rs、parse.rs、scanners.rs一行一行
4. 识别新的容器(脚注定义、列表标记、引用标记),再由 parse_line 等决定叶子块:段落、标题、代码块、HTML 块、分隔线、表格firstpass.rs叶子块、容器块、表格与脚注
5. 块里的文本切成节点链,可能是行内语法的字节记成 MaybeEmphasis、MaybeCode 等占位;链接引用定义收进 Allocations 的 refdefs,不进树firstpass.rs、parse.rs 的 Allocations表格与脚注、一次解析分配了什么

行内主线:一个段落变成事件

flowchart TB
  nx["Parser next"] --> cur{"当前节点是 Maybe 占位"}
  cur -->|否| ev["item_to_event"]
  cur -->|是| hi["handle_inline"]
  hi --> p1["handle_inline_pass1"]
  p1 --> emph["handle_emphasis_and_hard_break"]
  emph --> ev
步骤源码讲它的页
1. Parser::next 沿树深度优先走:有当前节点就产出它,没有就弹出父节点产出 Endparse.rs、tree.rs按需解析
2. 当前节点 is_maybe_inline() 时调 handle_inline,处理从这个节点到这条兄弟链末尾的全部占位parse.rs按需解析
3. handle_inline_pass1:代码段、行内 HTML、自动链接、链接与图片的方括号parse.rs、linklabel.rs、scanners.rs链接与图片、代码段、HTML、实体
4. handle_emphasis_and_hard_break:用分隔符栈 InlineStack 配对 *、_、~,处理硬换行parse.rs强调与分隔符栈、数学与智能标点
5. item_to_event 把节点变成 Event:普通文本切原文成 CowStr::Borrowed,转义和实体从 Allocations 里取parse.rs、strings.rs事件模型、CowStr

输出主线:事件变成 HTML

步骤源码讲它的页
1. push_html、write_html_io、write_html_fmt 都是建一个 HtmlWriter 再 runhtml.rsHTML 写出器
2. HtmlWriter::run 逐个取事件,Start 交给 start_tag,End 交给 end_tag,文本转义后写出html.rsHTML 写出器
3. 正文用 escape_html_body_text,属性用 escape_html,链接地址用 escape_href;Event::Html 原样写出pulldown-cmark-escape转义
4. 不写 HTML 时,在迭代器上 map、filter,或用 TextMergeStream 合并相邻文本utils.rs改写事件流

三条线的交接点是 Parser:块主线在构造函数里跑完,行内主线藏在 next 里,输出主线只是 next 的一个消费者。本站换掉的是第三条线,见 08 在本站中。

实验环境

项本卷
平台M2 Max、macOS 27、rustc 1.99.0,aarch64;计时用 release,取多次里的最小值,注明次数和当时的负载(同机还有别的实验在跑)
主 cratecrates.io 上的 pulldown-cmark =0.13.5,默认 feature 加 simd;对照本站时用 =0.13.4
对照comrak 0.59(建 AST)、markdown 1.0(markdown-rs,建 mdast),只在为什么是 pull parser 和设计取舍 里用
SIMD只在 target_arch = "x86_64" 且开 simd 时编译;本机跑的都是标量路径。比较两者时编 x86_64-apple-darwin、在 Rosetta 下跑,只看相对差
语料仓库 tag 上的 third_party/CommonMark/spec.txt(CommonMark 0.31.2,652 个例子)、third_party/GitHub/*.txt、specs/*.txt;中文用本站内容仓库的 1099 篇 Markdown(18.5 MB),页面上只给统计
例子文档上一节那 21 行,只开 ENABLE_TABLES

Tree 是 crate 私有的,公开 API 拿不到它的 Debug 输出。本卷打印树用的是一份 0.13.5 源码的本地拷贝,只加了几行把 Tree 的 Debug 暴露出来,解析逻辑一行没动;用到它的页会注明。

feature 各打开什么

Cargo.toml 的 [features] 有四个,默认开 getopts 和 html:

feature打开什么说明
html(默认)可选依赖 pulldown-cmark-escape,编译 html 模块push_html 等三个函数都在这个模块里
getopts(默认)可选依赖 getopts只给 [[bin]] 命令行工具用(required-features = ["getopts"]),库本身不用它;作为依赖时它照样被编进来
simdpulldown-cmark-escape?/simd,并让 firstpass.rs 里的 mod simd 参与编译两处都要求 target_arch = "x86_64";没开时 lib.rs 是 forbid(unsafe_code),开了才允许 unsafe
gen-tests让 build.rs 从 spec 文件重新生成 tests/suite/*.rs不开时 build.rs 什么都不做,生成的测试文件已经提交在仓库里

[features] 里没有 serde,但 serde 是可选依赖,Cargo 会自动生成同名 feature;打开它,Event、Tag、TagEnd、LinkType 等公开枚举会派生 Serialize、Deserialize(#[cfg_attr(feature = "serde", ...)]),CowStr 则在 strings.rs 里手写了这两个实现,序列化成普通字符串。

README 的「Build options」一节说不想要命令行工具就 cargo add pulldown-cmark --no-default-features。这样做会把 html 一起关掉:

error[E0433]: cannot find `html` in `pulldown_cmark`
  --> src/main.rs:4:19
   |
 4 |   pulldown_cmark::html::push_html(&mut out, pulldown_cmark::Parser::new("hello"));
   |                   ^^^^ could not find `html` in `pulldown_cmark`
   |
note: found an item that was configured out
...
83 | #[cfg(feature = "html")]
   |       ---------------- the item is gated behind the `html` feature

只想去掉 getopts 又要输出 HTML,写 default-features = false, features = ["html"]。

Options 每一位

扩展全在 Options 这个 bitflags 里,Parser::new 等于 new_ext(text, Options::empty()),只认 CommonMark。下表的测试数是 tests/suite/ 里对应文件的 #[test] 个数:

常量位扩展spec 文件(测试数)
ENABLE_TABLES1GFM 表格third_party/GitHub/gfm_table.txt(9)、specs/table.txt(28)
ENABLE_FOOTNOTES2GFM 风格脚注specs/footnotes.txt(26)
ENABLE_STRIKETHROUGH3删除线third_party/GitHub/gfm_strikethrough.txt(3)、specs/strikethrough.txt(15)
ENABLE_TASKLISTS4任务列表third_party/GitHub/gfm_tasklist.txt(2)
ENABLE_SMART_PUNCTUATION5弯引号、破折号、省略号third_party/CommonMark/smart_punct.txt(16)
ENABLE_HEADING_ATTRIBUTES6标题后的 {#id .class key=val}specs/heading_attrs.txt(42)
ENABLE_YAML_STYLE_METADATA_BLOCKS7--- 包起来的元数据块specs/metadata_blocks.txt(12,和下一行共用)
ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS8+++ 包起来的元数据块同上
ENABLE_OLD_FOOTNOTES9 和 2旧式脚注,同时置位 ENABLE_FOOTNOTESspecs/old_footnotes.txt(11)
ENABLE_MATH10$...$、$$...$$specs/math.txt(47)
ENABLE_GFM11目前只有引用块的 [!NOTE] 等五种提示specs/blockquotes_tags.txt(18)
ENABLE_DEFINITION_LIST12定义列表specs/definition_lists.txt(31)
ENABLE_SUPERSCRIPT13上标specs/super_sub.txt(6,和下一行共用)
ENABLE_SUBSCRIPT14下标同上
ENABLE_WIKILINKS15Obsidian 风格 [[...]]specs/wikilinks.txt(23)

另外 third_party/CommonMark/spec.txt 生成 652 条,specs/regression.txt 生成 212 条,合计 1153 条。几个容易看错的地方:

  • 没有第 0 位。 ENABLE_TABLES 从 1 << 1 开始,第 0 位原来是内部用的 FIRST_PASS,来历见为什么是 pull parser 的冷知识。
  • ENABLE_GFM 不是「GitHub 风格全开」。 文档注释写的是「Misc GitHub Flavored Markdown features not supported in CommonMark」,下面只列了提示块。表格、删除线、任务列表、脚注要各开各的;GFM 的自动链接字面量(裸写的 www. 网址)pulldown-cmark 没有实现。
  • ENABLE_OLD_FOOTNOTES 是两位。 它等于 (1 << 9) | (1 << 2),开它就同时开了脚注;内部用 has_gfm_footnotes()(第 2 位开、第 9 位没开)区分两种风格。
  • 测试不是一位一位开的。 测试入口 tests/lib.rs 的 test_markdown_html 对每一条(包括 CommonMark 的 652 条)都固定打开 MATH、TABLES、STRIKETHROUGH、SUPERSCRIPT、TASKLISTS、GFM、HEADING_ATTRIBUTES 和一种脚注,其余几个按例子围栏后的后缀(_smartpunct、_metadata_blocks、_old_footnotes、_super_sub、_wikilinks、_deflists)决定。只开 Options::empty() 跑同样的 652 条,结果在为什么是 pull parser。

不讲什么

  • src/main.rs(220 行):命令行工具,每个开关对应一个 Options 位,-e 打印事件流。本卷只用它的事件格式做对照。
  • entities.rs(2125 个 HTML5 命名实体)和 puncttable.rs 的内容:只讲怎么查,见代码段、HTML、实体。
  • guide/ 这本小书:当资料读,和代码对不上的地方在源码地图列出。
  • fuzz/、dos-fuzzer/、bench/ 的实现:只讲它们防什么。

阅读路线

前置知识

  • Markdown 的块与行内之分:CommonMark spec 把解析分成「先定块结构、再解析每个块里的行内」两个阶段,附录「A parsing strategy」写了这个算法,pulldown-cmark 的两遍基本照它来。
  • Rust:迭代器与 Iterator 适配器;生命周期(事件借用输入 &'input str);Cow。读过 serde 卷讲借用的那一页 会帮助理解 CowStr。
  • 不需要 async:pulldown-cmark 没有任何运行时依赖,必选的直接依赖只有 bitflags、unicase、memchr 三个,另有 getopts、serde、pulldown-cmark-escape 三个可选依赖。

小结

  • 锁 tag v0.13.5(48a2767),crates.io 包就是这个 commit;它在 branch_0.13 上,不是 main 的祖先;pulldown-cmark-escape 是 0.11.0,源码自 2024-05 起没变。
  • 0.13.5 = 0.13.4 + 8 个摘过来的 PR:3 个平方复杂度、2 个 panic、2 个解析结果、1 个 fuzz 目标。
  • 块主线:new_ext → run_first_pass → 每行 parse_block,scan_containers 先匹配旧容器 → Tree<Item> + Allocations。
  • 行内主线:next 走到 Maybe* 占位才 handle_inline:先代码段、HTML、链接,再分隔符栈 → item_to_event。
  • 输出主线:push_html → HtmlWriter::run → start_tag / end_tag → 三种转义;Event::Html 原样输出。
  • 默认 feature 是 getopts 和 html;--no-default-features 连 html 模块一起去掉;simd 只在 x86_64 生效。
  • Options 从第 1 位开始;ENABLE_GFM 只管提示块;ENABLE_OLD_FOOTNOTES 占两位。

💬 关联讨论

发起讨论
加载中...