pub(crate) fn sanitize_comment(text: &str) -> String {
let raw_lines: Vec<&str> = text.lines().collect();
let mut lines: Vec<String> = Vec::with_capacity(raw_lines.len());
let mut in_code_block = false;
let mut open_fence: Option<(char, usize)> = None;
for (idx, line) in raw_lines.iter().enumerate() {
if let Some(open) = open_fence {
if fence_marker(line).is_some_and(|f| f.closes(open)) {
open_fence = None;
}
lines.push((*line).to_string());
continue;
}
if in_code_block {
if is_indented(line) {
lines.push(strip_indent(line));
continue;
}
if line.is_empty() {
let next_is_indented = raw_lines[idx + 1..]
.iter()
.find(|l| !l.is_empty())
.is_some_and(|l| is_indented(l));
if next_is_indented {
lines.push(String::new());
continue;
}
}
lines.push("```".to_string());
in_code_block = false;
}
if let Some(fence) = fence_marker(line) {
open_fence = Some((fence.ch, fence.len));
let run = fence.ch.to_string().repeat(fence.len);
lines.push(format!("{}{run}{}", fence.indent, fence_info(fence.info)));
} else if is_indented(line) {
lines.push("```text".to_string());
in_code_block = true;
lines.push(strip_indent(line));
} else {
lines.push(sanitize_line(line));
}
}
if in_code_block {
lines.push("```".to_string());
}
if let Some((ch, len)) = open_fence {
lines.push(ch.to_string().repeat(len));
}
lines.join("\n")
}
fn fence_info(info: &str) -> String {
let mut dropped_target_ignore = false;
let mut tokens: Vec<&str> = Vec::new();
for tok in info.split([',', ' ', '\t']).filter(|t| !t.is_empty()) {
if tok.starts_with("ignore-") {
dropped_target_ignore = true;
} else {
tokens.push(tok);
}
}
if tokens.is_empty() {
return if dropped_target_ignore {
"rust,ignore".to_string()
} else {
"text".to_string()
};
}
if !tokens.contains(&"ignore") {
tokens.push("ignore");
}
tokens.join(",")
}
fn is_indented(line: &str) -> bool {
line.starts_with(" ") || line.starts_with('\t')
}
fn strip_indent(line: &str) -> String {
let stripped = line
.strip_prefix(" ")
.or_else(|| line.strip_prefix('\t'))
.unwrap_or(line);
if stripped.trim_start().starts_with("```") {
(*line).to_string()
} else {
stripped.to_string()
}
}
struct Fence<'a> {
indent: &'a str,
ch: char,
len: usize,
info: &'a str,
}
impl Fence<'_> {
fn closes(&self, open: (char, usize)) -> bool {
self.ch == open.0 && self.len >= open.1 && self.info.chars().all(|c| c == ' ' || c == '\t')
}
}
fn fence_marker(line: &str) -> Option<Fence<'_>> {
if is_indented(line) {
return None;
}
let indent_len = line.len() - line.trim_start_matches(' ').len();
let (indent, rest) = line.split_at(indent_len);
let ch = rest.chars().next()?;
if ch != '`' && ch != '~' {
return None;
}
let info = rest.trim_start_matches(ch);
let len = rest.len() - info.len();
if len < 3 || (ch == '`' && info.contains('`')) {
return None;
}
Some(Fence {
indent,
ch,
len,
info,
})
}
fn sanitize_line(line: &str) -> String {
let bytes = line.as_bytes();
let mut out = String::with_capacity(line.len());
let mut i = 0;
while i < bytes.len() {
let b = bytes[i];
if b == b'`' {
let run_start = i;
while i < bytes.len() && bytes[i] == b'`' {
i += 1;
}
let run_len = i - run_start;
if let Some(close_end) = find_backtick_closer(bytes, i, run_len) {
out.push_str(&line[run_start..close_end]);
i = close_end;
} else {
out.push_str(&line[run_start..i]);
}
continue;
}
match b {
b'\\' => {
out.push('\\');
i += 1;
if i < bytes.len() {
i += push_char_at(&mut out, line, i);
}
}
b'[' => {
if let Some(end) = find_inline_link_end(bytes, i) {
out.push_str(&line[i..=end]);
i = end + 1;
} else {
out.push_str("\\[");
i += 1;
}
}
b']' => {
out.push_str("\\]");
i += 1;
}
b'<' => {
if let Some(end) = find_autolink_end(bytes, i) {
out.push_str(&line[i..=end]);
i = end + 1;
} else {
out.push_str("\\<");
i += 1;
}
}
b'>' => {
out.push_str("\\>");
i += 1;
}
b'h' => {
if let Some(end) = find_bare_url_end(bytes, i) {
out.push('<');
out.push_str(&line[i..end]);
out.push('>');
i = end;
} else {
out.push('h');
i += 1;
}
}
_ => {
i += push_char_at(&mut out, line, i);
}
}
}
out
}
fn push_char_at(out: &mut String, s: &str, i: usize) -> usize {
let ch = s[i..]
.chars()
.next()
.expect("i is in bounds and on a char boundary");
out.push(ch);
ch.len_utf8()
}
fn find_backtick_closer(bytes: &[u8], from: usize, run_len: usize) -> Option<usize> {
let mut i = from;
while i < bytes.len() {
if bytes[i] == b'`' {
let start = i;
while i < bytes.len() && bytes[i] == b'`' {
i += 1;
}
if i - start == run_len {
return Some(i);
}
} else {
i += 1;
}
}
None
}
fn find_inline_link_end(bytes: &[u8], start: usize) -> Option<usize> {
debug_assert_eq!(bytes[start], b'[');
let mut j = start + 1;
while j < bytes.len() && bytes[j] != b']' {
if bytes[j] == b'[' {
return None;
}
j += 1;
}
if j + 1 >= bytes.len() || bytes[j + 1] != b'(' {
return None;
}
let mut depth = 1i32;
let mut k = j + 2;
while k < bytes.len() {
match bytes[k] {
b'(' => depth += 1,
b')' => {
depth -= 1;
if depth == 0 {
return Some(k);
}
}
_ => {}
}
k += 1;
}
None
}
fn has_url_scheme(rest: &[u8]) -> bool {
rest.starts_with(b"http://") || rest.starts_with(b"https://")
}
fn find_autolink_end(bytes: &[u8], start: usize) -> Option<usize> {
debug_assert_eq!(bytes[start], b'<');
let rest = &bytes[start + 1..];
if !has_url_scheme(rest) {
return None;
}
rest.iter().position(|&b| b == b'>').map(|p| start + 1 + p)
}
fn find_bare_url_end(bytes: &[u8], start: usize) -> Option<usize> {
if !has_url_scheme(&bytes[start..]) {
return None;
}
let mut j = start;
while j < bytes.len()
&& !bytes[j].is_ascii_whitespace()
&& !matches!(bytes[j], b')' | b'<' | b'>')
{
j += 1;
}
while j > start && matches!(bytes[j - 1], b'.' | b',' | b';' | b':' | b'!' | b'?') {
j -= 1;
}
Some(j)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn brackets_escaped() {
assert_eq!(
sanitize_line("see [Note] for details"),
"see \\[Note\\] for details"
);
}
#[test]
fn angle_brackets_escaped() {
assert_eq!(
sanitize_line("a map<string, int32> field"),
"a map\\<string, int32\\> field"
);
}
#[test]
fn author_escapes_pass_through() {
assert_eq!(sanitize_line(r"a \[not a link\]"), r"a \[not a link\]");
assert_eq!(sanitize_line(r"trailing \"), r"trailing \");
}
#[test]
fn code_spans_pass_through() {
assert_eq!(
sanitize_line("use `[T; N]` or `<T>`"),
"use `[T; N]` or `<T>`"
);
}
#[test]
fn unmatched_backtick_run_passes_through() {
assert_eq!(sanitize_line("stray ` tick [x]"), "stray ` tick \\[x\\]");
}
#[test]
fn inline_links_preserved() {
assert_eq!(
sanitize_line("see [docs](https://example.com/a#m()) here"),
"see [docs](https://example.com/a#m()) here"
);
}
#[test]
fn autolinks_preserved() {
assert_eq!(
sanitize_line("visit <https://example.com> now"),
"visit <https://example.com> now"
);
}
#[test]
fn bare_urls_wrapped() {
assert_eq!(
sanitize_line("docs at https://example.com/x, see there"),
"docs at <https://example.com/x>, see there"
);
assert_eq!(
sanitize_line("(https://example.com)"),
"(<https://example.com>)"
);
assert_eq!(
sanitize_line("see https://example.com."),
"see <https://example.com>."
);
}
#[test]
fn ref_style_link_falls_back_to_escaping() {
assert_eq!(
sanitize_line("see [Note][commented.Note]"),
"see \\[Note\\]\\[commented.Note\\]"
);
}
#[test]
fn user_fences_pass_through() {
let input = "Example:\n```json\n{\"a\": [1]}\n```\ndone [x]";
let expected = "Example:\n```json,ignore\n{\"a\": [1]}\n```\ndone \\[x\\]";
assert_eq!(sanitize_comment(input), expected);
}
#[test]
fn bare_fence_defaults_to_text() {
let input = "Example:\n```\n{\"a\": 1}\n```\ndone";
let expected = "Example:\n```text\n{\"a\": 1}\n```\ndone";
assert_eq!(sanitize_comment(input), expected);
}
#[test]
fn unterminated_fence_closed() {
let input = "para\n```\nstuff";
assert_eq!(sanitize_comment(input), "para\n```text\nstuff\n```");
}
#[test]
fn longer_fence_closed_with_matching_ticks() {
let input = "````\nnested ``` inside";
assert_eq!(sanitize_comment(input), "````text\nnested ``` inside\n````");
}
#[test]
fn rust_fence_is_marked_ignore_but_keeps_highlighting() {
let input = "```rust\nlet x = Note::default();\n```";
assert_eq!(
sanitize_comment(input),
"```rust,ignore\nlet x = Note::default();\n```"
);
}
#[test]
fn every_fence_is_made_inert() {
let cases = [
("rust", "rust,ignore"),
("no_run", "no_run,ignore"),
("should_panic", "should_panic,ignore"),
("compile_fail", "compile_fail,ignore"),
("compile_fail,E0277", "compile_fail,E0277,ignore"),
("rust,noplayground", "rust,noplayground,ignore"),
("edition2018", "edition2018,ignore"),
("ignore-wasm32", "rust,ignore"),
("rust,ignore-wasm32", "rust,ignore"),
("ignore-wasm32,ignore", "ignore"),
("json", "json,ignore"),
("proto", "proto,ignore"),
("rust no_run", "rust,no_run,ignore"),
];
for (info, expected) in cases {
assert_eq!(
sanitize_comment(&format!("```{info}\nbody\n```")),
format!("```{expected}\nbody\n```"),
"info string: {info}"
);
}
}
#[test]
fn tilde_fences_are_inerted_too() {
assert_eq!(
sanitize_comment("~~~rust\nlet x = 1;\n~~~"),
"~~~rust,ignore\nlet x = 1;\n~~~"
);
assert_eq!(
sanitize_comment("~~~\n{\"a\": 1}\n~~~"),
"~~~text\n{\"a\": 1}\n~~~"
);
assert_eq!(sanitize_comment("~~~\nx\n```"), "~~~text\nx\n```\n~~~");
}
#[test]
fn exotic_leading_whitespace_is_not_a_fence() {
for lead in ["\u{a0}", " \t"] {
let out = sanitize_comment(&format!("{lead}```\nlet x = 1;\n```"));
assert!(
out.starts_with(&format!("{lead}```\n")),
"opener-lookalike must stay prose: {out:?}"
);
assert!(
out.ends_with("```text\n```"),
"the run rustdoc treats as the real opener must be inerted \
and closed: {out:?}"
);
}
}
#[test]
fn closer_may_only_trail_spaces_or_tabs() {
let out = sanitize_comment("```\nx\n```\u{a0}");
assert!(
out.ends_with("\n```"),
"unclosed fence must be force-closed: {out:?}"
);
}
#[test]
fn backtick_in_info_string_is_not_a_fence() {
let out = sanitize_comment("```rust```\nx");
assert!(!out.contains("ignore"), "not treated as a fence: {out}");
}
#[test]
fn already_ignored_fences_are_untouched() {
for info in ["rust,ignore", "ignore", "ignore,json"] {
let input = format!("```{info}\nbody\n```");
assert_eq!(sanitize_comment(&input), input, "info string: {info}");
}
}
#[test]
fn indented_fence_opener_keeps_indent() {
assert_eq!(
sanitize_comment(" ```rust\n x\n ```"),
" ```rust,ignore\n x\n ```"
);
}
#[test]
fn four_tick_fence_keeps_inner_three_tick_line() {
let input = "````\n```\ncode\n````";
assert_eq!(sanitize_comment(input), "````text\n```\ncode\n````");
}
#[test]
fn info_string_line_inside_fence_is_content() {
let input = "```json\n{}\n```rust\nx\n```";
assert_eq!(
sanitize_comment(input),
"```json,ignore\n{}\n```rust\nx\n```"
);
}
#[test]
fn fence_directly_after_indented_block_is_tracked() {
let input = " x = 1\n```\nnot rust\n```";
assert_eq!(
sanitize_comment(input),
"```text\nx = 1\n```\n```text\nnot rust\n```"
);
}
#[test]
fn indented_fence_marker_is_code_not_fence() {
let input = " ```\n x";
assert_eq!(sanitize_comment(input), "```text\n ```\nx\n```");
}
#[test]
fn fence_line_inside_indented_block_keeps_indent() {
let input = " code\n ```\n more";
assert_eq!(sanitize_comment(input), "```text\ncode\n ```\nmore\n```");
}
#[test]
fn url_with_angle_bracket_stops_at_bracket() {
assert_eq!(
sanitize_line("see http://example.com/a>b"),
"see <http://example.com/a>\\>b"
);
}
#[test]
fn indented_block_fenced_as_text() {
let input = "Usage:\n req = Note{}\n send(req)\nDone.";
let expected = "Usage:\n```text\nreq = Note{}\nsend(req)\n```\nDone.";
assert_eq!(sanitize_comment(input), expected);
}
#[test]
fn blank_line_inside_indented_block_kept_open() {
let input = " a\n\n b\nprose";
let expected = "```text\na\n\nb\n```\nprose";
assert_eq!(sanitize_comment(input), expected);
}
#[test]
fn trailing_indented_block_closed() {
let input = "text\n code";
assert_eq!(sanitize_comment(input), "text\n```text\ncode\n```");
}
#[test]
fn multibyte_chars_survive_around_metachars() {
assert_eq!(
sanitize_line("émoji 🦀 <T> — done"),
"émoji 🦀 \\<T\\> — done"
);
assert_eq!(
sanitize_line("précis at https://例え.jp/パス end"),
"précis at <https://例え.jp/パス> end"
);
}
#[test]
fn blank_lines_preserved() {
assert_eq!(
sanitize_comment("para one\n\npara two"),
"para one\n\npara two"
);
}
}