use regex::Regex;
pub fn rewrite_wikilinks(content: &str, old_vault_path: &str, new_vault_path: &str) -> String {
let old_path = strip_md(old_vault_path);
let new_path = strip_md(new_vault_path);
let old_base = basename(&old_path);
let new_base = basename(&new_path);
let after_path = if old_path != old_base {
rewrite_target_form(content, &old_path, &new_path)
} else {
content.to_string()
};
rewrite_target_form(&after_path, &old_base, &new_base)
}
fn rewrite_target_form(content: &str, old: &str, new: &str) -> String {
let pattern = format!(r"(!?\[\[){}(\||#|\]\])", regex::escape(old));
let re = Regex::new(&pattern).expect("wikilink rewrite regex compile");
let apply = |text: &str| {
re.replace_all(text, |caps: ®ex::Captures| {
format!("{}{}{}", &caps[1], new, &caps[2])
})
.into_owned()
};
map_outside_code(content, &apply)
}
pub fn wrap_wikilinks_as_stale(content: &str, deleted_vault_path: &str) -> String {
let old_path = strip_md(deleted_vault_path);
let old_base = basename(&old_path);
let after_path = if old_path != old_base {
wrap_target_form(content, &old_path)
} else {
content.to_string()
};
wrap_target_form(&after_path, &old_base)
}
fn wrap_target_form(content: &str, target: &str) -> String {
let link_pat = format!(r"!?\[\[{}(?:[|#][^\]]*)?\]\]", regex::escape(target));
let re = Regex::new(&link_pat).expect("wrap regex compile");
let wrap_one = |text: &str| -> String {
let mut out = String::with_capacity(text.len());
let mut cursor = 0;
for m in re.find_iter(text) {
let already_wrapped =
text[..m.start()].ends_with("~~") && text[m.end()..].starts_with("~~");
out.push_str(&text[cursor..m.start()]);
if already_wrapped {
out.push_str(m.as_str());
} else {
out.push_str("~~");
out.push_str(m.as_str());
out.push_str("~~");
}
cursor = m.end();
}
out.push_str(&text[cursor..]);
out
};
map_outside_code(content, &wrap_one)
}
fn strip_md(p: &str) -> String {
match p.get(p.len().saturating_sub(3)..) {
Some(suffix) if suffix.eq_ignore_ascii_case(".md") => p[..p.len() - 3].to_string(),
_ => p.to_string(),
}
}
fn basename(p: &str) -> String {
p.rsplit('/').next().unwrap_or(p).to_string()
}
fn map_outside_code(content: &str, f: &dyn Fn(&str) -> String) -> String {
let mut out = String::with_capacity(content.len());
let mut fence: Option<(char, usize)> = None;
for line in content.split_inclusive('\n') {
let marker = fence_marker(line);
match fence {
Some((fc, flen)) => {
out.push_str(line); if let Some((mc, mlen)) = marker
&& mc == fc
&& mlen >= flen
{
fence = None;
}
}
None => match marker {
Some((mc, mlen)) => {
out.push_str(line); fence = Some((mc, mlen));
}
None => out.push_str(&map_outside_inline_code(line, f)),
},
}
}
out
}
fn fence_marker(line: &str) -> Option<(char, usize)> {
let trimmed = line.trim_start();
let first = trimmed.chars().next()?;
if first != '`' && first != '~' {
return None;
}
let run = trimmed.chars().take_while(|&c| c == first).count();
(run >= 3).then_some((first, run))
}
fn map_outside_inline_code(line: &str, f: &dyn Fn(&str) -> String) -> String {
let bytes = line.as_bytes();
let mut out = String::with_capacity(line.len());
let mut i = 0;
let mut plain_start = 0;
while i < bytes.len() {
if bytes[i] == b'`' {
let run_start = i;
let mut n = 0;
while i < bytes.len() && bytes[i] == b'`' {
n += 1;
i += 1;
}
if let Some(close_start) = find_backtick_run(bytes, i, n) {
out.push_str(&f(&line[plain_start..run_start]));
let code_end = close_start + n;
out.push_str(&line[run_start..code_end]); i = code_end;
plain_start = code_end;
}
} else {
i += 1;
}
}
out.push_str(&f(&line[plain_start..]));
out
}
fn find_backtick_run(bytes: &[u8], from: usize, n: usize) -> Option<usize> {
let mut i = from;
while i < bytes.len() {
if bytes[i] == b'`' {
let start = i;
let mut run = 0;
while i < bytes.len() && bytes[i] == b'`' {
run += 1;
i += 1;
}
if run == n {
return Some(start);
}
} else {
i += 1;
}
}
None
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rewrites_bare_basename() {
let out = rewrite_wikilinks("see [[old]] for more", "old.md", "new.md");
assert_eq!(out, "see [[new]] for more");
}
#[test]
fn rewrites_path_prefix() {
let out = rewrite_wikilinks("see [[wiki/old]] for more", "wiki/old.md", "wiki/new.md");
assert_eq!(out, "see [[wiki/new]] for more");
}
#[test]
fn rewrites_alias_form() {
let out = rewrite_wikilinks("see [[old|My Alias]]", "old.md", "new.md");
assert_eq!(out, "see [[new|My Alias]]");
}
#[test]
fn rewrites_section_anchor() {
let out = rewrite_wikilinks("see [[old#Header]]", "old.md", "new.md");
assert_eq!(out, "see [[new#Header]]");
}
#[test]
fn rewrites_block_anchor() {
let out = rewrite_wikilinks("see [[old#^block-id]]", "old.md", "new.md");
assert_eq!(out, "see [[new#^block-id]]");
}
#[test]
fn rewrites_embed_form() {
let out = rewrite_wikilinks("![[old]]", "old.md", "new.md");
assert_eq!(out, "![[new]]");
}
#[test]
fn rewrites_embed_with_section() {
let out = rewrite_wikilinks("![[old#Header]]", "old.md", "new.md");
assert_eq!(out, "![[new#Header]]");
}
#[test]
fn does_not_rewrite_partial_basename_match() {
let out = rewrite_wikilinks("see [[older]] and [[old]]", "old.md", "new.md");
assert_eq!(out, "see [[older]] and [[new]]");
}
#[test]
fn does_not_rewrite_suffix_match() {
let out = rewrite_wikilinks("see [[my-old]] vs [[old]]", "old.md", "new.md");
assert_eq!(out, "see [[my-old]] vs [[new]]");
}
#[test]
fn rewrites_multiple_matches_in_one_file() {
let out = rewrite_wikilinks(
"first [[old]], second [[old|alias]], third ![[old#Sec]]",
"old.md",
"new.md",
);
assert_eq!(
out,
"first [[new]], second [[new|alias]], third ![[new#Sec]]"
);
}
#[test]
fn passthrough_when_no_matches() {
let original = "no wikilinks here, just text";
let out = rewrite_wikilinks(original, "old.md", "new.md");
assert_eq!(out, original);
}
#[test]
fn rewrites_path_form_when_source_uses_path_target_uses_basename() {
let out = rewrite_wikilinks("use [[old]] here", "wiki/old.md", "concepts/new.md");
assert_eq!(out, "use [[new]] here");
}
#[test]
fn rewrites_path_form_keeps_path_target_when_directory_changes() {
let out = rewrite_wikilinks("use [[wiki/old]] here", "wiki/old.md", "concepts/new.md");
assert_eq!(out, "use [[concepts/new]] here");
}
#[test]
fn rewrites_uppercase_md_extension() {
let out = rewrite_wikilinks("see [[Foo]] here", "Foo.MD", "Bar.md");
assert_eq!(out, "see [[Bar]] here");
}
#[test]
fn rewrites_regex_special_chars_in_basename() {
let out = rewrite_wikilinks("see [[c++]]", "c++.md", "rust.md");
assert_eq!(out, "see [[rust]]");
}
#[test]
fn does_not_rewrite_inside_fenced_code() {
let input = "before [[old]]\n```\nexample [[old]] in code\n```\nafter [[old]]";
let out = rewrite_wikilinks(input, "old.md", "new.md");
assert_eq!(
out,
"before [[new]]\n```\nexample [[old]] in code\n```\nafter [[new]]"
);
}
#[test]
fn does_not_rewrite_inside_inline_code() {
let out = rewrite_wikilinks("real [[old]] but `[[old]]` literal", "old.md", "new.md");
assert_eq!(out, "real [[new]] but `[[old]]` literal");
}
#[test]
fn does_not_rewrite_tilde_fenced_code() {
let input = "~~~\n[[old]]\n~~~\nplain [[old]]";
let out = rewrite_wikilinks(input, "old.md", "new.md");
assert_eq!(out, "~~~\n[[old]]\n~~~\nplain [[new]]");
}
#[test]
fn wrap_stale_skips_fenced_code() {
let input = "see [[old]]\n```\ncode [[old]]\n```";
let out = wrap_wikilinks_as_stale(input, "old.md");
assert_eq!(out, "see ~~[[old]]~~\n```\ncode [[old]]\n```");
}
#[test]
fn wrap_stale_bare_basename() {
let out = wrap_wikilinks_as_stale("see [[old]] here", "old.md");
assert_eq!(out, "see ~~[[old]]~~ here");
}
#[test]
fn wrap_stale_with_alias() {
let out = wrap_wikilinks_as_stale("see [[old|My Alias]] here", "old.md");
assert_eq!(out, "see ~~[[old|My Alias]]~~ here");
}
#[test]
fn wrap_stale_with_section() {
let out = wrap_wikilinks_as_stale("see [[old#Header]] here", "old.md");
assert_eq!(out, "see ~~[[old#Header]]~~ here");
}
#[test]
fn wrap_stale_embed() {
let out = wrap_wikilinks_as_stale("![[old]]", "old.md");
assert_eq!(out, "~~![[old]]~~");
}
#[test]
fn wrap_stale_path_prefix() {
let out = wrap_wikilinks_as_stale("see [[wiki/old]]", "wiki/old.md");
assert!(out.contains("~~[[wiki/old]]~~"));
}
#[test]
fn wrap_stale_idempotent() {
let already = "see ~~[[old]]~~ here";
let out = wrap_wikilinks_as_stale(already, "old.md");
assert_eq!(out, already, "already-wrapped links must not double-wrap");
}
#[test]
fn wrap_stale_skips_partial_basename_match() {
let out = wrap_wikilinks_as_stale("see [[older]] and [[old]]", "old.md");
assert_eq!(out, "see [[older]] and ~~[[old]]~~");
}
#[test]
fn wrap_stale_multiple_links() {
let out = wrap_wikilinks_as_stale(
"first [[old]] then [[old|alias]] then ![[old#Sec]]",
"old.md",
);
assert_eq!(
out,
"first ~~[[old]]~~ then ~~[[old|alias]]~~ then ~~![[old#Sec]]~~"
);
}
#[test]
fn wrap_stale_no_matches_passes_through() {
let original = "no wikilinks here";
let out = wrap_wikilinks_as_stale(original, "old.md");
assert_eq!(out, original);
}
}