use std::ops::Range;
use pulldown_cmark::{Event, Options, Parser, Tag};
pub fn parser_options() -> Options {
let mut options = Options::empty();
options.insert(Options::ENABLE_TABLES);
options
}
fn mask_ranges(text: &str, ranges: &[Range<usize>]) -> String {
let mut bytes = text.as_bytes().to_vec();
for range in ranges {
let end = range.end.min(bytes.len());
let start = range.start.min(end);
for b in &mut bytes[start..end] {
if *b != b'\n' && *b != b'\r' {
*b = b' ';
}
}
}
String::from_utf8(bytes).unwrap_or_else(|_| text.to_string())
}
fn code_ranges(text: &str, spans: bool) -> Vec<Range<usize>> {
let mut out = Vec::new();
let mut block_end = 0usize;
for (event, range) in Parser::new_ext(text, parser_options()).into_offset_iter() {
match event {
Event::Start(Tag::CodeBlock(_)) => {
block_end = block_end.max(range.end);
out.push(range);
}
Event::Code(_) if spans && range.start >= block_end => out.push(range),
_ => {}
}
}
out
}
pub fn mask_code_blocks(text: &str) -> String {
mask_ranges(text, &code_ranges(text, false))
}
pub fn mask_code_blocks_and_spans(text: &str) -> String {
mask_ranges(text, &code_ranges(text, true))
}
pub fn closing_fence_if_unterminated(text: &str) -> Option<String> {
if !text.contains("```") && !text.contains("~~~") {
return None;
}
const PROBE: &str = "memstead-fence-probe";
let survives = |t: &str| mask_code_blocks(&format!("{t}\n{PROBE}")).ends_with(PROBE);
if survives(text) {
return None;
}
let ranges = code_ranges(text, false);
let open = ranges.iter().rfind(|r| r.end >= text.len())?;
let first_line = text[open.start..].lines().next().unwrap_or("");
let fence = first_line.trim_start();
let ch = fence.chars().next()?;
if ch != '`' && ch != '~' {
return None;
}
let count = fence.chars().take_while(|c| *c == ch).count().max(3);
let cand = ch.to_string().repeat(count);
if survives(&format!("{text}\n{cand}")) {
Some(cand)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn assert_offset_preserving(input: &str, masked: &str) {
assert_eq!(input.len(), masked.len(), "byte length must be preserved");
assert_eq!(
input.match_indices('\n').collect::<Vec<_>>(),
masked.match_indices('\n').collect::<Vec<_>>(),
"newline positions must be preserved"
);
}
fn mask(input: &str) -> String {
let masked = mask_code_blocks(input);
assert_offset_preserving(input, &masked);
masked
}
fn mask_all(input: &str) -> String {
let masked = mask_code_blocks_and_spans(input);
assert_offset_preserving(input, &masked);
masked
}
#[test]
fn class_1_indented_code_block_is_masked() {
let input = "Text:\n\n ## Not A Heading\n [[not-a-link]]\n\nAfter\n";
let masked = mask(input);
assert!(!masked.contains("## Not A Heading"));
assert!(!masked.contains("[[not-a-link]]"));
assert!(masked.contains("After"));
}
#[test]
fn class_2_indented_fence_opens_a_block() {
let input = "- item\n\n ```\n ## Not A Heading\n ```\n\nAfter\n";
let masked = mask(input);
assert!(!masked.contains("## Not A Heading"));
assert!(masked.contains("After"));
}
#[test]
fn class_3_tilde_fence_is_masked() {
let input = "~~~\n## Not A Heading\n[[not-a-link]]\n~~~\n\nAfter\n";
let masked = mask(input);
assert!(!masked.contains("## Not A Heading"));
assert!(!masked.contains("[[not-a-link]]"));
assert!(masked.contains("After"));
}
#[test]
fn class_4_info_string_on_a_closing_line_does_not_close() {
let input = "```\ncode\n``` not-a-closer\n## Not A Heading\n```\n\nAfter\n";
let masked = mask(input);
assert!(!masked.contains("## Not A Heading"));
assert!(masked.contains("After"));
}
#[test]
fn class_5_fence_inside_a_blockquote_is_masked() {
let input = "> ```\n> ## Not A Heading\n> [[not-a-link]]\n> ```\n\nAfter\n";
let masked = mask(input);
assert!(!masked.contains("## Not A Heading"));
assert!(!masked.contains("[[not-a-link]]"));
assert!(masked.contains("After"));
}
#[test]
fn class_6_longer_fence_is_not_closed_by_a_shorter_one() {
let input = "````\n```\n## Not A Heading\n```\n````\n\nAfter\n";
let masked = mask(input);
assert!(!masked.contains("## Not A Heading"));
assert!(masked.contains("After"));
}
#[test]
fn prose_heading_and_links_survive() {
let input = "# Title\n\n## Section\n\nSee [[other-entity]] and [[a|b]].\n";
let masked = mask(input);
assert_eq!(masked, input);
}
#[test]
fn fenced_block_masking_matches_the_old_bare_fence_behaviour() {
let input = "before\n```rust\nfn f() {}\n```\nafter\n";
let masked = mask(input);
assert!(masked.starts_with("before\n"));
assert!(masked.ends_with("after\n"));
assert!(!masked.contains("fn f()"));
assert!(!masked.contains("```"));
}
#[test]
fn unclosed_fence_masks_to_end_of_text() {
let input = "before\n```\n## Not A Heading\nstill inside\n";
let masked = mask(input);
assert!(masked.starts_with("before\n"));
assert!(!masked.contains("## Not A Heading"));
assert!(!masked.contains("still inside"));
}
#[test]
fn multibyte_content_masks_without_corruption() {
let input = "```\nGrüße — ünïcødé ✓\n```\nafter ✓\n";
let masked = mask(input);
assert!(!masked.contains("Grüße"));
assert!(masked.contains("after ✓"));
}
#[test]
fn crlf_line_endings_survive_masking() {
let input = "before\r\n```\r\n## Not A Heading\r\n```\r\nafter\r\n";
let masked = mask(input);
assert!(!masked.contains("## Not A Heading"));
assert!(masked.contains("after"));
assert_eq!(input.matches('\r').count(), masked.matches('\r').count());
}
#[test]
fn inline_span_hides_a_link() {
let input = "See `[[not-a-link]]` but [[real-link]].\n";
let masked = mask_all(input);
assert!(!masked.contains("[[not-a-link]]"));
assert!(masked.contains("[[real-link]]"));
}
#[test]
fn double_backtick_span_hides_a_link() {
let input = "See `` [[not-a-link]] `` but [[real-link]].\n";
let masked = mask_all(input);
assert!(!masked.contains("[[not-a-link]]"));
assert!(masked.contains("[[real-link]]"));
}
#[test]
fn backtick_inside_a_double_backtick_span_leaves_no_remnant() {
let input = "Literal ``a ` b`` then [[real-link]].\n";
let masked = mask_all(input);
assert!(!masked.contains('`'));
assert!(masked.contains("[[real-link]]"));
}
#[test]
fn unterminated_backtick_fence_yields_matching_closer() {
assert_eq!(
closing_fence_if_unterminated("```\ncode with no closer"),
Some("```".to_string())
);
assert_eq!(
closing_fence_if_unterminated("````\n```\nstill inside"),
Some("````".to_string())
);
assert_eq!(
closing_fence_if_unterminated("~~~\ntilde block"),
Some("~~~".to_string())
);
}
#[test]
fn balanced_and_container_fences_need_no_closer() {
assert_eq!(closing_fence_if_unterminated("```\ncode\n```"), None);
assert_eq!(closing_fence_if_unterminated("no fences at all"), None);
assert_eq!(closing_fence_if_unterminated("> ```\n> quoted"), None);
assert_eq!(closing_fence_if_unterminated("text:\n\n code"), None);
}
#[test]
fn block_mask_does_not_double_count_inline_spans_inside_blocks() {
let input = "```\nlet s = `x`;\n```\nafter `y`.\n";
let masked = mask_all(input);
assert!(!masked.contains('`'));
assert!(masked.contains("after"));
}
}