Rust in Everything
基于 pulldown-cmark 0.13.5

源码地图

主线位置:三条主线经过的文件。块主线在 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.0HTML 转义,一个 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.rs3044Item 与 ItemBody(树上节点的类型);Parser 和它的 Iterator;行内的两趟处理;InlineStack、LinkStack、CodeDelims;Allocations、RefDefs;OffsetIter;块主线也用的 scan_containers三条按需解析、强调与分隔符栈、链接与图片
firstpass.rs2849FirstPass:逐行建块树,表格、脚注、链接定义、标题属性;查找特殊字节的标量实现和 x86_64 的 SIMD 实现块一行一行、叶子块、容器块、表格与脚注
entities.rs2158生成的 HTML5 命名实体表,2125 项,按名字排序行内代码段、HTML、实体
scanners.rs1566无状态的小扫描函数:LineStart(行首的空白、引用标记、列表标记)、围栏、HTML 块的起止、自动链接、实体块、行内一行一行
puncttable.rs1541生成的 Unicode 标点表,判断强调的左右侧要用行内强调与分隔符栈
lib.rs743crate 文档;公开类型 Event、Tag、TagEnd、Options 等;模块声明全部事件模型
html.rs631HtmlWriter 和三个入口函数输出HTML 写出器
strings.rs432CowStr、InlineStr全部CowStr
tree.rs298Tree、Node、TreeIndex:节点放在一个 Vec 里,用下标连成「第一个孩子、下一个兄弟」块、行内扁平数组上的树
main.rs220命令行工具,required-features = ["getopts"]—不讲
utils.rs181TextMergeStream、TextMergeWithOffset:合并相邻的 Text 事件输出改写事件流
linklabel.rs173链接标签的扫描;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.rsmk_entities.pyWHATWG 的 entities.json(脚本注释给了下载地址)ENTITIES: [(&[u8], &str); 2125],名字不带 & 和 ;,按字节序排列,查找用二分
puncttable.rsmk_puncttable.pyUnicode 的 UnicodeData.txtASCII 部分是 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.mdCowStr、InlineStrCowStr
dev/html-generation.mdHtmlWriter、转义HTML 写出器、转义
dev/performance.mdSIMD、树的布局、防病态输入的上限转义、设计取舍说 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 输出有选项。

💬 关联讨论

发起讨论
加载中...