设计取舍
主线位置:三条主线的交叉处。前面各页讲块主线怎么建树、行内主线怎么按需解析、输出主线怎么转义;本页把散在这三条线上的八个选择放在一起,问「换来了什么、放弃了什么」。机制只给一句话和链接,篇幅留给代价和数字。
flowchart TB pull["事件流"] --> g1["省内存 可流式输出"] pull --> c1["回看要自己缓冲"] two["两遍"] --> c2["输入要一次给全"] flat["扁平数组树"] --> g2["分配少"] cow["借用输入"] --> c3["事件绑着输入"] opts["Options 位"] --> c4["加扩展即破坏性"] raw["不净化"] --> c5["信任边界在调用方"] guard["上限与守卫"] --> c6["超限后结果变了"] simd["SIMD"] --> c7["只在 x86_64"]
README 列了四个目标:「Fast; a bare minimum of allocation and copying」「Safe; written in pure Rust with no unsafe blocks (except in the opt-in SIMD feature)」「Versatile; in particular source-maps are supported」「Correct; the goal is 100% compliance with the CommonMark spec」。下面八节,几乎都是在这四条之间找平衡。
数字有两个来源。一是本卷前面各页已经测过的,直接引用并链过去;二是本页自己的实验:exp-pulldown-cmark/design-exp(Options 的开销、SIMD、链接展开上限、括号深度、原样 HTML)、design-mini(09 动手 的迷你解析器,拿来当对照组)和 design-dos(上游 dos-fuzzer 的本地拷贝)。平台 M2 Max、macOS 27、rustc 1.99.0,release 构建,计时取多次里的最小值,次数随文给出;测量时 uptime 负载在 4.5 到 10.6 之间(同机有别的实验在跑)。对照库是 comrak 0.59 和 markdown 1.0(markdown-rs),只写测过的项。
事件流,而不是 AST
做法。 Parser 实现 Iterator<Item = Event>,HTML 写出器只是这个迭代器的一个消费者(为什么是 pull parser)。
换来的。 在 spec.txt(205 KB)上只解析不输出,三个库的实测(为什么是 pull parser):
| pulldown-cmark | comrak | markdown-rs | |
|---|---|---|---|
| 解析时间 | 0.444 ms | 1.234 ms | 35.0 ms |
| 峰值堆内存 | 674 KB | 2.4 MB | 19.8 MB |
| 分配次数 | 70 | 11111 | 80898 |
快和省主要不是因为「没有树」(下一节会看到它也有树),而是因为树里不存字符串、节点不单独分配。事件流本身的好处在使用侧:map、filter 这类逐事件的改写不需要额外内存,写出可以和解析交错进行。
放弃的。 需要回看或前瞻的变换(目录、标题锚点、按结构挪节点)要自己缓冲;没有把事件写回 Markdown 的写出器,comrak 有 format_commonmark。做法见改写事件流。
两遍:块先定完,行内按需
做法。 Parser::new_ext 一进来就调 run_first_pass,把整份文档的块结构建成树;之后每次 next 走到一个块的第一个行内占位,才对这个块剩下的整条兄弟链调 handle_inline(一份文档、按需解析)。
为什么非要先扫完全文:链接引用定义可以写在使用处之后,列表是紧凑还是松散要看到列表末尾才知道(为什么是 pull parser、容器块)。这两件事都只关乎块,所以第一遍只定块;行内语法只影响所在的块,可以推迟。
代价有多大。 「pull」只对第二遍成立。为什么是 pull parser 量过:spec.txt 上只构造 Parser(还没拿第一个事件)就花掉完整解析时间的 60%,本站语料上约一半。为了看清这部分是「CommonMark 逼的」,拿一个没有链接定义、没有列表的子集做对照:迷你解析器只认段落、ATX 标题、强调和代码段,块级可以真正按需,一次只读一个块。同一份 1.38 MB 的文档(# 标题 *h*、一段带强调和代码段的两行文字,重复 2 万次;两边的事件数相同),design-mini 的 first 各取 20 次最小,跑两轮:
1380000 bytes, min of 20
mini-pull first event 0.0003 ms all 12.825 ms (420000 events)
pulldown-cmark first event 6.3042 ms all 13.254 ms (420000 events)
same event count: true
1380000 bytes, min of 20
mini-pull first event 0.0003 ms all 12.800 ms (420000 events)
pulldown-cmark first event 6.0023 ms all 13.453 ms (420000 events)
same event count: true
- 迷你解析器拿到第一个事件只读了第一行;pulldown-cmark 要先建完整棵树,6.0 到 6.3 ms,占全程的 45% 到 48%。
- 全程两者差不多(12.8 对 13.3 ms),但这不说明迷你版更快:它在每个字节上做的判断少得多。这里要看的只是「第一个事件要等多久」。
- 代价还有一条:输入必须是一个完整的
&'input str,不能喂Read,也不能边收边解析。内存随文档长度线性增长,一次解析分配了什么里本站语料的峰值中位数是输入的 4.6 倍。
上游 guide 的 dev/performance.md 在「Key Performance Considerations」里写「The parser is designed for streaming, allowing incremental processing」。对输出端成立(事件可以边产出边写),对输入端不成立。
扁平数组上的树
做法。 块和行内节点都在一个 Vec<Node<Item>> 里,用从 1 开始的 NonZeroUsize 下标连成「第一个孩子、下一个兄弟」;节点只存字节范围,额外数据放 Allocations 的表里,节点只记下标(扁平数组上的树)。
换来的。 Node<Item> 48 字节,Option<TreeIndex> 和 TreeIndex 一样 8 字节;整棵树是一次分配加几次翻倍扩容,所以 spec.txt 整个解析只有 70 次分配。第二遍用同一个 spine 当显式栈,无递归。
放弃的。
- 节点不能删。
truncate_siblings截掉的节点留在Vec里,只是不再可达;Allocations的表也只增不减,take_*只换空值(一次解析分配了什么)。所有内存活到Parser被 drop。 - 容量靠估。
max(128, len / 32)在本站语料上估少了:平均 17.7 字节一个节点,1099 篇里 985 篇要翻倍扩容。 - 每个解析器有固定起步价。 空字符串也要 2 次分配、16 384 字节(树 128 项、
links表 128 项)。逐条解析大量短文本时,这 16 KB 每条都付。
借用输入:CowStr
做法。 事件里的字符串是 CowStr<'input>:能借就借输入(Borrowed),不超过 22 字节的新字符串直接放在枚举里(Inlined),只有更长的才上堆(Boxed)。整个枚举 24 字节(CowStr)。
换来的。 本站 1099 篇文档的事件里有 519 938 个字符串,Boxed 只有 36 个、共 966 字节,全是跨行的行内代码(CowStr)。
放弃的。 事件带着 'input 生命周期,离不开输入字符串。要把事件存下来、跨线程发出去,得 into_static;在本站语料上逐个 into_static 让分配次数从 8455 涨到 161 939,时间从 42.73 ms 涨到 54.30 ms,多 27%(事件模型)。想把相邻文本合成一个也是同样的价钱:TextMergeStream 也是 +27%(一次解析分配了什么)。借用是默认路径,任何「拥有」都要额外付。
扩展是 Options 的位,不是插件
做法。 15 个扩展全写在库里,由 Options 这个 bitflags 在运行时打开(每一位对应什么见怎么读这一卷)。没有插件接口,也没有按扩展划分的 cargo feature:[features] 里只有 html、simd、getopts、gen-tests,不开的扩展代码照样编进去。
换来的。 用户侧最简单,一个整数决定语法;扩展之间的交互(表格单元格里的代码段、脚注里的列表)都在同一套代码里处理,也在同一套 spec 测试里测。
放弃的之一:开得越多,扫得越慢。 第一遍扫行内容时只停在「可能是标记」的字节上,这张表由 special_bytes 按选项生成:开表格加 |,开删除线或下标加 ~,开数学加 $、{、},开智能标点加 .、-、"、'。停的次数多了,扫描就慢。实验 design-exp opts 对同一批输入换三组选项,只解析(spec.txt 50 次、语料 10 次取最小),跑两轮:
simd feature: false, arch: aarch64
spec 204815 bytes (min of 50); corpus 1099 files 18522807 bytes (min of 10)
empty spec 0.439 ms corpus 41.59 ms (445.4 MB/s, 661806 events)
site 7 spec 0.450 ms corpus 43.71 ms (423.7 MB/s, 719481 events)
all spec 0.475 ms corpus 45.17 ms (410.0 MB/s, 717190 events)
simd feature: false, arch: aarch64
spec 204815 bytes (min of 50); corpus 1099 files 18522807 bytes (min of 10)
empty spec 0.439 ms corpus 42.48 ms (436.1 MB/s, 661806 events)
site 7 spec 0.444 ms corpus 43.80 ms (422.9 MB/s, 719481 events)
all spec 0.470 ms corpus 44.70 ms (414.4 MB/s, 717190 events)
site 7 是本站 mdx.rs 开的七个选项(表格、脚注、删除线、任务列表、数学、GFM、标题属性)。两轮取最小,语料上 site 7 比不开慢 5%,全开慢 7%;spec.txt 上是 1% 和 7%。事件数也变了:开了扩展,原来的文字变成了表格、公式等结构。这点开销不大,但它是每份文档都付的,不管文档里有没有用到这些语法。
放弃的之二:每加一个扩展就是一次破坏性发布。 Tag 和 Event 都没有 #[non_exhaustive],新扩展要么加 Tag 变体、要么加 Event 变体,下游穷尽的 match 就编译不过。按 tag 数各版本 lib.rs 里的变体和选项:
| 版本 | Tag 变体 | Event 变体 | ENABLE_* 选项 |
|---|---|---|---|
| v0.9.0 | 16 | 10 | 6 |
| v0.10.0 | 18 | 11 | 9 |
| v0.11.0 | 18 | 13 | 11 |
| v0.12.0 | 21 | 13 | 12 |
| v0.13.0 | 23 | 13 | 15 |
| v0.13.5 | 23 | 13 | 15 |
0.x 的每个次版本号都是破坏性的;从 0.10 到 0.13,每一次都同时多了选项和变体。同一个次版本内的补丁版本(0.13.0 到 0.13.5)一个变体没加。bitflags 加一位是兼容的,枚举加一个变体不是:选项的扩展性被事件枚举的穷尽性抵消了。
不净化 HTML
做法。 原样的 HTML 块和行内 HTML 变成 Event::Html / Event::InlineHtml,push_html 原样写出;链接地址只做转义,不看协议(转义)。没有任何选项能关掉原样 HTML。
同一段输入,三个库都用默认配置(design-exp raw):
--- pulldown-cmark push_html
<script>alert(1)</script>
<p><a href="javascript:alert(1)">x</a> <img src=x onerror=alert(1)></p>
--- comrak default
<!-- raw HTML omitted -->
<p><a href="">x</a> <!-- raw HTML omitted --></p>
--- markdown-rs default
<script>alert(1)</script>
<p><a href="">x</a> <img src=x onerror=alert(1)></p>
comrak 和 markdown-rs 默认都不让原样 HTML 和 javascript: 地址通过,要显式打开(comrak 的 render.r#unsafe,markdown-rs 的 allow_dangerous_html / allow_dangerous_protocol;为什么是 pull parser 跑 spec 时就是这样打开的)。pulldown-cmark 默认就是「全通过」。
换来的。 CommonMark 规范本身要求原样输出 HTML,652 个例子不开任何选项全过;库保持单一职责,不在解析器里维护一份标签白名单。
放弃的。 信任边界完全交给调用方:渲染用户内容时,要么在事件流上过滤 Html / InlineHtml 和链接地址,要么对输出的 HTML 再做一遍净化。本站怎么做见 08 在本站中。
防超线性:上限和守卫
Markdown 的许多规则要「往回找」或「往后找」:分隔符找配对、反引号找同样长的一串、方括号找链接目标。朴素实现很容易在病态输入上变成平方。pulldown-cmark 的做法是逐个堵:每发现一种超线性输入,就加一个上限或一个记录「此前不可能匹配」的下界。
dos-fuzzer 怎么找
仓库里不发布的 dos-fuzzer/ 专门找这类输入(源码地图)。它不是随机字节 fuzz:
literals::get用syn解析pulldown-cmark/src下的源码(跳过entities.rs和main.rs),把常量、if条件、match分支和守卫里的字符串、字符、字节字面量抽出来,再手工补几个(&、不换行空格等)。- 随机拼 6 个字面量当一个模式,重复到 32 KiB(
NUM_BYTES),取 5 个长度点,用time_needed以Options::all()解析并计线程 CPU 时间。 slope_stddev把 (长度, 时间) 当点,算任意两点间斜率的标准差:线性时斜率都差不多,标准差超过 300 就算可疑;可疑的再测最多 5 次(TEST_COUNT),全部可疑才报告。单次超过 500 ms(MAX_MILLIS)直接报告。--regressions模式只跑regression_test里固定的 31 个模式,大多带着对应的 issue 链接,比如a***、[](、a <![CDATA[、*_、[^x]\n,以及 #1148 的深嵌套列表。仓库的 CI 配置里有一步「Test for superlinear time regressions」,跑的就是它。
design-dos 把它复制出来,依赖改成 crates.io 上的 pulldown-cmark =0.13.5;离线缓存里没有 ndarray-stats 和 rand_xoshiro,所以把 slope_stddev 照原公式改写成不依赖 ndarray,随机数生成器换成 rand 的 StdRng(回归模式不用随机数)。在 0.13.5 上,31 个模式全部判为线性,进程退出码 0,没有输出,用时 0.08 秒。反过来验证这道门会红:把解析器换成 #1155 修复之前的版本,同一个命令退出码 1:
possible non-linear behaviour found
pattern: {"prefix":"","repeating_pattern":"*_ ","suffix":"","repeating_pattern_after_suffix":""}
score: 670.6920738869483
[(2184.0, 981625.0), (4369.0, 3734542.0), (6553.0, 8501791.0), (8738.0, 14886208.0), (10922.0, 22734417.0)]
时间单位是纳秒:长度翻 5 倍,时间从 0.98 ms 涨到 22.7 ms,约 23 倍。
守卫清单
| 守卫 | 防的是什么 | 代码 |
|---|---|---|
LINK_MAX_NESTED_PARENS = 32 | 链接地址里无限嵌套的括号 | 常量加 scan_link_dest 里的一个判断 |
InlineStack 的 lower_bounds | 配不上的闭分隔符每次都扫整个栈 | 9 个下界,按字符、能否两用、长度模 3 分桶;get_lowerbound 等约 45 行 |
LinkStack 的 disabled_ix | 链接里不能再有链接,每次禁用都从头扫 | 一个下标,31 行 |
CodeDelims | 每个反引号串都向后找同长的一串 | 按长度分桶的队列,43 行 |
MathDelims 与 MATH_BRACE_CONTEXT_MAX_NESTING = 25 | 公式定界符同上;花括号嵌套的上下文表 | 61 行,加第一遍里 26 行涉及 brace_context 的代码 |
HtmlScanGuard | 未闭合的 <![CDATA[、<?、<!X、<!-- 每次都扫到段尾 | 4 个「此前必然失败」的位置 |
link_ref_expansion_limit | 一个长地址被引用很多次,输出是平方级 | 一个计数,见下文 |
MAX_AUTOCOMPLETED_CELLS = 2^18 | 宽表头配很多短行,补出平方级的空单元格 | 见表格与脚注 |
ListFlags 的 LAST_LINE_EMPTY | 深嵌套列表后的连续空行,每行都走一遍 spine | #1148,0.13.5 摘入,见一行一行 |
几个专门的结构体加起来约 200 行(CodeDelims 43、MathDelims 61、InlineStack 的下界方法约 45、LinkStack 31、HtmlScanGuard 12、ListFlags 9);在它们之外,parse.rs 和 scanners.rs 的解析逻辑里还有 37 行引用 lower_bound、disabled_ix、scan_guard、code_delims、math_delims、link_ref_expansion_limit 这些名字。读行内解析时,相当一部分分支不是 CommonMark 规则,而是在维护这些下界。分隔符的分桶见强调与分隔符栈,CodeDelims 见代码段、HTML、实体。
不加会怎样。 迷你解析器照 CommonMark 附录的「process emphasis」写,只是省掉了 openers_bottom 这类下界。构造一个让它踩坑的输入:n 个 *a(只能开)后面接 n 个 a**b(既能开又能闭,长度 2)。1 + 2 是 3 的倍数,每隔一个 ** 就配不上任何 *,只能把栈扫到底。design-mini 的 quad,每个 n 取 5 次最小:
n= 1000 bytes= 8001 mini-pull 1.007 ms (4002 events) pulldown-cmark 0.095 ms (4002 events)
n= 2000 bytes= 16001 mini-pull 3.821 ms (8002 events) pulldown-cmark 0.186 ms (8002 events)
n= 4000 bytes= 32001 mini-pull 15.026 ms (16002 events) pulldown-cmark 0.368 ms (16002 events)
n= 8000 bytes= 64001 mini-pull 53.252 ms (32002 events) pulldown-cmark 0.703 ms (32002 events)
n= 16000 bytes= 128001 mini-pull 213.550 ms (64002 events) pulldown-cmark 1.380 ms (64002 events)
n 翻倍,迷你版乘 4,pulldown-cmark 乘 2;128 KB 时差 155 倍,事件流相同。那 45 行下界换来的就是这一列。
上限会改变结果
下界只是跳过「不可能匹配」的位置,不改结果;上限不一样,超过以后输出就变了。
以 link_ref_expansion_limit 为例:它在 Parser 构造时设为 text.len().max(100_000),fetch_link_type_url_title 每解析一个引用链接就减去地址和标题的长度,减到 0 以后返回 None,后面的 [x] 原样变成文字。实验 design-exp expand 定义一个 1000 字节或 10000 字节的地址,引用不同次数,数三个库(默认配置)的输出里有几个链接:
url 1000 B x 50 refs, input 1207 B: pulldown links 50, html 50857 B | comrak links 1 html 1220 B | markdown-rs links 50 html 50857 B
url 1000 B x 100 refs, input 1407 B: pulldown links 100, html 101707 B | comrak links 1 html 1420 B | markdown-rs links 100 html 101707 B
url 1000 B x 101 refs, input 1411 B: pulldown links 100, html 101711 B | comrak links 1 html 1424 B | markdown-rs links 101 html 102724 B
url 1000 B x 1000 refs, input 5007 B: pulldown links 100, html 105307 B | comrak links 5 html 9072 B | markdown-rs links 1000 html 1017007 B
url 10000 B x 50 refs, input 10207 B: pulldown links 10, html 100337 B | comrak links 1 html 10220 B | markdown-rs links 50 html 500857 B
url 10000 B x 100 refs, input 10407 B: pulldown links 10, html 100537 B | comrak links 1 html 10420 B | markdown-rs links 100 html 1001707 B
url 10000 B x 101 refs, input 10411 B: pulldown links 10, html 100541 B | comrak links 1 html 10424 B | markdown-rs links 101 html 1011724 B
url 10000 B x 1000 refs, input 14007 B: pulldown links 10, html 104137 B | comrak links 1 html 14020 B | markdown-rs links 1000 html 10017007 B
- pulldown-cmark:总展开量封顶在约 100 KB,1000 字节的地址最多 100 个链接,第 101 个起是文字
[x];没有任何事件或回调告诉你发生了截断。 - comrak 0.59 也有上限,而且更紧:输出始终和输入同一量级,大多数情况下只有第一个引用成了链接。它的规则本页没有读源码核实。
- markdown-rs 1.0 不设上限:14 KB 的输入产出 10 MB 的 HTML。
一份正常文档要撞上 100 KB 的展开量并不容易,但撞上时结果是静默变的。
PR #845 之前,每个引用链接都把定义里的地址和标题完整复制一份,没有任何计数。第一个提交 d30d4ef 的说明只有「Limit link def expansion」和「Fixes #844」,理由写在它给 Parser 加的字段注释里,就是今天 parse.rs 第 207 行起的那一段:[x]: xxx... 引用三次会展开成三个完整的 <a href="xxx...">,「This is quadratic growth, because it's filling in the area of a square. To prevent this, track how much it's expanded and limit it.」初值那一行的注释是「always allow 100KiB」,代码写的是 100_000。那时的检查放在找到定义之后、分配链接之前,用 continue 跳过;同一个 PR 的第二个提交 6a37830(「Check link expansions against the limit before cloning anything」)把查定义、调回调、扣额度收拢成新函数 fetch_link_type_url_title,检查放在函数开头,额度用完就连定义也不查、不克隆。两个提交首次发布于 v0.10.2。
括号深度的上限也一样会改结果,只是规范允许:CommonMark 只要求实现至少支持 3 层。
LINK_MAX_NESTED_PARENS 是 32。[a]( 加 33 层 (、x、33 层 ) 再加 ),是链接还是文字?用 design-exp parens 跑 31 到 33 层,三个库各看一眼。答案在文末。
SIMD 只做 x86_64
做法。 第一遍扫「可能是标记」的字节时,iterate_special_bytes 在 #[cfg(all(target_arch = "x86_64", feature = "simd"))] 时走 mod simd:一次装 16 字节,用 SSSE3 的 pshufb 查表得到一个 16 位掩码,只在掩码为 1 的位置回调。它在每次调用时还要检查 is_x86_feature_detected!("ssse3")、整段字节不少于 16,否则退回 scalar_iterate_special_bytes:逐字节查一张 256 项的 bool 表。转义 crate 里有同样结构的一份(转义)。这段代码来自 PR #238,首次发布于 0.5.0;main 上至今仍只有 x86_64 版本。
换来的。 不开 simd 时整个 crate 是 forbid(unsafe_code)(lib.rs 第 77 行),README 说的「no unsafe blocks (except in the opt-in SIMD feature)」由编译器保证。unsafe 被关在一个默认关闭、只在一种架构上编译的模块里。
放弃的。 aarch64(包括本卷的实验机)上 simd feature 什么也不做。x86_64 上值不值,本机只能在 Rosetta 下比相对差:同一份源码编成 x86_64-apple-darwin,一个开 simd、一个不开,/usr/bin/arch -x86_64 跑 design-exp opts(程序先确认 is_x86_feature_detected!("ssse3") 为真,SIMD 路径确实会走),交替跑两轮:
simd feature: false, arch: x86_64
spec 204815 bytes (min of 50); corpus 1099 files 18522807 bytes (min of 10)
empty spec 0.636 ms corpus 55.08 ms (336.3 MB/s, 661806 events)
site 7 spec 0.640 ms corpus 58.32 ms (317.6 MB/s, 719481 events)
all spec 0.674 ms corpus 59.67 ms (310.4 MB/s, 717190 events)
simd feature: true, arch: x86_64
spec 204815 bytes (min of 50); corpus 1099 files 18522807 bytes (min of 10)
empty spec 0.655 ms corpus 58.13 ms (318.6 MB/s, 661806 events)
site 7 spec 0.659 ms corpus 61.24 ms (302.5 MB/s, 719481 events)
all spec 0.703 ms corpus 63.73 ms (290.6 MB/s, 717190 events)
...
第二轮几乎相同(不开 0.629 / 55.60 ms,开 0.645 / 58.69 ms)。在 Rosetta 下,开 simd 的只解析反而慢 3% 到 6%。这只说明「在这台机器的翻译层上没有收益」,不能代表真实的 x86_64 处理器:Rosetta 怎么翻译 pshufb、这些数在原生 CPU 上是多少,本卷都没有测。从代码能看出的限制是:SIMD 只在「两个特殊字节之间隔得很远」时省事,而 parse_line 每行调用一次 iterate_special_bytes,换行本身就是特殊字节,一次调用最多扫一行;每次调用还要做一次特性检测,掩码里每个 1 仍要逐个回调。Rosetta 下的这点负收益来自哪一项,本卷没有拆。guide 的 dev/performance.md 说「Large documents benefit most from SIMD scanning」,本页的测量没能支持这句话。
和 comrak、markdown-rs 对照
只列本卷实测过的(默认配置,除非注明):
| 项 | pulldown-cmark 0.13.5 | comrak 0.59 | markdown-rs 1.0 | 出处 |
|---|---|---|---|---|
| spec.txt 只解析 | 0.444 ms,峰值 674 KB | 1.234 ms,2.4 MB | 35.0 ms,19.8 MB | 为什么是 pull parser |
| CommonMark 652 例 | 全过 | 全过(" 写法规范化后) | 全过(同左) | 同上 |
原样 HTML、javascript: | 默认原样输出,无开关 | 默认省略,需 unsafe | 默认转义,需 allow_dangerous_* | 本页 |
| 一个长地址被引用多次 | 展开量封顶约 100 KB | 输出与输入同一量级 | 不设上限,14 KB 变 10 MB | 本页 |
| 链接地址括号嵌套 | 33 层还是链接,34 层起是文字 | 32 层是链接,33 层是文字 | 同 comrak | 本页 |
动脑筋答案
是链接。design-exp parens 的输出:
depth 31: link
comrak link | markdown-rs link
depth 32: link
comrak link | markdown-rs link
depth 33: link
comrak text | markdown-rs text
28:link 29:link 30:link 31:link 32:link 33:link 34:text 35:text 36:text 37:text 38:text 39:text 40:text
- pulldown-cmark 实际允许 33 层,34 层才放弃。原因在
scan_link_dest的判断顺序:遇到(时先检查if nest > max_next,再nest += 1。nest是 32 时检查通过,于是能涨到 33。 - comrak 和 markdown-rs 在 33 层就放弃,允许的最大深度是 32。
- 差一层不影响正确性(规范只要求至少 3 层),也不影响它防的东西:深度有上限,扫描就是线性的。它说明的是「上限」这类守卫的另一面:同一份输入在边界附近,三个库会给出不同的结果,而且都符合规范。main 上这两行没有改。
小结
- 事件流换来 spec.txt 上 0.444 ms、674 KB、70 次分配(comrak 1.234 ms、2.4 MB;markdown-rs 35 ms、19.8 MB);回看式的变换要自己缓冲,也没有写回 Markdown 的出口。
- 两遍是 CommonMark 逼的(链接定义在后、列表松紧在后):第一个事件之前要先建完整棵块树,实测占解析时间的 45% 到 60%;一个没有这两样语法的子集可以 0.0003 ms 出第一个事件。输入必须一次给全。
- 扁平数组树和借用的
CowStr让分配几乎只剩树本身;代价是节点和表只增不删、容量估少了要翻倍、每个解析器起步 16 KB,以及事件绑着输入,into_static或合并文本各多 27% 的时间。 - 扩展是运行时的
Options位,全部编进库里:开本站的七个选项慢 5%,全开慢 7%;Tag、Event不是non_exhaustive,每个加扩展的次版本都是破坏性发布。 - 不净化 HTML:默认原样输出
<script>和javascript:,comrak、markdown-rs 默认都拦住;信任边界在调用方。 - 防超线性靠 dos-fuzzer(从源码抽字面量拼输入、看斜率)和一串下界与上限,约 200 行专门的结构加散在解析逻辑里的 37 行;去掉下界的迷你版在 128 KB 上慢 155 倍。上限超了会静默改结果:一个 1000 字节的地址最多展开 100 次。
- SIMD 只在 x86_64 且开
simd时编译,unsafe只在这里;Rosetta 下开了反而慢 3% 到 6%,真机收益本卷没有测到。