Rust in Everything
基于 pulldown-cmark 0.13.5

&doc[range] 切不回原文,还会 panic

主线位置:两条主线的交界。into_offset_iter 把每个事件对应的树节点的 start..end 一起交出来,这些数是第一遍记下、第二遍改写过的字节位置。每种事件的范围覆盖什么,事件模型逐项核对过;这一页讲把范围当成「事件文本在源文里的位置」时会出的错。

flowchart TB
  r["事件的范围"] --> ent["实体 范围比文本长"]
  r --> mg["合并文本 含跳过的字节"]
  r --> task["任务框 在段落之外"]
  r --> tab["制表符缩进的列表项"]
  tab --> prev["起点在上一行"]
  tab --> mid["起点在字符中间"]
  mid --> panic["切片 panic"]

实验在 exp-pulldown-cmark/pita-lab,bin p08_offsets、p08_tabs、p08_panic(release),输出存在 out-p08.txt、out-p08-panic.txt;main 上的对照在 exp-pulldown-cmark/pita-mainprobe(git archive 导出的 origin/main,29d6cfa),输出 out-p08-main.txt。M2 Max、macOS 27、rustc 1.99.0,pulldown-cmark 0.13.5。

现象

编辑器里的「点 HTML 跳到源码」、按事件做源文替换,都会写 &doc[range]。下面四个输入各有问题(每段先列事件,audit: 下面是检查程序的报告,检查程序在文末)。

实体和转义:

== entity and escape: "Tom & Jerry \\*\n"
    0..19 Start(Paragraph)
    0..4 Text(Borrowed("Tom "))
    4..9 Text(Borrowed("&"))
    9..16 Text(Borrowed(" Jerry "))
    17..18 Text(Borrowed("*"))
    0..19 End(Paragraph)
  audit:
  4..9 Text(Borrowed("&")): source slice is "&"

用 TextMergeWithOffset 合并之后,范围从第一段的起点到最后一段的终点,中间的 amp; 和反斜杠都算在里面:

  0..18 Text(Boxed("Tom & Jerry *")): source slice is "Tom & Jerry \\*"

松散列表里的任务框(开 ENABLE_TASKLISTS):

== loose task item: "- [ ] 买菜\n\n  记得带袋子\n"
    0..32 Start(List(None))
    0..32 Start(Item)
    6..13 Start(Paragraph)
    2..5 TaskListMarker(false)
    6..12 Text(Borrowed("买菜"))
    6..13 End(Paragraph)
    ...
  audit:
  2..5 TaskListMarker(false): outside parent 6..13

TaskListMarker 在事件流里位于 Paragraph 之内,范围却在段落之前。

用制表符缩进的子列表:

== tab-indented list: "*     code\n\t+ 嵌套\n"
    ...
    6..11 Start(CodeBlock(Indented))
    6..11 Text(Borrowed("code\n"))
    6..11 End(CodeBlock)
    10..21 Start(List(None))
    10..21 Start(Item)
    ...
  audit:
  10..21 Start(Item): item does not start at its marker: "\n\t+ 嵌套\n"

子列表从 10 开始,那是上一行(代码块里)的换行符,和代码块的范围重叠了一个字节。把上一行换成多字节字符,起点就落进字符中间。定义列表里的例子(开 ENABLE_DEFINITION_LIST):

let doc = "a\n:\n\t漢\n\t- o\n";
for (ev, r) in Parser::new_ext(doc, Options::ENABLE_DEFINITION_LIST).into_offset_iter() {
  println!("{r:?} {ev:?} {:?}", &doc[r.clone()]);
}
0..14 Start(DefinitionList) "a\n:\n\t漢\n\t- o\n"
0..2 Start(DefinitionListTitle) "a\n"
0..1 Text(Borrowed("a")) "a"
0..2 End(DefinitionListTitle) "a\n"
2..14 Start(DefinitionListDefinition) ":\n\t漢\n\t- o\n"
5..8 Text(Borrowed("漢")) "漢"

thread 'main' (7727697) panicked at src/bin/p08_panic.rs:5:39:
start byte index 7 is not a char boundary; it is inside '漢' (bytes 5..8 of string)

原因

范围是节点的,不是文本的。 OffsetIter 返回的是树节点的 item.start..item.end。实体是一个 SynthesizeText 节点,覆盖整个 &,交出的文本却是解码后的 &;反斜杠不属于任何节点;合并时 TextMergeWithOffset 只取首尾。只有普通文本、代码块正文这类借用输入的事件,&doc[range] 才等于事件里的字符串。

任务框是段落的第一个孩子,段落却从任务框之后开始。 parse_block 扫到 [ ] 时(第 146 行起)先做好一个 TaskListMarker 节点(start: after_marker_index),然后跳过空白,调 self.parse_paragraph(ix, Some(task_list_marker))。parse_paragraph 以 ix(文字的起点)为段落的 start,再把任务框挂成第一个孩子。紧凑列表的段落是 TightParagraph,不产生事件,看不出来;松散列表才露出来。main 上的 PR #1135 重写了任务框的处理(还没有进任何 tag),段落的起点挪到了 [ ] 之后的空格;用 main 跑同一个输入,段落是 5..13、任务框是 2..5,仍然不在段落之内。

制表符:列数当成了字节数。 列表项的起点在 parse_block 里这样算(第 134 行):

                self.continue_list(container_start - outer_indent, ch, index);
                self.tree.append(Item {
                    start: container_start - outer_indent,

outer_indent 是列数,一个制表符是一个字节却可以算好几列,减出来就退过了行首,落到上一行,甚至落进一个多字节字符的中间。这在 0.13.5 里没有修。main 上的 3cbeeec「Keep tab-indented list offsets on the current line」改成用容器之后的字节位置(start_ix + save.bytes_scanned()),提交说明写的是:「outer_indent is columns, not bytes, so with a TAB in the indentation the subtraction went back too far: onto the previous line (into the preceding code block in issue 983) or into a multibyte character.」它随 PR #1151 合入 main,还没有进任何 tag。用 main 跑上面两个输入,子列表分别从 12 和 10 开始,正好是列表标记 + 和 - 的位置,切片不再 panic。

修法

  • 文本用事件里的字符串,范围只用来定位。 报行号、做跳转用 range.start;要显示文字,用 Text 里的内容。要把一个事件替换回源文,替换的是整个范围,不要假设范围里的字节就是事件文本。

  • 任务框按列表项处理。 要标注任务框在源文里的位置,用它自己的范围(2..5 就是 [ ]);要找「这一项」的范围,用外层 Item 的,不要用段落的。

  • 切片用 get,不要用下标。 doc.get(range) 落在字符中间时返回 None,不会 panic。需要一个近似的片段时,把起点往后挪到字符边界:

    fn slice<'d>(doc: &'d str, r: Range<usize>) -> &'d str {
      let mut start = r.start.min(r.end);
      while !doc.is_char_boundary(start) {
        start += 1;
      }
      doc.get(start..r.end).unwrap_or("")
    }
    
      7..14 Start(List(None)) "\n\t- o\n"
      7..14 Start(Item) "\n\t- o\n"
    

    拿到的片段可能以上一行的换行符开头,按行定位时再跳过开头的空白。处理来自用户的文档,这一步不能省:0.13.5 的范围本身可能是坏的,任何一个 &doc[r] 都可能让整个请求崩掉。

怎么检查

对自己的语料跑一遍检查程序:范围能不能切、文本和源文切片是否相等、子事件是否在父事件范围之内、列表项是否从标记开始。

fn audit<'a>(doc: &str, events: impl Iterator<Item = (Event<'a>, Range<usize>)>) {
  let mut stack: Vec<Range<usize>> = Vec::new();
  for (ev, r) in events {
    if matches!(ev, Event::End(_)) {
      stack.pop();
    }
    let Some(src) = doc.get(r.clone()) else {
      println!("  {r:?} {ev:?}: not on a char boundary");
      continue;
    };
    if let Some(p) = stack.last() {
      if r.start < p.start || r.end > p.end {
        println!("  {r:?} {ev:?}: outside parent {p:?}");
      }
    }
    if let Event::Text(t) = &ev {
      if t.as_ref() != src {
        println!("  {r:?} {ev:?}: source slice is {src:?}");
      }
    }
    if let Event::Start(Tag::Item) = &ev {
      let marker = src.trim_start_matches([' ', '\t']).chars().next();
      if !marker.is_some_and(|c| matches!(c, '-' | '+' | '*') || c.is_ascii_digit()) {
        println!("  {r:?} {ev:?}: item does not start at its marker: {src:?}");
      }
    }
    if matches!(ev, Event::Start(_)) {
      stack.push(r);
    }
  }
}

上面每段的 audit: 就是它的输出;定义列表那一段报的是:

  7..14 Start(List(None)): not on a char boundary
  7..14 Start(Item): not on a char boundary
  7..14 End(Item): not on a char boundary
  7..14 End(List(false)): not on a char boundary

「文本不等于源文切片」的报告大多是预期内的(实体、转义),用来确认你的代码没有依赖这一点;后三类是要处理的。另外在代码里搜 [r 、[range 这类对源文的下标切片,换成 get。

小结

  • 范围是树节点的字节区间:实体、合并后的文本,范围比文本长;只有借用输入的文本,&doc[range] 才等于事件文本。
  • 松散列表里,TaskListMarker 的范围在它所在段落的范围之前,main 上也是。
  • 0.13.5 里制表符缩进的列表项,起点按列数往回减,会落到上一行或多字节字符中间,&doc[range] 直接 panic;修复(#1151)只在 main 上。
  • 修法:文本取自事件,范围只做定位,切片一律 get 并对齐字符边界;用检查程序扫一遍自己的语料。

💬 关联讨论

发起讨论
加载中...