怎么读这一卷
主线位置:三条主线的总览。本页先说明锁的是哪个提交、它和 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:
| 项 | 本卷 | 说明 |
|---|---|---|
| tag | v0.13.5 → 48a2767(2026-10-21) | 轻量 tag,直接指向合并 #1181 的提交;crates.io 包里 .cargo_vcs_info.json 记的也是它 |
pulldown-cmark-escape | 0.11.0 | crates.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 上找合并提交:
| PR | main 上的合并说明 | 改了哪里 |
|---|---|---|
| #1021 | Correctly parse all-spaces line between refdef and paragraph | firstpass.rs 5 行;build.rs 加了定义列表的例子前缀,tests/suite/ 全部重新生成 |
| #1046 | Fix confusing lifetime syntax in fuzzer | 只改 fuzz/ |
| #1102 | Fix: wikilink with pipe separator leaks child node's next chain into sibling content | parse.rs 的 handle_wikilink |
| #1111 | Fix subtract-overflow panic in handle_wikilink on malformed input | parse.rs |
| #1136 | Fix quadratic parsing with a [^ on every line when footnotes are enabled | firstpass.rs |
| #1143 | Fix panic on definition-list offsets inside a combining character | firstpass.rs、parse.rs |
| #1148 | Skip the container scan on consecutive blank lines | scan_containers 加了一个参数,List 的第一个字段从 bool 变成 Cell<ListFlags> |
| #1155 | Fix 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&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 沿树深度优先走:有当前节点就产出它,没有就弹出父节点产出 End | parse.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 再 run | html.rs | HTML 写出器 |
2. HtmlWriter::run 逐个取事件,Start 交给 start_tag,End 交给 end_tag,文本转义后写出 | html.rs | HTML 写出器 |
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,取多次里的最小值,注明次数和当时的负载(同机还有别的实验在跑) |
| 主 crate | crates.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"]),库本身不用它;作为依赖时它照样被编进来 |
simd | pulldown-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_TABLES | 1 | GFM 表格 | third_party/GitHub/gfm_table.txt(9)、specs/table.txt(28) |
ENABLE_FOOTNOTES | 2 | GFM 风格脚注 | specs/footnotes.txt(26) |
ENABLE_STRIKETHROUGH | 3 | 删除线 | third_party/GitHub/gfm_strikethrough.txt(3)、specs/strikethrough.txt(15) |
ENABLE_TASKLISTS | 4 | 任务列表 | third_party/GitHub/gfm_tasklist.txt(2) |
ENABLE_SMART_PUNCTUATION | 5 | 弯引号、破折号、省略号 | third_party/CommonMark/smart_punct.txt(16) |
ENABLE_HEADING_ATTRIBUTES | 6 | 标题后的 {#id .class key=val} | specs/heading_attrs.txt(42) |
ENABLE_YAML_STYLE_METADATA_BLOCKS | 7 | --- 包起来的元数据块 | specs/metadata_blocks.txt(12,和下一行共用) |
ENABLE_PLUSES_DELIMITED_METADATA_BLOCKS | 8 | +++ 包起来的元数据块 | 同上 |
ENABLE_OLD_FOOTNOTES | 9 和 2 | 旧式脚注,同时置位 ENABLE_FOOTNOTES | specs/old_footnotes.txt(11) |
ENABLE_MATH | 10 | $...$、$$...$$ | specs/math.txt(47) |
ENABLE_GFM | 11 | 目前只有引用块的 [!NOTE] 等五种提示 | specs/blockquotes_tags.txt(18) |
ENABLE_DEFINITION_LIST | 12 | 定义列表 | specs/definition_lists.txt(31) |
ENABLE_SUPERSCRIPT | 13 | 上标 | specs/super_sub.txt(6,和下一行共用) |
ENABLE_SUBSCRIPT | 14 | 下标 | 同上 |
ENABLE_WIKILINKS | 15 | Obsidian 风格 [[...]] | 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/的实现:只讲它们防什么。
阅读路线
- 只想会用:为什么是 pull parser、一份文档、事件模型,然后 05 输出 三页。
- 想读懂实现:按顺序读。02 讲清楚树里有哪些节点,03 才好读;04 解释事件里的字符串为什么大多不分配。
- 只关心块结构:一份文档 → 扁平数组上的树 → 一行一行 → 叶子块 → 容器块。
- 只关心行内:一份文档 → 按需解析 → 强调与分隔符栈 → 链接与图片。
前置知识
- 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占两位。