&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并对齐字符边界;用检查程序扫一遍自己的语料。