use std::ops::Range;
const MASK_CHAR: char = '\u{FFFC}';
const MIN_FENCE_LEN: usize = 3;
#[must_use]
pub fn mask_code(text: &str) -> String {
let unfenced = apply_mask(text, &fenced_ranges(text));
apply_mask(&unfenced, &inline_ranges(&unfenced))
}
fn apply_mask(text: &str, ranges: &[Range<usize>]) -> String {
if ranges.is_empty() {
return text.to_string();
}
let mut out = String::with_capacity(text.len());
let mut cursor = 0;
for range in ranges {
out.push_str(&text[cursor..range.start]);
out.extend(
text[range.clone()]
.chars()
.map(|c| if c == '\n' { c } else { MASK_CHAR }),
);
cursor = range.end;
}
out.push_str(&text[cursor..]);
out
}
#[derive(Clone, Copy)]
struct Fence {
marker: char,
len: usize,
}
fn parse_fence_line(line: &str) -> Option<(Fence, &str)> {
let mut rest = line.trim_end_matches(['\n', '\r']);
loop {
rest = rest.trim_start_matches([' ', '\t']);
match rest.strip_prefix('>') {
Some(after) => rest = after,
None => break,
}
}
let marker = rest.chars().next().filter(|c| matches!(c, '`' | '~'))?;
let len = rest.chars().take_while(|&c| c == marker).count();
(len >= MIN_FENCE_LEN).then(|| {
(
Fence { marker, len },
&rest[len..], )
})
}
fn opening_fence(line: &str) -> Option<Fence> {
let (fence, info) = parse_fence_line(line)?;
(fence.marker != '`' || !info.contains('`')).then_some(fence)
}
fn closes_fence(line: &str, open: Fence) -> bool {
parse_fence_line(line).is_some_and(|(fence, after)| {
fence.marker == open.marker && fence.len >= open.len && after.trim().is_empty()
})
}
fn fenced_ranges(text: &str) -> Vec<Range<usize>> {
let mut ranges = Vec::new();
let mut open: Option<(Fence, usize)> = None;
let mut offset = 0;
for line in text.split_inclusive('\n') {
let end = offset + line.len();
match open {
None => {
if let Some(fence) = opening_fence(line) {
open = Some((fence, offset));
}
}
Some((fence, start)) => {
if closes_fence(line, fence) {
ranges.push(start..end);
open = None;
}
}
}
offset = end;
}
ranges
}
fn paragraphs(text: &str) -> Vec<Range<usize>> {
let mut paragraphs = Vec::new();
let mut start: Option<usize> = None;
let mut offset = 0;
for line in text.split_inclusive('\n') {
let trimmed = line.trim_start();
let separator = trimmed.is_empty() || trimmed.starts_with(MASK_CHAR);
match (separator, start) {
(false, None) => start = Some(offset),
(true, Some(s)) => {
paragraphs.push(s..offset);
start = None;
}
_ => {}
}
offset += line.len();
}
if let Some(s) = start {
paragraphs.push(s..text.len());
}
paragraphs
}
struct Run {
range: Range<usize>,
escaped: bool,
}
impl Run {
fn opening_len(&self) -> usize {
self.range.len() - usize::from(self.escaped)
}
}
fn inline_ranges(text: &str) -> Vec<Range<usize>> {
let mut ranges = Vec::new();
for paragraph in paragraphs(text) {
let runs = backtick_runs(text, paragraph);
let mut k = 0;
while k < runs.len() {
let opener = &runs[k];
let closer = (opener.opening_len() > 0)
.then(|| {
runs[k + 1..]
.iter()
.position(|run| run.range.len() == opener.opening_len())
})
.flatten();
match closer {
Some(offset) => {
let start = opener.range.end - opener.opening_len();
ranges.push(start..runs[k + 1 + offset].range.end);
k += offset + 2;
}
None => k += 1,
}
}
}
ranges
}
fn backtick_runs(text: &str, paragraph: Range<usize>) -> Vec<Run> {
let mut runs = Vec::new();
let mut start: Option<usize> = None;
let mut push = |start: usize, end: usize| {
let backslashes = text[..start]
.chars()
.rev()
.take_while(|&c| c == '\\')
.count();
runs.push(Run {
range: start..end,
escaped: backslashes % 2 == 1,
});
};
for (i, c) in text[paragraph.clone()].char_indices() {
let i = paragraph.start + i;
match (c == '`', start) {
(true, None) => start = Some(i),
(false, Some(s)) => {
push(s, i);
start = None;
}
_ => {}
}
}
if let Some(s) = start {
push(s, paragraph.end);
}
runs
}
#[cfg(test)]
mod tests {
use super::*;
fn visible(text: &str) -> String {
mask_code(text)
.chars()
.map(|c| if c == MASK_CHAR { '_' } else { c })
.collect()
}
#[test]
fn text_without_code_is_unchanged() {
let text = "See #1614 and owner/repo#5.\n\nA second paragraph.";
assert_eq!(mask_code(text), text);
}
#[test]
fn a_backtick_fence_is_masked_through_its_closing_line() {
assert_eq!(
visible("before\n```\no/r#1\n```\nafter #2"),
"before\n___\n_____\n___\nafter #2"
);
}
#[test]
fn a_tilde_fence_is_masked() {
assert_eq!(visible("~~~\no/r#1\n~~~\n#2"), "___\n_____\n___\n#2");
}
#[test]
fn a_fence_info_string_is_part_of_the_block() {
assert_eq!(
visible("```rust\no/r#1\n```\n#2"),
"_______\n_____\n___\n#2"
);
}
#[test]
fn a_closing_fence_may_be_longer_than_the_opening_one() {
assert_eq!(visible("```\no/r#1\n`````\n#2"), "___\n_____\n_____\n#2");
}
#[test]
fn a_shorter_run_does_not_close_a_fence() {
assert_eq!(
visible("````\n```\no/r#1\n````\n#2"),
"____\n___\n_____\n____\n#2"
);
}
#[test]
fn a_fence_of_the_other_marker_does_not_close_a_fence() {
assert_eq!(
visible("```\n~~~\no/r#1\n```\n#2"),
"___\n___\n_____\n___\n#2"
);
}
#[test]
fn a_closing_fence_carrying_text_does_not_close_the_fence() {
assert_eq!(
visible("```\n``` x\no/r#1\n```\n#2"),
"___\n_____\n_____\n___\n#2"
);
}
#[test]
fn a_fence_indented_up_to_three_spaces_is_still_a_fence() {
assert_eq!(
visible(" ```\no/r#1\n ```\n#2"),
"______\n_____\n______\n#2"
);
}
#[test]
fn a_fence_nested_in_a_list_item_is_masked() {
assert_eq!(
visible("1. step\n\n ~~~\n o/r#1\n ~~~\n\n#2"),
"1. step\n\n__________\n____________\n__________\n\n#2"
);
}
#[test]
fn a_blockquoted_fence_is_masked() {
assert_eq!(
visible("> ~~~\n> o/r#1\n> ~~~\n#2"),
"_____\n_______\n_____\n#2"
);
}
#[test]
fn a_span_does_not_pair_across_a_fenced_block() {
assert_eq!(
visible("a ` b\n```\ncode\n```\nreal #1 ` d"),
"a ` b\n___\n____\n___\nreal #1 ` d"
);
}
#[test]
fn an_escaped_backtick_does_not_open_a_span() {
let text = "use \\`#5\\` literally, see #6";
assert_eq!(mask_code(text), text);
}
#[test]
fn an_escaped_backtick_does_not_stop_a_later_span() {
assert_eq!(visible("a \\` b `c` d"), "a \\` b ___ d");
}
#[test]
fn a_backslash_before_a_closing_backtick_still_closes_the_span() {
assert_eq!(visible("`a\\` #1"), "____ #1");
}
#[test]
fn bodies_masked_together_can_pair_a_stray_fence_with_a_later_block() {
assert_ne!(
mask_code("```\nstray\n\n```\nquoted o/r#9\n```"),
format!(
"{}{}",
mask_code("```\nstray\n"),
mask_code("\n```\nquoted o/r#9\n```")
)
);
}
#[test]
fn an_unclosed_fence_is_left_as_text() {
let text = "```\no/r#1\nstill going #2";
assert_eq!(mask_code(text), text);
}
#[test]
fn a_backtick_line_whose_info_string_has_a_backtick_is_not_a_fence() {
assert_eq!(visible("``` a`b` c\n#2"), "``` a___ c\n#2");
}
#[test]
fn a_crlf_fence_is_masked() {
assert_eq!(
visible("```\r\no/r#1\r\n```\r\n#2"),
"____\n______\n____\n#2"
);
}
#[test]
fn an_inline_span_is_masked_and_the_rest_is_kept() {
assert_eq!(visible("use `o/r#1` here #2"), "use _______ here #2");
}
#[test]
fn a_double_backtick_span_may_contain_a_single_backtick() {
assert_eq!(visible("x ``a ` b`` y"), "x _________ y");
}
#[test]
fn an_unmatched_backtick_run_is_literal() {
let text = "a ` b #1";
assert_eq!(mask_code(text), text);
}
#[test]
fn runs_of_different_lengths_do_not_pair() {
let text = "a `` b ` c #1";
assert_eq!(mask_code(text), text);
}
#[test]
fn an_inline_span_may_cross_a_line_break() {
assert_eq!(visible("`o/r\n#1` #2"), "____\n___ #2");
}
#[test]
fn an_inline_span_does_not_cross_a_blank_line() {
let text = "a ` b\n\nreal #1\n\nc ` d";
assert_eq!(mask_code(text), text);
}
#[test]
fn an_unmatched_opener_does_not_stop_a_later_pair_matching() {
assert_eq!(visible("`a\n\nb `c` d"), "`a\n\nb ___ d");
}
#[test]
fn two_spans_on_one_line_are_masked_separately() {
assert_eq!(visible("`a` #1 `b`"), "___ #1 ___");
}
#[test]
fn a_span_inside_a_fence_does_not_open_a_second_mask() {
assert_eq!(visible("```\n`a`\n```\n#1"), "___\n___\n___\n#1");
}
#[test]
fn masking_keeps_multibyte_text_around_a_span_intact() {
assert_eq!(visible("café `é#1` ünï #2"), "café _____ ünï #2");
}
#[test]
fn every_newline_survives_masking() {
let text = "a\n```\nb\n\nc\n```\n`d\ne`\nf\n";
assert_eq!(
mask_code(text).matches('\n').count(),
text.matches('\n').count()
);
}
#[test]
fn an_empty_string_masks_to_an_empty_string() {
assert_eq!(mask_code(""), "");
}
}