Rust in Everything
基于 pulldown-cmark 0.13.5

强调与分隔符栈

主线位置:行内主线第二趟。handle_inline_pass1 处理完代码段和链接之后,handle_emphasis_and_hard_break 从左到右再走一遍兄弟链:能开的分隔符串压进 InlineStack,能闭的串从栈顶往下找配对,找到就把中间的节点挂成 Emphasis / Strong 的孩子;走完后栈里剩下的全退成文本。例子文档里的 *pull*、**事件流**、_强调_ 都在这里配上。

flowchart TB
  run["一串分隔符"] --> cc{"能闭"}
  cc -->|是| fm["find_match 从下界往上找"]
  fm -->|找到| wrap["改写成 Emphasis 或 Strong"]
  wrap --> left{"还剩分隔符"}
  left -->|是| fm
  fm -->|没找到| lb["抬高这一桶的下界"]
  cc -->|否| co{"能开"}
  lb --> co
  left -->|否| nextrun["下一个节点"]
  co -->|是| push["压栈"]
  co -->|否| txt["退成 Text"]
  push --> nextrun
  txt --> nextrun

实验平台和 inline-probe 同按需解析:在本地拷贝的 0.13.5 里加了计数器,另加一个开关把下界固定为 0(NAIVE_EMPH),其余逻辑不动。0.13.4 与 0.13.5 的对照用 crates.io 上的原版(inline-run134、inline-run135)。

第一遍:能开还是能闭

CommonMark「Emphasis and strong emphasis」一节先定义分隔符串(delimiter run)的左侧(left-flanking)和右侧(right-flanking),再规定 * 和 _ 什么时候能开、能闭。这只取决于串前后各一个字符,所以在第一遍的 parse_line 里就算好了。delim_run_can_open(去掉表格的特例):

    let next_char = if let Some(c) = suffix[run_len..].chars().next() {
        c
    } else {
        return false;
    };
    if next_char.is_whitespace() {
        return false;
    }
    if ix == 0 {
        return true;
    }
    // ...
    let delim = suffix.bytes().next().unwrap();
    // `*` and `~~` can be intraword, `_` and `~` cannot
    if delim == b'*' && !is_punctuation(next_char) {
        return true;
    }
    if delim == b'~' && run_len > 1 {
        return true;
    }
    let prev_char = s[..ix].chars().last().unwrap();
    if delim == b'~' && prev_char == '~' && !is_punctuation(next_char) {
        return true;
    }

    prev_char.is_whitespace()
        || is_punctuation(prev_char) && (delim != b'\'' || ![']', ')'].contains(&prev_char))

它把规范的两步合成了一个函数:「后面不是空白」,再加「后面不是标点,或者前面是空白或标点」,这就是左侧的定义;对 * 来说左侧就能开,所以 * 后面跟字母时直接返回 true,词中间的 foo*bar* 也能配。_ 走到最后一行,要求前面是空白或标点,所以 foo_bar_ 不是强调,这是规范为蛇形命名(snake_case)留的规则。delim_run_can_close 是镜像。同一个函数还服务 ~(双 ~~ 可以在词中间,单 ~ 不行)和智能标点的引号(' 前面是 ] 或 ) 时不算开引号,这样 [x]'s 里的是撇号)。

「标点」由 is_punctuation 判断。puncttable.rs 是 tools/mk_puncttable.py 从 Unicode 数据生成的,收的是 P 和 S 开头的全部类别,与 CommonMark 0.31 对「Unicode punctuation character」的定义一致。查法分两级:

pub(crate) fn is_punctuation(c: char) -> bool {
    let cp = c as u32;
    if cp < 128 {
        return is_ascii_punctuation(cp as u8);
    }
    if cp > 0x1FBCA {
        return false;
    }
    let high = (cp / 16) as u16;
    match PUNCT_TAB.binary_search(&high) {
        Ok(index) => (PUNCT_MASKS[index] & (1 << (cp & 15))) != 0,
        _ => false,
    }
}

ASCII 用 8 个 u16 位掩码(每个管 16 个码点)。其余码点按 16 个一组:PUNCT_TAB 是有标点的组号,727 项、有序,二分找到组,再看 PUNCT_MASKS 里同下标那个掩码的第 cp & 15 位。727 项的二分最多比较 10 次。

全角标点 ,、「、」 都在表里。

❗IMPORTANT
动脑筋:下面两个段落,哪个能得到 <strong>?
**注意**,这里要改

**注意,**这里要改

两段只差一个逗号放在 ** 里面还是外面。按上面的 delim_run_can_open / delim_run_can_close 推一下第二段末尾那对 ** 能不能闭。答案在文末。

第二趟:找配对

handle_emphasis_and_hard_break 遇到 MaybeEmphasis(count, can_open, can_close) 时,先看能不能闭;能闭就反复调 InlineStack::find_match,直到这串用完或者找不到;剩下的如果能开就压栈,否则退成文本。栈元素 InlineEl 记着这串第一个节点的下标 start、还剩几个 count、原始长度 run_length、字符 c、能否两用 both。

找的过程:

        let lowerbound = min(self.stack.len(), self.get_lowerbound(c, run_length, both));
        let res = self.stack[lowerbound..]
            .iter()
            .cloned()
            .enumerate()
            .rfind(|(_, el)| {
                if c == b'~' && run_length != el.run_length {
                    return false;
                }
                el.c == c
                    && (!both && !el.both
                        || (run_length + el.run_length) % 3 != 0
                        || run_length % 3 == 0)
            });
  • 从栈顶往下找最近的同字符开分隔符,rfind 就是规范附录「look back in the stack」那一步。
  • 3 的倍数规则:开和闭两串里只要有一串能两用(既能开又能闭),两串原始长度之和就不能是 3 的倍数,除非两串长度都是 3 的倍数。条件里的 run_length % 3 == 0 只检查了闭的一方:开的一方长度是 3 的倍数而闭的一方不是时,和不可能被 3 整除,前一个条件已经放行了。
  • ~ 要求长度相等:~~ 只和 ~~ 配,~ 只和 ~ 配。三个以上的 ~ 在第一遍就不算分隔符(is_valid_seq)。

找到之后,栈里在它之上的元素全部退成文本、截掉(规范「Remove any delimiters between the opener and closer from the delimiter stack」)。然后从里往外包。输入 *a **b** c* 时栈的变化(EMPH_TRACE=1 inline-probe events,@ 后面是字节位置):

  run @0 (1x '*', open=true, close=false) pushed
  run @3 (2x '*', open=true, close=false) pushed
  closer @6 (2x '*', run 2) matches opener @3 (count 2, run 2); stack now [(0, 1, '*')]
  closer @10 (1x '*', run 1) matches opener @0 (count 1, run 1); stack now []

配对数 match_count = min(count, el.count),每次能拿两个就做 Strong,只剩一个就做 Emphasis,所以 ***x*** 是 <em><strong>x</strong></em>。包的时候不建新节点:开分隔符串里的那个节点直接改成 Strong,child 指向原来夹在中间的第一个节点,next 接到闭分隔符串之后。用不完的开分隔符(el.count > match_count)以新的 count 重新压栈。

3 的倍数规则的典型例子是 *foo**bar*:

  run @0 (1x '*', open=true, close=false) pushed
  run @4 (2x '*', open=true, close=true) pushed
  closer @9 (1x '*', run 1) matches opener @0 (count 1, run 1); stack now []

中间的 ** 能两用,先试着闭:栈里只有长度 1 的 *,1 + 2 = 3,配不上,于是压栈。最后的 * 只能闭,从栈顶找:** 能两用、长度和又是 3,跳过;下面的 * 配上。** 被截掉,输出 <em>foo**bar</em>,和规范的例子一致。

~ 生成什么取决于选项:双 ~~ 在开了 ENABLE_STRIKETHROUGH 时是删除线;单 ~ 开了 ENABLE_SUBSCRIPT 是下标,否则只开删除线时也算删除线(GFM 允许单个 ~)。^ 只在开了 ENABLE_SUPERSCRIPT 时是上标。三种都走同一个栈、同一个 find_match。

下界:为什么按桶分

一个只能闭的分隔符如果在栈里找不到配对,最直接的写法是每次都从栈顶找到栈底。设想一个段落先有 n 个只能开的 *,后面跟 n 个只能闭的 _:每个 _ 都要把 n 个 * 看一遍,总共 n² 次比较。

规范附录「An algorithm for parsing nested emphasis and links」已经给了办法:为每种分隔符记一个 openers_bottom,「indexed to the length of the closing delimiter run (modulo 3) and to whether the closing delimiter can also be an opener」,找不到时把它挪到当前位置,「this puts a lower bound on future searches」。InlineStack 的 lower_bounds: [usize; 9] 就是这张表,只是存成栈下标。一次查找失败时,set_lowerbound 把对应的桶设成当前栈高:「这个高度以下,没有东西能和这一类闭分隔符配」。下一次同一类的闭分隔符从这个高度开始找,get_lowerbound 负责取值。9 个桶:

下标常量管哪一类闭分隔符
0UNDERSCORE_NOT_BOTH只能闭的 _
1ASTERISK_NOT_BOTH只能闭的 *
2–4ASTERISK_BASE + run_length % 3*,按长度模 3
5TILDES~(以及没有自己桶的 ^)
6–8UNDERSCORE_BASE + run_length % 3_,按长度模 3

分桶的依据是配对条件只依赖三样东西:字符、闭分隔符的长度模 3、它能不能两用。同一桶里的两个闭分隔符,能配的开分隔符集合完全相同,前一个没找到,后一个也不可能在同一段里找到。能两用的闭分隔符只能配「长度和不是 3 的倍数」的开分隔符,只能闭的还能额外配「也不能两用」的开分隔符,前者的候选是后者的子集;所以只能闭的那类查下界时取两个桶的较小值(min(mod3_lower, ..NOT_BOTH)),失败时两个桶都可以抬高。截断栈(truncate)时,高于新栈高的下界被压回栈高。

把下界固定成 0 对照(各 5 次取最小,inline-probe patho,find_match_scanned 是比较过的栈元素总数):

输入n字节0.13.5下界固定为 0比较次数(0.13.5 / 下界为 0)
n 个 *a + n 个 a_100060000.083 ms2.021 ms1000 / 1000000
同上4000240000.309 ms32.111 ms4000 / 16000000
同上16000960001.429 ms506.144 ms16000 / 256000000
*a_ 重复 n 次100030000.074 ms1.036 ms1000 / 500500
同上16000480001.238 ms257.340 ms16000 / 128008000
同上640001920004.975 ms4046.586 ms64000 / 2048032000

(uptime 负载约 4.4,其他写手的实验在同时跑。)有下界时比较次数等于 n:第一个闭分隔符把整个栈看一遍,之后每个都从栈顶开始、立刻失败。没有下界时比较次数正好是 n² 或 n(n+1)/2,时间随之平方增长。

📝NOTE
冷知识:*a_ 一行在 0.13.4 上是平方的

下界从 2019 年的「Guard against pathological emphasis」(f799862,#254,首次发布于 0.5.0)就有了,但 _ 那一支漏了一个桶。0.13.4 的 set_lowerbound 里,只能闭的 _ 失败时只设 UNDERSCORE_NOT_BOTH,不设按长度的桶:

        if c == b'_' {
            if both {
                self.lower_bounds[InlineStack::UNDERSCORE_BASE + count % 3] = new_bound;
            } else {
                self.lower_bounds[InlineStack::UNDERSCORE_NOT_BOTH] = new_bound;
            }

而 get_lowerbound 给只能闭的 _ 取的是两个桶的较小值,按长度的那个桶还是 0,下界等于没设。#1155 改成两个都设(main 上 2026-09-29 合入;摘进 0.13 分支的提交是 6da8248,首次发布于 0.13.5)。提交说明:「A _ that can close but not open and found no opener raised only UNDERSCORE_NOT_BOTH, while get_lowerbound for the next one takes the min of that and the still-zero mod-3 slot, so each such closer rescanned the whole stack. Raise both, as asterisks already do.」同一个 PR 加了单元测试 issue_1154,并把这个输入加进 dos-fuzzer 的回归列表。

*a_ 里每个 * 只能开、每个 _ 只能闭。原版 crate,Parser::new(&doc).count(),各 3 次取最小:

== 0.13.4
star_us n=  10000 bytes=   30000 events=  30002 min_ms=    18.550
star_us n=  40000 bytes=  120000 events= 120002 min_ms=   294.001
star_us n= 160000 bytes=  480000 events= 480002 min_ms=  4715.581
star_us n= 400000 bytes= 1200000 events=1200002 min_ms= 29195.605
== 0.13.5
star_us n=  10000 bytes=   30000 events=  30002 min_ms=     0.711
star_us n=  40000 bytes=  120000 events= 120002 min_ms=     3.009
star_us n= 160000 bytes=  480000 events= 480002 min_ms=    13.158
star_us n= 400000 bytes= 1200000 events=1200002 min_ms=    32.914
1.2 MB 的一行从 29 秒降到 33 毫秒,和提交说明里的「1.2 MB took 26.8 s, now 0.07 s」同一量级。这种输入不需要开任何扩展。

下界不只是优化

按上面的论证,下界只跳过「肯定配不上」的部分,结果应该和不跳一样。对 * 和 _ 是这样;对 ~ 和 ^,0.13.5 有两处不成立:

  • ~ 只有一个桶,但配对条件还要求长度相等。一个找不到配对的单 ~ 闭分隔符会把 TILDES 抬到栈顶,之后的 ~~ 就看不到栈里更早的 ~~ 了。
  • ^ 没有自己的桶,get_lowerbound 和 set_lowerbound 的 else 分支让它和 ~ 共用 TILDES。

inline-probe html 的第二个参数是选项字母(S 删除线,P 上标),NAIVE=1 把下界固定为 0;inline-cmp 在 STRIKE=1 时给三个库都开删除线(markdown-rs 用 GFM 预设):

$ inline-probe html "=~~a~ b~~" S
<p>~~a~ b~~</p>
$ NAIVE=1 inline-probe html "=~~a~ b~~" S
<p><del>a~ b</del></p>
$ STRIKE=1 inline-cmp "=~~a~ b~~"
pulldown: <p>~~a~ b~~</p>
comrak:   <p>~~a~ b~~</p>
mdrs:     <p><del>a~ b</del></p>
$ inline-probe html "=~~a b^ c~~" S
<p><del>a b^ c</del></p>
$ inline-probe html "=~~a b^ c~~" SP
<p>~~a b^ c~~</p>
$ NAIVE=1 inline-probe html "=~~a b^ c~~" SP
<p><del>a b^ c</del></p>

第一个例子,同一份代码去掉下界就得到删除线;comrak 和 pulldown-cmark 一样不认,markdown-rs 认。第二个例子里,开不开上标决定了一段删除线成不成立,而那个 ^ 本身什么也没配上。main 上的 bc1ded8「Do not use the same lower limit for ^ and ~」(#1024)给 ^ 加了自己的桶,但它没有被摘进 0.13 分支,0.13.5 里没有;~ 长度不等的那一处在 main 的代码里仍然是一个桶。

动脑筋答案

只有第一段。三个库各自用默认设置渲染(inline-cmp,参数以 = 开头表示直接给出文档):

--- =**注意**,这里要改
pulldown: <p><strong>注意</strong>,这里要改</p>
comrak:   <p><strong>注意</strong>,这里要改</p>
mdrs:     <p><strong>注意</strong>,这里要改</p>
--- =**注意,**这里要改
pulldown: <p>**注意,**这里要改</p>
comrak:   <p>**注意,**这里要改</p>
mdrs:     <p>**注意,**这里要改</p>

第二段末尾那对 ** 前面是 ,(标点),后面是 这(不是空白也不是标点)。按右侧串的定义,前面是标点时,后面必须是空白或标点才算右侧串,所以它闭不上;反过来它满足左侧串的定义,能开。第一遍的树里两对都是只能开(inline-probe tree):

== after first pass
Item { start: 0, end: 25, body: Paragraph }
  Item { start: 0, end: 1, body: MaybeEmphasis(2, true, false) }
  Item { start: 1, end: 2, body: MaybeEmphasis(1, true, false) }
  Item { start: 2, end: 11, body: Text { backslash_escaped: false } }
  Item { start: 11, end: 12, body: MaybeEmphasis(2, true, false) }
  Item { start: 12, end: 13, body: MaybeEmphasis(1, true, false) }
  Item { start: 13, end: 25, body: Text { backslash_escaped: false } }
...

两串都压栈,没有闭分隔符,第二趟结束时 pop_all 把它们全部退成文本。三个库输出一致,这是规范本身的规则,不是哪个实现的错;中文写作时把标点放到 ** 外面就能避开。本站内容仓库 1139 个文件里,有 3 个页面留下了 38 个没配上的 **,其中 19 个紧挨着非 ASCII 的标点(inline-probe corpus_emph,合并相邻的文本事件后统计,不含代码)。main 上已经合入了 #1059 的 ENABLE_CJK_FRIENDLY_EMPHASIS,放宽了中日韩字符旁边的左右侧判定,还没有进任何 tag。

小结

  • 能开、能闭在第一遍按左右侧规则算好;* 可以在词中间,_ 不行;标点表 puncttable.rs 收 Unicode P、S 类,两级查表。全角标点紧贴 ** 时常常闭不上,这是规范行为,三个库一致。
  • 第二趟用 InlineStack 从栈顶往下找同字符的开分隔符,3 的倍数规则写在 find_match 的条件里;配上后从里往外改写节点,不建新节点。
  • 9 个下界按「字符、长度模 3、能否两用」分桶,同一桶的闭分隔符候选集合相同,所以失败一次就能整段跳过。关掉下界,病态输入的比较次数从 n 变成 n²。
  • 0.13.5 摘进来的 #1155 补上了 _ 的一个桶,1.2 MB 的 *a_ 从 29 秒降到 33 毫秒。
  • 对 ~(长度要相等)和 ^(与 ~ 共桶),0.13.5 的下界会改变结果;^ 的问题只在 main 上修了。

💬 关联讨论

发起讨论
加载中...