源码地图
主线位置:三条主线经过的文件。块主线在
firstpass.rs、scanners.rs、tree.rs和parse.rs的scan_containers里;行内主线在parse.rs、linklabel.rs、scanners.rs里;输出主线在html.rs和pulldown-cmark-escape里。本页说明每个文件管什么、谁调谁、哪些是生成的、测试和 fuzz 怎么组织,以及上游guide/对应本卷哪一页。
flowchart TB lib["lib.rs 公开类型"] --> parse["parse.rs Parser"] parse --> fp["firstpass.rs"] fp --> tree["tree.rs"] fp --> sc["scanners.rs"] fp --> ll["linklabel.rs"] parse --> tree parse --> sc parse --> ll ll --> sc sc --> ent["entities.rs 生成"] sc --> punct["puncttable.rs 生成"] parse --> str["strings.rs CowStr"] html["html.rs"] --> esc["pulldown-cmark-escape"] html --> lib utils["utils.rs"] --> lib
箭头是 use 关系:firstpass.rs 对外只有一个 run_first_pass,整个 crate 里只有 parse.rs 的构造函数调它;html.rs 和 utils.rs 只认公开的 Event,不碰树。
工作区
根目录的 Cargo.toml 是一个工作区,default-members 只有前两个:
| 成员 | 发布 | 是什么 |
|---|---|---|
pulldown-cmark/ | 是,0.13.5 | 解析器和 html 模块,外加一个命令行工具 src/main.rs |
pulldown-cmark-escape/ | 是,0.11.0 | HTML 转义,一个 470 行的 lib.rs;html feature 打开时才被依赖 |
bench/ | 否 | criterion 基准:third_party/xi-editor/crdt.md 的整篇渲染、third_party/markdown-it/ 下的 25 份分类样本,以及几组病态输入(缺单元格的表格、反复引用的长链接定义等) |
fuzz/ | 否 | cargo-fuzz 目标:parse 用随机的 Options 位和随机文本跑解析,只查 panic;commonmark_js 是差分 fuzz,把同一输入交给仓库里打包的 commonmark.js(参考实现)和 pulldown-cmark,比较事件序列 |
dos-fuzzer/ | 否 | 找超线性:从 pulldown-cmark 源码里抽出分支条件里的字面量,随机拼接、重复不同次数,看解析时间是否随长度超线性增长 |
工作区的 [profile.release] 设了 lto = true、codegen-units = 1、panic = "abort";这只影响在仓库里构建,作为依赖时用的是你自己的 profile,README 建议把这三行抄进自己的项目。
guide/ 是一本 mdBook,pulldown-cmark/third_party/ 放 CommonMark 和 GitHub 的 spec、commonmark.js、markdown-it 的样本,pulldown-cmark/tools/ 放两个生成脚本。这些都写在 pulldown-cmark/Cargo.toml 的 exclude 里,crates.io 的包里没有。
src/ 下的文件
行数是 tag 上的总行数,含测试模块:
| 文件 | 行数 | 管什么 | 主线 | 讲它的页 |
|---|---|---|---|---|
parse.rs | 3044 | Item 与 ItemBody(树上节点的类型);Parser 和它的 Iterator;行内的两趟处理;InlineStack、LinkStack、CodeDelims;Allocations、RefDefs;OffsetIter;块主线也用的 scan_containers | 三条 | 按需解析、强调与分隔符栈、链接与图片 |
firstpass.rs | 2849 | FirstPass:逐行建块树,表格、脚注、链接定义、标题属性;查找特殊字节的标量实现和 x86_64 的 SIMD 实现 | 块 | 一行一行、叶子块、容器块、表格与脚注 |
entities.rs | 2158 | 生成的 HTML5 命名实体表,2125 项,按名字排序 | 行内 | 代码段、HTML、实体 |
scanners.rs | 1566 | 无状态的小扫描函数:LineStart(行首的空白、引用标记、列表标记)、围栏、HTML 块的起止、自动链接、实体 | 块、行内 | 一行一行 |
puncttable.rs | 1541 | 生成的 Unicode 标点表,判断强调的左右侧要用 | 行内 | 强调与分隔符栈 |
lib.rs | 743 | crate 文档;公开类型 Event、Tag、TagEnd、Options 等;模块声明 | 全部 | 事件模型 |
html.rs | 631 | HtmlWriter 和三个入口函数 | 输出 | HTML 写出器 |
strings.rs | 432 | CowStr、InlineStr | 全部 | CowStr |
tree.rs | 298 | Tree、Node、TreeIndex:节点放在一个 Vec 里,用下标连成「第一个孩子、下一个兄弟」 | 块、行内 | 扁平数组上的树 |
main.rs | 220 | 命令行工具,required-features = ["getopts"] | — | 不讲 |
utils.rs | 181 | TextMergeStream、TextMergeWithOffset:合并相邻的 Text 事件 | 输出 | 改写事件流 |
linklabel.rs | 173 | 链接标签的扫描;LinkLabel 就是 UniCase<CowStr>,比较时做 Unicode 大小写折叠 | 行内 | 链接与图片 |
合计 13836 行,去掉两张生成的表是 10137 行。真正的解析逻辑集中在 parse.rs 和 firstpass.rs,两个文件加起来 5893 行;lib.rs 里大半是公开类型的定义和文档。
几条读代码时有用的事实:
- 公开的只有一层。
lib.rs里除了html和utils两个公开模块,其余模块都是私有的,Tree、Item、ItemBody、FirstPass全是pub(crate)。用户能拿到的只有Parser(以及OffsetIter)和它产出的Event。 Parser的Debug不打印树。impl Debug for Parser的注释是「Only print the fields that have public types」,只打印text、options和回调是否存在。Tree自己有Debug,按缩进打印每个节点,但外面调不到;本卷打印树用的是加了几行暴露它的本地拷贝。OffsetIter::next是Parser::next的一份拷贝。 两个next的逻辑逐行对应,OffsetIter只是多返回节点的start..end,不是包一层调用;改其中一个时另一个要跟着改。
生成的文件
两张表的文件头都写着「Autogenerated by」,生成脚本在 pulldown-cmark/tools/:
| 文件 | 脚本 | 输入 | 内容 |
|---|---|---|---|
entities.rs | mk_entities.py | WHATWG 的 entities.json(脚本注释给了下载地址) | ENTITIES: [(&[u8], &str); 2125],名字不带 & 和 ;,按字节序排列,查找用二分 |
puncttable.rs | mk_puncttable.py | Unicode 的 UnicodeData.txt | ASCII 部分是 8 个 u16 掩码;其余码位按 16 个一组,PUNCT_TAB 记哪些组里有标点(727 组),二分找到组后查 PUNCT_MASKS 的那一位 |
两个脚本都不在构建过程里,更新 Unicode 或 HTML 实体时手动跑一次、把结果提交进来。
测试怎么组织
tests/ 下有四个入口:lib.rs(spec 测试的公共函数 test_markdown_html,加上 mod suite)、html.rs(20 个手写的 HTML 输出测试)、errors.rs(23 个,只要求不 panic、不死循环,不比较输出,其中 13 个叫 test_fuzzer_input_N)、serde.rs(5 个,开 serde 时才有意义)。
spec 测试是生成的。build.rs 在开了 gen-tests 时读 specs/ 下的全部 .txt,加上硬编码的 5 份 third_party 文件(CommonMark 的 spec.txt、smart_punct.txt,GitHub 的表格、删除线、任务列表),把每个以 32 个反引号加 example 开头的例子变成一个 #[test],写进 tests/suite/<文件名>.rs。例子里的 → 换成制表符;围栏后的后缀(_smartpunct、_wikilinks 等)决定额外打开哪个 Option。17 份文件生成 1153 条测试,其中 spec.rs 652 条、regression.rs 212 条。
生成的文件提交在仓库里,平时 cargo test 不需要 gen-tests;改了 spec 文件忘了重新生成,测试跑的还是旧例子。third_party/GitHub/spec.txt(GFM 的完整 spec)在仓库里,但不在硬编码列表中,不生成测试。
比较 HTML 时先过一遍 html_standardize:把 <br>、<br/> 统一成 <br />,<hr> 同理,再去掉标签之间的换行。注释写着「normalizing the HTML using html5ever may hide actual errors」,所以只做这几步。
上游文档对应本卷哪一页
guide/src/ 是一本 mdBook,dev/ 下 6 章讲内部实现,examples/ 下 8 页对应 pulldown-cmark/examples/ 的 8 个例子。本卷把它们当资料读:
| 上游文档 | 讲什么 | 本卷 | 和 0.13.5 的代码对不上的地方 |
|---|---|---|---|
README.md | 定位、为什么是 pull parser、用法、构建选项 | 为什么是 pull parser、怎么读这一卷 | 扩展只列了脚注、表格、任务列表、删除线;TextMergeStream 的示例 use 里没有导入 TextMergeStream,原样编译报 cannot find type TextMergeStream in this scope;--no-default-features 会连 html 一起关掉 |
guide/src/dev/index.md | 两遍的总体架构、目录结构 | 一份文档 | 目录结构只列了 7 个文件,没有 lib.rs、linklabel.rs、puncttable.rs、utils.rs、main.rs;「The HTML renderer can be customized through options」在代码里没有对应:html 模块只有三个函数,没有任何选项 |
dev/block-parsing.md | 第一遍、spine、树的节点 | 扁平数组上的树、一行一行 | |
dev/inline-processing.md | 第二遍、分隔符栈、链接 | 03 行内解析 | |
dev/string-handling.md | CowStr、InlineStr | CowStr | |
dev/html-generation.md | HtmlWriter、转义 | HTML 写出器、转义 | |
dev/performance.md | SIMD、树的布局、防病态输入的上限 | 转义、设计取舍 | 说 SIMD 扫描「is implemented in scanners.rs」,实际在 firstpass.rs 的 mod simd(和 escape crate),scanners.rs 里没有 SIMD;引的 compute_mask 删掉了 bitmask_lookup 的定义 |
dev/extensions.md | 怎样加一个扩展 | 06 设计取舍 | |
guide/src/examples/ | 8 个例子的讲解 | 改写事件流 |
小结
- 工作区 5 个成员,只发布
pulldown-cmark和pulldown-cmark-escape;bench/、fuzz/、dos-fuzzer/分别管性能、panic 与和 commonmark.js 的差分、超线性。 src/13836 行,去掉生成的实体表和标点表是 10137 行;解析逻辑在parse.rs(3044)和firstpass.rs(2849)。- 树和节点类型全是 crate 私有,
Parser的Debug不打印树;OffsetIter::next复制了Parser::next的逻辑。 entities.rs、puncttable.rs由tools/下的 Python 脚本生成,不在构建过程里。build.rs只在gen-tests下工作,把 17 份 spec 文件变成 1153 条已提交的测试;比较前只做<br>、<hr>和标签间换行的规范化。- README 和
guide/有几处和代码对不上:README 的TextMergeStream示例编译不过,guide把 SIMD 放错了文件,还说 HTML 输出有选项。