diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 542b325..dfc5e54 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -132,6 +132,73 @@ mod tests { "data:image/png;base64,TWFya3BhZA==", ); } + + #[test] + fn embed_protection_survives_longer_backtick_runs_earlier_in_the_doc() { + // A 4-backtick inline sample desynchronized the old regex pairing and + // exposed every later code span to rewriting. + let input = "```` ```mermaid ```` fence sample\n\ncode: `![[not-an-embed.md]]`\n"; + let out = process_internal_embeds(input); + assert!(out.contains("`![[not-an-embed.md]]`"), "got: {out}"); + assert!(!out.contains("this is"), "got: {out}"); + } + + #[test] + fn wikilinks_and_inline_footnotes_in_code_spans_stay_literal() { + let input = "`[[#heading]]` and `^[not a footnote]` but [[#real|jump]]\n"; + let out = process_wikilinks(input); + assert!(out.contains("`[[#heading]]`"), "got: {out}"); + assert!(out.contains("`^[not a footnote]`"), "got: {out}"); + assert!(out.contains("[jump](#real)"), "got: {out}"); + } } struct WatcherState { @@ -153,13 +220,133 @@ fn bring_webview_window_to_front(window: &tauri::WebviewWindow) { let _ = window.set_focus(); } +/// Byte ranges of code regions — fenced code blocks and inline code spans — +/// paired with CommonMark's rules. The regex alternation previously used for +/// protection (```` ```.*?```|`.*?` ````) cannot express them: a fence closes +/// only on a line-leading run of the same character at least as long as the +/// opener, and a span opened by N backticks closes only on a run of exactly +/// N. One mismatched pairing (e.g. a 4-backtick inline sample, or a ~~~ +/// fence, which the old pattern did not know at all) desynchronized the +/// protection for the entire rest of the document. +fn code_region_ranges(content: &str) -> Vec<(usize, usize)> { + let len = content.len(); + let mut regions: Vec<(usize, usize)> = Vec::new(); + let mut plain_segments: Vec<(usize, usize)> = Vec::new(); + // (fence char, opener run length, region start) + let mut fence: Option<(u8, usize, usize)> = None; + let mut seg_start = 0usize; + + let mut line_start = 0usize; + while line_start < len { + let line_end = content[line_start..] + .find('\n') + .map(|i| line_start + i + 1) + .unwrap_or(len); + let line = &content[line_start..line_end]; + let trimmed = line.trim_start_matches(' '); + let indent = line.len() - trimmed.len(); + let marker = trimmed.as_bytes().first().copied(); + let run_len = trimmed + .as_bytes() + .iter() + .take_while(|&&b| Some(b) == marker) + .count(); + let is_fence_line = indent <= 3 + && matches!(marker, Some(b'`') | Some(b'~')) + && run_len >= 3; + + match fence { + Some((ch, opener_len, start)) => { + let only_spaces_after = trimmed[run_len..].trim().is_empty(); + if is_fence_line + && marker == Some(ch) + && run_len >= opener_len + && only_spaces_after + { + regions.push((start, line_end)); + fence = None; + seg_start = line_end; + } + } + None => { + // The info string of a backtick fence may not contain backticks. + let info_ok = marker != Some(b'`') || !trimmed[run_len..].contains('`'); + if is_fence_line && info_ok { + if seg_start < line_start { + plain_segments.push((seg_start, line_start)); + } + fence = Some((marker.unwrap(), run_len, line_start)); + } + } + } + line_start = line_end; + } + match fence { + // An unclosed fence runs to the end of the input. + Some((_, _, start)) => regions.push((start, len)), + None => { + if seg_start < len { + plain_segments.push((seg_start, len)); + } + } + } + + // Inline code spans in the text between fences: a run of N backticks + // pairs with the next run of exactly N; runs that never pair are literal. + for (seg_s, seg_e) in plain_segments { + let seg = content[seg_s..seg_e].as_bytes(); + let mut runs: Vec<(usize, usize)> = Vec::new(); // (offset in seg, len) + let mut i = 0usize; + while i < seg.len() { + if seg[i] == b'`' { + let start = i; + while i < seg.len() && seg[i] == b'`' { + i += 1; + } + runs.push((start, i - start)); + } else { + i += 1; + } + } + let mut r = 0usize; + while r < runs.len() { + let (open_start, open_len) = runs[r]; + if let Some(close) = (r + 1..runs.len()).find(|&j| runs[j].1 == open_len) { + let (close_start, close_len) = runs[close]; + regions.push((seg_s + open_start, seg_s + close_start + close_len)); + r = close + 1; + } else { + r += 1; + } + } + } + + regions.sort_unstable(); + regions +} + +fn in_code_region(regions: &[(usize, usize)], pos: usize) -> bool { + regions + .binary_search_by(|&(s, e)| { + if pos < s { + std::cmp::Ordering::Greater + } else if pos >= e { + std::cmp::Ordering::Less + } else { + std::cmp::Ordering::Equal + } + }) + .is_ok() +} + fn process_internal_embeds(content: &str) -> Cow<'_, str> { - let re = Regex::new(r"(?s)```.*?```|`.*?`|!\[\[(.*?)\]\]").unwrap(); + let regions = code_region_ranges(content); + let re = Regex::new(r"(?s)!\[\[(.*?)\]\]").unwrap(); re.replace_all(content, |caps: &Captures| { - let full_match = caps.get(0).unwrap().as_str(); - if full_match.starts_with('`') { - return full_match.to_string(); + let full = caps.get(0).unwrap(); + if in_code_region(®ions, full.start()) { + return full.as_str().to_string(); } let inner = caps.get(1).map(|m| m.as_str()).unwrap_or(""); @@ -194,12 +381,13 @@ fn process_wikilinks<'a>(content: &'a str) -> Cow<'a, str> { let mut processed = Cow::Borrowed(content); // 1. Process [[#target]] or [[#target|alias]] - let re_links = Regex::new(r"(?s)```.*?```|`.*?`|\[\[#([^\|\]]+)(?:\|([^\]]+))?\]\]").unwrap(); + let re_links = Regex::new(r"(?s)\[\[#([^\|\]]+)(?:\|([^\]]+))?\]\]").unwrap(); if re_links.is_match(&processed) { + let regions = code_region_ranges(&processed); let replaced = re_links.replace_all(&processed, |caps: &Captures| { - let full_match = caps.get(0).unwrap().as_str(); - if full_match.starts_with('`') { - return full_match.to_string(); + let full = caps.get(0).unwrap(); + if in_code_region(®ions, full.start()) { + return full.as_str().to_string(); } let target = caps.get(1).map(|m| m.as_str()).unwrap_or(""); let alias = caps.get(2).map(|m| m.as_str()).unwrap_or(target); @@ -211,12 +399,13 @@ fn process_wikilinks<'a>(content: &'a str) -> Cow<'a, str> { // 2. Process ^block-id at the end of lines // For block IDs, they are trailing. We skip code blocks but also need to be careful with inline code at EOL. - let re_ids = Regex::new(r"(?s)```.*?```|`.*?`|(?m)\s+\^([a-zA-Z0-9_-]+)$").unwrap(); + let re_ids = Regex::new(r"(?m)\s+\^([a-zA-Z0-9_-]+)$").unwrap(); if re_ids.is_match(&processed) { + let regions = code_region_ranges(&processed); let replaced = re_ids.replace_all(&processed, |caps: &Captures| { - let full_match = caps.get(0).unwrap().as_str(); - if full_match.starts_with('`') { - return full_match.to_string(); + let full = caps.get(0).unwrap(); + if in_code_region(®ions, full.start()) { + return full.as_str().to_string(); } let id = caps.get(1).map(|m| m.as_str()).unwrap_or(""); format!( @@ -228,12 +417,13 @@ fn process_wikilinks<'a>(content: &'a str) -> Cow<'a, str> { } // 3. Convert ==highlight== to highlight - let re_highlight = Regex::new(r"(?s)```.*?```|`.*?`|==([^=\n]+)==").unwrap(); + let re_highlight = Regex::new(r"==([^=\n]+)==").unwrap(); if re_highlight.is_match(&processed) { + let regions = code_region_ranges(&processed); let replaced = re_highlight.replace_all(&processed, |caps: &Captures| { - let full_match = caps.get(0).unwrap().as_str(); - if full_match.starts_with('`') { - return full_match.to_string(); + let full = caps.get(0).unwrap(); + if in_code_region(®ions, full.start()) { + return full.as_str().to_string(); } format!("{}", caps.get(1).unwrap().as_str()) }); @@ -241,14 +431,15 @@ fn process_wikilinks<'a>(content: &'a str) -> Cow<'a, str> { } // 4. Convert ^[inline footnote] to a footnote reference - let re_inline_fn = Regex::new(r"(?s)```.*?```|`.*?`|\^\[([^\]]+)\]").unwrap(); + let re_inline_fn = Regex::new(r"\^\[([^\]]+)\]").unwrap(); if re_inline_fn.is_match(&processed) { + let regions = code_region_ranges(&processed); let mut footnote_defs = String::new(); let mut fn_count = 0usize; let replaced = re_inline_fn.replace_all(&processed, |caps: &Captures| { - let full_match = caps.get(0).unwrap().as_str(); - if full_match.starts_with('`') { - return full_match.to_string(); + let full = caps.get(0).unwrap(); + if in_code_region(®ions, full.start()) { + return full.as_str().to_string(); } fn_count += 1; let label = format!("ifn-{}", fn_count);