Rust in Everything
基于 pulldown-cmark 0.13.5

设计取舍

主线位置:三条主线的交叉处。前面各页讲块主线怎么建树、行内主线怎么按需解析、输出主线怎么转义;本页把散在这三条线上的八个选择放在一起,问「换来了什么、放弃了什么」。机制只给一句话和链接,篇幅留给代价和数字。
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-cmarkcomrakmarkdown-rs
解析时间0.444 ms1.234 ms35.0 ms
峰值堆内存674 KB2.4 MB19.8 MB
分配次数701111180898

快和省主要不是因为「没有树」(下一节会看到它也有树),而是因为树里不存字符串、节点不单独分配。事件流本身的好处在使用侧: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.016106
v0.10.018119
v0.11.0181311
v0.12.0211312
v0.13.0231315
v0.13.5231315

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
&lt;script&gt;alert(1)&lt;/script&gt;
<p><a href="">x</a> &lt;img src=x onerror=alert(1)&gt;</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 分支和守卫里的字符串、字符、字节字面量抽出来,再手工补几个(&amp;、不换行空格等)。
  • 随机拼 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 的展开量并不容易,但撞上时结果是静默变的。

📝NOTE
冷知识:链接展开上限是 2024 年才加的

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 层。

❗IMPORTANT
动脑筋: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.5comrak 0.59markdown-rs 1.0出处
spec.txt 只解析0.444 ms,峰值 674 KB1.234 ms,2.4 MB35.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%,真机收益本卷没有测到。

💬 关联讨论

发起讨论
加载中...