结束围栏后多了一个制表符,代码块吞掉了后文
主线位置:块主线的叶子块。围栏代码块怎么找结束围栏、规范 4.5 节的每条规则对应哪段代码,见叶子块的「围栏代码块」一节,那一页已经指出了这处出入。本页讲它在文档里的样子、怎么绕开、怎么查。
flowchart TB
open["开头围栏"] --> loop["逐行读代码"]
loop --> sc["scan_closing_code_fence"]
sc --> sp["跳过空格"]
sp --> eol{"到行尾了"}
eol -->|是| close["代码块结束"]
eol -->|否 是制表符| text["这行当代码"]
text --> loop
loop --> eof["文档末尾才结束"]
实验 crate:exp-pulldown-cmark/pitb-probe 的 fence-tab(0.13.5),pitb-probe134 配 0.13.4,输出逐字节相同。M2 Max、macOS 27、rustc 1.99.0,输出在 out/fence-tab.txt。
现象
一段代码的结束围栏后面多了一个制表符(编辑器自动缩进、从别处粘贴都可能留下它,肉眼看不见)。源文是:
=== tab: "```rust\nfn main() {}\n```\t\n\n# 下一节\n\n正文。\n"
事件流和 HTML:
0..50 Start(CodeBlock(Fenced(Borrowed("rust"))))
8..50 Text(Borrowed("fn main() {}\n```\t\n\n# 下一节\n\n正文。\n"))
0..50 End(CodeBlock)
--- html
<pre><code class="language-rust">fn main() {}
```
# 下一节
正文。
</code></pre>
结束围栏、后面的标题和段落都进了代码块,一直到文档末尾。同样位置换成一个空格就正常:
=== space: "```rust\nfn main() {}\n``` \n\n# 下一节\n"
--- html
<pre><code class="language-rust">fn main() {}
</code></pre>
<h1>下一节</h1>
~~~ 围栏也一样(tab-tilde 一例)。没有报错。
原因
CommonMark 0.31.2 第 4.5 节说结束围栏「may be followed only by spaces or tabs, which are ignored」。scan_closing_code_fence 数完围栏字符后只跳过空格:
i += num_fence_chars_found;
let num_trailing_spaces = scan_ch_repeat(&bytes[i..], b' ');
i += num_trailing_spaces;
scan_eol(&bytes[i..]).map(|_| i)
跳过空格后不在行尾(这里是一个 \t),scan_eol 返回 None,这一行就不是结束围栏。parse_fenced_code_block 把它当成一行代码追加,接着读下一行。之后再没有结束围栏,代码块只在容器结束或文档末尾结束(叶子块)。
main 上已修,没有发布。 PR #1150 的提交 73b05dd「Allow tabs after a closing code fence」把这两行换成:
// CommonMark: the closing fence "may be followed only by spaces or tabs"
i += scan_while(&bytes[i..], |c| c == b' ' || c == b'\t');
提交说明是「The closing fence scanner only skipped trailing spaces, so a fence followed by a tab did not close the block. CommonMark 4.5 allows spaces or tabs.」这个提交不在任何 tag 里,0.13.5 没有摘它;0.13.4 的行为与 0.13.5 相同。
问: 开头围栏的信息串里写制表符呢?比如 ```rust<TAB>ignore。
答: 块能正常认出来,但语言名不对。信息串原样进了 CodeBlockKind::Fenced,push_html 取语言时只在空格处截断(html.rs 第 261 行 的 info.split(' ')),制表符和后面的词一起进了 class:
=== info-tab: "```rust\tignore\nfn main() {}\n```\n"
0..31 Start(CodeBlock(Fenced(Borrowed("rust\tignore"))))
15..28 Text(Borrowed("fn main() {}\n"))
0..31 End(CodeBlock)
--- html
<pre><code class="language-rust ignore">fn main() {}
</code></pre>
HTML 的 class 按空白(包括制表符)分词,浏览器看到的是 language-rust 和 ignore 两个类;但把 class 当整串处理、或者自己从事件里取 info 的代码会拿到 rust\tignore。main 上的 PR #1173(404794f「Stop the code block language at a tab in the info string」)改成在空格或制表符处截断,同样没有发布。自己从事件取语言时,用 split_whitespace().next()。
修法
删掉围栏行尾的制表符。 删掉之后:
=== fixed: "```rust\nfn main() {}\n```\n\n# 下一节\n\n正文。\n"
--- html
<pre><code class="language-rust">fn main() {}
</code></pre>
<h1>下一节</h1>
<p>正文。</p>
让编辑器在保存时去掉行尾空白,是最省事的预防。
处理不受你控制的 Markdown: 在解析前对「只由 3 个以上反引号或波浪号组成、后面跟着空白」的行去掉行尾空白。这一步只影响围栏行,不改变代码内容。main 上的修复发布后可以去掉。
怎么检查
代码块的文字里如果有一行,开头最多 3 个空格、接着至少和开头围栏一样长的同种围栏字符、然后只有空白且其中有制表符,它几乎一定是想当结束围栏的那一行。pitb-probe 里的 check_fences 在事件流上找这样的行:
Event::Text(text) => {
if let Some((ch, n)) = fence {
for l in text.lines() {
let body = l.trim_start_matches(' ');
let run = body.bytes().take_while(|&b| b == ch).count();
let rest = &body[run..];
if l.len() - body.len() <= 3 && run >= n && rest.contains('\t') && rest.trim().is_empty() {
// report
}
}
}
}
check: fence "```" followed by a tab, inside the code text that starts at byte 8
check: 1 problem(s)
改好的文档报 check: 0 problem(s)。不想写代码,也可以直接在源文上搜:围栏字符串后面紧跟制表符的行,比如在 zsh 或 bash 里:
grep -nE $'^ {0,3}(`{3,}|~{3,})[ \t]*\t' 文件.md
对一个结束围栏后带制表符、另一个带空格的样例文件,它只报出了第 3 行,也就是带制表符的那一行。
小结
- 0.13.5 的
scan_closing_code_fence在围栏字符之后只跳过空格;带制表符的结束围栏不算结束,代码块吞掉后面全部内容,直到容器或文档结束。 - 开头围栏信息串里的制表符会和后面的词一起进
class="language-…"。 - 两处都是与规范或参考实现的出入,main 上分别由 PR #1150、#1173 修好,尚未进入任何发布版本;0.13.4 相同。
- 修法:去掉围栏行的行尾空白;自己取语言名用
split_whitespace。检查:在代码块文字里找「围栏字符加制表符」的行。