Rust in Everything
基于 pulldown-cmark 0.13.5

迷你拉取式解析器

主线位置:块主线和行内主线的缩小版。块主线缩成「每次 next 发现队列空了,就读一个块」;行内主线缩成「把块里的文字切成文本、代码段和分隔符,再用分隔符栈配对」。输出主线不做,停在事件上。每一步都对应前面讲真实实现的某一页,本页讲思路和关键片段,并说明 pulldown-cmark 在同一步多做了什么。

flowchart TB
  nx["next"] --> q{"队列空了"}
  q -->|否| pop["弹出一个事件"]
  q -->|是| pb["parse_block"]
  pb --> h["ATX 标题"]
  pb --> p["段落"]
  h --> pi["parse_inline"]
  p --> pi
  pi --> cut["切成节点"]
  cut --> pe["process_emphasis"]
  pe --> fill["事件进队列"]
  fill --> pop

代码在 exp-pulldown-cmark/design-mini:src/lib.rs 共 335 行(去掉空行和注释 294 行),没有任何依赖;pulldown-cmark =0.13.5 只作为 dev-dependency 出现在测试里。本页摘关键片段,完整实现放在对应课程里。平台 M2 Max、macOS 27、rustc 1.99.0。

它支持的语法:

  • 块:段落、ATX 标题(# 到 ######,可带结尾的 # 串)。其他一切非空行都算段落的一部分。
  • 行内:* / ** 强调、代码段、反斜杠转义、软换行、硬换行(行尾两个以上空格,或反斜杠加换行)。_ 当普通文字。

事件

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Tag {
  Paragraph,
  Heading(u8),
  Emphasis,
  Strong,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Event<'a> {
  Start(Tag),
  End(Tag),
  Text(Cow<'a, str>),
  Code(Cow<'a, str>),
  SoftBreak,
  HardBreak,
}

和 pulldown-cmark 的 Event 同一个形状:成对的 Start / End,中间是叶子事件,文本借用输入。两处简化:End 直接带 Tag,不像上游另有一个 2 字节的 TagEnd(事件模型);字符串用标准库的 Cow<'a, str>,拥有的那一支是 24 字节的 String,放不下内联的短串(CowStr)。这个子集里只有跨行的代码段需要拥有字符串。

块:一次读一个块

pub struct Parser<'a> {
  src: &'a str,
  pos: usize,
  queue: VecDeque<Event<'a>>,
}

impl<'a> Iterator for Parser<'a> {
  type Item = Event<'a>;

  fn next(&mut self) -> Option<Event<'a>> {
    if self.queue.is_empty() && !self.parse_block() {
      return None;
    }
    self.queue.pop_front()
  }
}

Parser::new 什么也不做。第一次 next 时队列是空的,parse_block 从 pos 开始读一个块,把这个块的全部事件(Start、行内事件、End)推进队列,后面的 next 依次弹出,弹空了再读下一个块:

  fn parse_block(&mut self) -> bool {
    while let Some((line, next)) = line_at(self.src, self.pos) {
      if is_blank(line) {
        self.pos = next;
        continue;
      }
      if let Some((level, content)) = atx_heading(line) {
        self.pos = next;
        self.push_block(Tag::Heading(level), content);
        return true;
      }
      // A paragraph runs until a blank line or a heading.
      let start = self.pos + (line.len() - line.trim_start_matches([' ', '\t']).len());
      let mut end = self.pos + line.len();
      self.pos = next;
      while let Some((line, next)) = line_at(self.src, self.pos) {
        if is_blank(line) || atx_heading(line).is_some() {
          break;
        }
        end = self.pos + line.len();
        self.pos = next;
      }
      let content = self.src[start..end].trim_end_matches([' ', '\t']);
      self.push_block(Tag::Paragraph, content);
      return true;
    }
    false
  }

段落的内容是原文里的一段连续切片,续行的缩进留在里面,交给行内那一步跳过;这样文本事件能一直借用输入。标题的识别在 atx_heading 里:至多 3 个空格缩进、1 到 6 个 #、后面是空格、制表符或行尾;结尾的 # 串前面必须有空白才算收尾(# foo# 的标题文字是 foo#)。规则对应 CommonMark 4.2 节,pulldown-cmark 的实现见叶子块。

这里和 pulldown-cmark 最大的不同是:块级也是按需的。 pulldown-cmark 在 Parser::new_ext 里就跑完了整份文档的第一遍(一行一行),因为链接引用定义可以写在后面、列表的松紧要看到末尾(为什么是 pull parser)。这个子集里没有这两样,所以不需要先扫完全文。代价和收益量在设计取舍:同一份 1.38 MB 的文档,第一个事件 0.0003 ms 对 6 ms,全部事件 12.8 ms 对 13.3 ms。

📝NOTE
没有蠢问题

问: 既然是「拉取式」,为什么还要把一个块的事件先攒进 VecDeque,不能扫到哪吐到哪吗?

答: 强调要看到后面才能定。*foo bar* 里第一个 * 是不是强调的开始,要等扫到第二个 * 才知道;开分隔符可能要到下一行甚至更后面才配上(spec 例 405 的 *foo\nbar*)。行内语法不跨块,所以以块为单位攒是最小的单位。pulldown-cmark 也是这样:handle_inline 一次处理当前块剩下的整条兄弟链(按需解析),只不过它把结果写回树上的节点,不另开队列。

行内:先切成节点

parse_inline 扫一遍块的内容,切出四种东西:文本、代码段、换行、* 串。前三种直接是事件,* 串先当成「分隔符」节点,等配对完再决定它变成标签还是文字:

struct Delim<'a> {
  run: &'a str,
  count: usize,
  used_left: usize,
  can_open: bool,
  can_close: bool,
  ends: Vec<Tag>,
  starts: Vec<Tag>,
}

enum Node<'a> {
  Event(Event<'a>),
  Delim(Delim<'a>),
}

扫描是一个按字节的 match,这里摘反引号、星号和换行三支:

      b'`' => {
        let n = run_len(b, i, b'`');
        if let Some(close) = find_backticks(b, i + n, n) {
          push_text(s, text_start, i, &mut nodes);
          nodes.push(Node::Event(Event::Code(code_content(&s[i + n..close]))));
          i = close + n;
          text_start = i;
        } else {
          i += n; // no closer: the backticks stay in the text
        }
      }
      b'*' => {
        let n = run_len(b, i, b'*');
        push_text(s, text_start, i, &mut nodes);
        let (can_open, can_close) = flanking(s[..i].chars().next_back(), s[i + n..].chars().next());
        nodes.push(Node::Delim(Delim {
          run: &s[i..i + n],
          // ...
        }));
        i += n;
        text_start = i;
      }
      b'\n' => {
        let mut end = i;
        while end > text_start && b[end - 1] == b' ' {
          end -= 1;
        }
        push_text(s, text_start, end, &mut nodes);
        let brk = if i - end >= 2 { Event::HardBreak } else { Event::SoftBreak };
        nodes.push(Node::Event(brk));
        i = skip_indent(b, i + 1);
        text_start = i;
      }
  • 代码段先于强调。 遇到 n 个反引号就向后找恰好 n 个的一串,找到了整段就是 Code,中间的 * 不再参与配对(spec 例 341 *foo`*`)。code_content 把换行变成空格,两头各有一个空格(且不全是空格)时各去掉一个。找不到就把反引号留在文本里继续扫。
  • 换行。 行尾的空格不进文本;两个以上是 HardBreak,否则是 SoftBreak。下一行开头的缩进跳过,这就是段落续行去掉缩进的地方。
  • 反斜杠。 后面是 ASCII 标点时,反斜杠本身不输出,被转义的字节作为下一段文本的开头,所以 \* 不会成为分隔符。pulldown-cmark 同样把 Text 在这里切开(CowStr)。

find_backticks 每次都从当前位置往后线性找。一行里有很多配不上的反引号串时这是平方级的,pulldown-cmark 用 CodeDelims 按长度分桶记下所有反引号串的位置(代码段、HTML、实体)。

分隔符栈

一个 * 串能不能开、能不能闭,看它两边的字符(CommonMark 6.2 节的 left-flanking / right-flanking):

fn is_punct(c: char) -> bool {
  c.is_ascii_punctuation() || (!c.is_ascii() && !c.is_alphanumeric() && !c.is_whitespace())
}

/// (left-flanking, right-flanking); the text edges count as whitespace.
fn flanking(prev: Option<char>, next: Option<char>) -> (bool, bool) {
  let ws = |c: Option<char>| c.map_or(true, char::is_whitespace);
  let punct = |c: Option<char>| c.is_some_and(is_punct);
  let left = !ws(next) && (!punct(next) || ws(prev) || punct(prev));
  let right = !ws(prev) && (!punct(prev) || ws(next) || punct(next));
  (left, right)
}

* 的规则最简单:左侧翼就能开,右侧翼就能闭。_ 多了词内规则,这里没有做。标点用「ASCII 标点,或非 ASCII 里既不是字母数字也不是空白」近似;规范要求的是 Unicode 的 P 和 S 两大类,pulldown-cmark 为此生成了一张表(puncttable.rs)。全角的 ,、。 在两种判断下都是标点,所以 *强调*。 能正确闭合。

配对照 CommonMark 附录「process emphasis」写,只是省掉了它的 openers_bottom 优化:

fn process_emphasis(nodes: &mut [Node]) {
  let mut stack: Vec<usize> = Vec::new();
  for ci in 0..nodes.len() {
    if !matches!(nodes[ci], Node::Delim(_)) {
      continue;
    }
    while delim(nodes, ci).can_close && delim(nodes, ci).count > 0 {
      let (c_run, c_open) = (delim(nodes, ci).run.len(), delim(nodes, ci).can_open);
      let found = stack.iter().rposition(|&oi| {
        let o = match &nodes[oi] {
          Node::Delim(o) => o,
          Node::Event(_) => return false,
        };
        // The "multiple of 3" rule.
        let sum = o.run.len() + c_run;
        !((o.can_close || c_open) && sum % 3 == 0 && !(o.run.len() % 3 == 0 && c_run % 3 == 0))
      });
      let Some(si) = found else { break };
      let oi = stack[si];
      let n = if delim(nodes, oi).count >= 2 && delim(nodes, ci).count >= 2 { 2 } else { 1 };
      let tag = if n == 2 { Tag::Strong } else { Tag::Emphasis };
      let opener = delim(nodes, oi);
      opener.count -= n;
      opener.starts.insert(0, tag); // later matches wrap the earlier ones
      let opener_done = opener.count == 0;
      let closer = delim(nodes, ci);
      closer.count -= n;
      closer.used_left += n;
      closer.ends.push(tag);
      stack.truncate(si + 1); // delimiters in between become text
      if opener_done {
        stack.pop();
      }
    }
    let d = delim(nodes, ci);
    if d.can_open && d.count > 0 {
      stack.push(ci);
    }
  }
}
  • 栈里只放能开的分隔符的下标。 从左到右遇到一个能闭的,就从栈顶往下找第一个能配的开分隔符。两边都至少剩 2 个就配成 Strong,否则 Emphasis;配不完的剩余继续找下一个。
  • 3 的倍数规则。 只要有一方「既能开又能闭」,两串的原始长度之和是 3 的倍数(且不是两者都为 3 的倍数)就不能配。它让 *foo**bar* 成为一个 <em>,而不是 *foo 加一个 <strong>。
  • 中间的分隔符变文字。 配上以后,栈里夹在两者之间的开分隔符全部丢掉,它们剩下的 * 最后作为文本输出。
  • 谁包谁。 一个分隔符可能被配多次(***foo*** 先配 **,再配 *)。先配上的在内层,所以开分隔符把新标签插到 starts 前面,闭分隔符把新标签追加到 ends 后面。闭分隔符从左端消耗,开分隔符从右端消耗,剩下的中间一段是文本。输出时每个分隔符依次吐出:ends 里的 End、剩余的 *、starts 里的 Start。

spec 例 413 和 417 是这几条的检验,两边的事件流(合并相邻文本之后,见下一节):

"***foo** bar*\n"
ours   [Start(Paragraph), Start(Emphasis), Start(Strong), Text("foo"), End(Strong), Text(" bar"), End(Emphasis), End(Paragraph)]
theirs [Start(Paragraph), Start(Emphasis), Start(Strong), Text("foo"), End(Strong), Text(" bar"), End(Emphasis), End(Paragraph)]
"foo******bar*********baz\n"
ours   [Start(Paragraph), Text("foo"), Start(Strong), Start(Strong), Start(Strong), Text("bar"), End(Strong), End(Strong), End(Strong), Text("***baz"), End(Paragraph)]
theirs [Start(Paragraph), Text("foo"), Start(Strong), Start(Strong), Start(Strong), Text("bar"), End(Strong), End(Strong), End(Strong), Text("***baz"), End(Paragraph)]

pulldown-cmark 的 InlineStack 是同一套规则,多了按字符、能否两用、长度模 3 分的 9 个下界:一次没找到,就记下「这一类的闭分隔符以后不用再往下找」。这个迷你版没有,于是每个配不上的闭分隔符都把栈扫到底;设计取舍里构造了这样的输入,128 KB 时它要 213 ms,pulldown-cmark 1.4 ms。规则和分桶的细节见强调与分隔符栈。

和 pulldown-cmark 对照

测试 tests/spec.rs 按 build.rs 的方式把 tag 上的 third_party/CommonMark/spec.txt 拆成 652 个例子(编号和上游 tests/suite/spec.rs 的 spec_test_N 一致),同时记下每个例子所在的小节。两边的事件先映射到同一个类型再比:

fn from_pd(e: pd::Event<'_>) -> Event<'static> {
  let tag = |t: pd::TagEnd| match t {
    pd::TagEnd::Paragraph => Some(Tag::Paragraph),
    pd::TagEnd::Heading(l) => Some(Tag::Heading(l as u8)),
    pd::TagEnd::Emphasis => Some(Tag::Emphasis),
    pd::TagEnd::Strong => Some(Tag::Strong),
    _ => None,
  };
  match e {
    pd::Event::Start(t) => match tag(t.to_end()) {
      Some(t) => Event::Start(t),
      None => Event::Text(Cow::Owned(format!("<unsupported {t:?}>"))),
    },
    // ...
    other => Event::Text(Cow::Owned(format!("<unsupported {other:?}>"))),
  }
}

/// Merges adjacent Text events and drops empty ones: both parsers split text
/// at different places (escapes, leftover delimiters).
fn merge(events: impl Iterator<Item = Event<'static>>) -> Vec<Event<'static>> {
  let mut out: Vec<Event> = Vec::new();
  for e in events {
    match (out.last_mut(), e) {
      (_, Event::Text(t)) if t.is_empty() => {}
      (Some(Event::Text(prev)), Event::Text(t)) => prev.to_mut().push_str(&t),
      (_, e) => out.push(e),
    }
  }
  out
}
  • Start(Tag::Heading { level, .. }) 只保留级别;pulldown-cmark 的 id、classes、attrs 在不开 ENABLE_HEADING_ATTRIBUTES 时都是空的。
  • 这个子集以外的事件(CodeBlock、Link、Html 等)映射成一段带 <unsupported 前缀的文字,保证一定比不上。
  • 相邻的 Text 合并、空 Text 丢掉。两边切文本的位置不同:pulldown-cmark 在反斜杠转义、实体、没配上的方括号处切开(事件模型);迷你版在反斜杠转义和剩余的分隔符处切。合并以后比的是「文字内容和结构」。

从 ATX 标题、段落、代码段、强调与加粗、硬换行五个小节里挑 30 条,覆盖每一种支持的语法和几条容易错的边角:

const CHOSEN: [usize; 30] = [
  // ATX headings
  62, 63, 64, 66, 68, 71, 75, 76,
  // Paragraphs
  219, 220, 222, 226,
  // Code spans
  328, 329, 335, 338, 341, 347,
  // Emphasis and strong emphasis
  350, 351, 355, 369, 411, 413, 417, 418, 445, 478,
  // Hard line breaks
  633, 634,
];

另一个测试 survey 把相关六个小节的每一条都跑一遍,分三类:一致;pulldown-cmark 的事件里有本子集没有的东西或输入含 _(「unsupported」);剩下的才是真正的不一致,要求为 0。cargo test --test spec -- --nocapture 的输出:

running 3 tests
test show ... ignored
ATX headings: 18 examples, 16 match, 2 use unsupported syntax, 0 real mismatches []
test chosen_examples_match_pulldown_cmark ... ok
Paragraphs: 8 examples, 7 match, 1 use unsupported syntax, 0 real mismatches []
Code spans: 22 examples, 20 match, 2 use unsupported syntax, 0 real mismatches []
Emphasis and strong emphasis: 132 examples, 87 match, 45 use unsupported syntax, 0 real mismatches []
Hard line breaks: 15 examples, 13 match, 2 use unsupported syntax, 0 real mismatches []
Soft line breaks: 2 examples, 2 match, 0 use unsupported syntax, 0 real mismatches []
test survey ... ok

test result: ok. 2 passed; 0 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.01s

挑的 30 条全部一致;六个小节 197 条里 145 条一致,其余 52 条都用了子集以外的语法。看几条落在「unsupported」里的,正好是缺的东西:

  • 例 69 # foo:pulldown-cmark 是缩进代码块,迷你版当成段落。
  • 例 77 **** 单独一行:pulldown-cmark 是分隔线。
  • 例 344 <a href="`">`:pulldown-cmark 先认出行内 HTML,反引号在属性值里;迷你版没有 HTML,把后面一段当成了代码段。
  • 例 419 *foo [*bar*](/url)*:强调里套链接。

这组测试本身也要能红。把 3 的倍数规则那一行改成恒为 true 再跑,chosen_examples_match_pulldown_cmark 和 survey 都失败。

❗IMPORTANT
动脑筋:去掉 3 的倍数规则,挑的 30 条里哪几条会不一致?六个小节里一共几条?

把 process_emphasis 里 rposition 的条件换成 true,其余不动。答案在文末。

少了什么

每一行都是前面某一页的主题:

缺的东西pulldown-cmark 怎么做讲它的页
先建整棵块树第一遍扫完全文,Tree<Item> 放在一个 Vec 里扁平数组上的树、一行一行
其他叶子块Setext 标题、缩进与围栏代码块、HTML 块、分隔线;段落会被它们打断叶子块
容器块与惰性续行引用块、列表、紧凑与松散;每行先 scan_containers容器块、一行一行
制表符LineStart 按 4 列制表位展开缩进一行一行
扩展表格、脚注、删除线、数学等,由 Options 位打开表格与脚注、怎么读这一卷
_ 强调、删除线同一个 InlineStack,_ 有词内规则,~ 只和等长的配强调与分隔符栈
分隔符栈的下界9 个 lower_bounds,配不上时记下,避免平方强调与分隔符栈、设计取舍
Unicode 标点表puncttable.rs 生成的表,按规范的 P、S 类强调与分隔符栈
链接、图片、引用定义LinkStack、refdefs、标签的大小写折叠链接与图片
代码段的位置索引CodeDelims 按长度分桶,避免向后线性找代码段、HTML、实体
实体、行内 HTML、自动链接第一遍解实体,第二遍扫 HTML 和 <...>代码段、HTML、实体
智能标点、数学ENABLE_SMART_PUNCTUATION、ENABLE_MATH数学与智能标点
字节范围into_offset_iter 给每个事件配原文范围事件模型
内联短字符串CowStr::Inlined 放 22 字节以内的串,不分配CowStr
输出push_html 与三种转义HTML 写出器、转义
防病态输入的上限链接括号深度、展开量、表格补齐等设计取舍

动脑筋答案

把条件换成 { let _ = sum; true } 再跑 cargo test --test spec,输出里的这几行:

thread 'chosen_examples_match_pulldown_cmark' (7467206) panicked at tests/spec.rs:119:3:
mismatch: [411]
...
Emphasis and strong emphasis: 132 examples, 83 match, 45 use unsupported syntax, 4 real mismatches [411, 412, 415, 429]
...
test result: FAILED. 0 passed; 2 failed; 1 ignored; 0 measured; 0 filtered out; finished in 0.01s
  • 挑的 30 条里只有 411(*foo**bar**baz*)。中间的 ** 两边都是字母,既能开又能闭;没有这条规则,它会和开头的 * 配上(1 + 2 = 3),结构就乱了。有了规则,**bar** 自己配成 <strong>,外层再配 <em>。
  • 六个小节里一共 4 条:411、412、415、429(*foo**bar*、*foo**bar***、**foo*bar*baz**),都在「强调与加粗」里,都有一串夹在字母中间、能两用的分隔符,和长度不同的另一串加起来正好是 3。
  • 417(foo******bar*********baz)不在其中:6 + 9 = 15 是 3 的倍数,但 6 和 9 本身也都是 3 的倍数,规则的例外条款放行,去不去掉规则结果一样。
  • 测试用的 30 条只抓到 4 条里的 1 条。想让这条规则有更多防护,可以把 412、415、429 也加进 CHOSEN;survey 已经覆盖了它们。

小结

  • 335 行、无依赖的 Parser 实现 Iterator<Item = Event>:队列空了读一个块,块内先切成文本、代码段、换行和 * 串,再按 CommonMark 的「process emphasis」配对,事件进队列后逐个弹出。
  • 子集里没有链接定义和列表,所以块级也能按需,第一个事件不用等全文;pulldown-cmark 必须先跑完第一遍。行内仍以块为单位攒,和 pulldown-cmark 一样。
  • 挑的 30 条 spec 例子事件流和 pulldown-cmark 0.13.5 完全一致(合并相邻文本后比较);相关六个小节 197 条里 145 条一致,其余都用了子集外的语法,没有真正的不一致。去掉 3 的倍数规则,30 条里有 1 条变红,六节里 4 条。
  • 少的东西逐项对应前面的页:块树与容器、其他叶子块、_ 和扩展、链接与实体、字节范围、内联短串,以及分隔符栈的下界和 CodeDelims 这类防平方的结构。

💬 关联讨论

发起讨论
加载中...