Rust in Everything
基于 pulldown-cmark 0.13.5

结束围栏后多了一个制表符,代码块吞掉了后文

主线位置:块主线的叶子块。围栏代码块怎么找结束围栏、规范 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 相同。

📝NOTE
没有蠢问题

问: 开头围栏的信息串里写制表符呢?比如 ```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。检查:在代码块文字里找「围栏字符加制表符」的行。

💬 关联讨论

发起讨论
加载中...