use aho_corasick::{AhoCorasickBuilder, MatchKind};
use regex::{Captures, Regex};
use std::collections::{HashMap, HashSet};
use std::path::{Path, PathBuf};
use walkdir::WalkDir;
use crate::link_grep::{
compute_patterns_for_folder, compute_relative_path, compute_url_path, get_folder_url_path,
};
use crate::link_index::{is_internal_link, normalize_url_path, resolve_relative_url};
use crate::relationships::normalize_name;
use crate::repo::{build_markdown_url_path, is_markdown_extension, should_ignore};
use crate::wikilink_index::WikilinkIndex;
fn collect_move_bases(source_folder: &str, old_url: &str) -> Vec<String> {
let old_abs = format!("/{}", old_url.trim_matches('/'));
let old_rel = compute_relative_path(source_folder, old_url);
let mut bases = vec![old_abs];
if old_rel != "." {
bases.push(old_rel.clone());
if !old_rel.starts_with("../") && !old_rel.starts_with("./") {
bases.push(format!("./{}", old_rel));
}
}
let mut seen = HashSet::new();
bases.retain(|b| !b.is_empty() && seen.insert(b.clone()));
bases.sort_by_key(|b| std::cmp::Reverse(b.len()));
bases
}
fn reclassify_target(matched: &str, new_abs: &str, new_rel: &str) -> String {
if matched.starts_with('/') {
new_abs.to_string()
} else if matched.starts_with("./") {
format!("./{}", new_rel)
} else {
new_rel.to_string()
}
}
pub fn rewrite_links_for_move(
old_url: &str,
new_url: &str,
source_folder: &str,
content: &str,
) -> String {
if old_url.trim_matches('/').is_empty() {
return content.to_string();
}
let bases = collect_move_bases(source_folder, old_url);
if bases.is_empty() {
return content.to_string();
}
let new_abs = format!("/{}", new_url.trim_matches('/'));
let new_rel = compute_relative_path(source_folder, new_url);
let reclass = |t: &str| reclassify_target(t, &new_abs, &new_rel);
let alt = bases
.iter()
.map(|b| regex::escape(b))
.collect::<Vec<_>>()
.join("|");
let mut out = content.to_string();
if let Ok(re) = Regex::new(&format!(
r"(?P<pre>\[[^\]]*\]\()(?P<t>{alt})(?P<ext>(?:\.md)?)(?P<slash>/?)(?P<suf>(?:[#?][^)]*)?)(?P<post>\))"
)) {
out = re
.replace_all(&out, |c: &Captures| {
format!(
"{}{}{}{}{}{}",
&c["pre"],
reclass(&c["t"]),
&c["ext"],
&c["slash"],
&c["suf"],
&c["post"]
)
})
.into_owned();
}
if let Ok(re) = Regex::new(&format!(
r"(?m)(?P<pre>^[ \t]*\[[^\]]+\]:[ \t]*)(?P<t>{alt})(?P<ext>(?:\.md)?)(?P<slash>/?)(?P<suf>(?:[#?]\S*)?)(?P<b>[ \t]|$)"
)) {
out = re
.replace_all(&out, |c: &Captures| {
format!(
"{}{}{}{}{}{}",
&c["pre"],
reclass(&c["t"]),
&c["ext"],
&c["slash"],
&c["suf"],
&c["b"]
)
})
.into_owned();
}
let wiki_bases: Vec<&String> = bases.iter().filter(|b| b.contains('/')).collect();
if !wiki_bases.is_empty() {
let walt = wiki_bases
.iter()
.map(|b| regex::escape(b))
.collect::<Vec<_>>()
.join("|");
if let Ok(re) = Regex::new(&format!(
r"(?i)(?P<pre>\[\[)(?P<t>{walt})(?P<ext>(?:\.md)?)(?P<slash>/?)(?P<anchor>(?:#[^\]|]*)?)(?P<disp>(?:\|[^\]]*)?)(?P<post>\]\])"
)) {
out = re
.replace_all(&out, |c: &Captures| {
format!(
"{}{}{}{}{}{}{}",
&c["pre"],
reclass(&c["t"]),
&c["ext"],
&c["slash"],
&c["anchor"],
&c["disp"],
&c["post"]
)
})
.into_owned();
}
}
out
}
fn folder_of(url: &str, is_index: bool) -> String {
if is_index {
let trimmed = url.trim_end_matches('/');
if trimmed.is_empty() {
"/".to_string()
} else {
format!("{}/", trimmed)
}
} else {
get_folder_url_path(url)
}
}
fn markdown_ext_suffix(path: &str, exts: &[String]) -> Option<String> {
let trimmed = path.trim_end_matches('/');
let last = trimmed.rsplit('/').next().unwrap_or(trimmed);
let (_, ext) = last.rsplit_once('.')?;
if is_markdown_extension(&ext.to_lowercase(), exts) {
Some(format!(".{ext}"))
} else {
None
}
}
fn relocate_link_target(
target: &str,
old_url: &str,
old_is_index: bool,
new_folder: &str,
new_url: &str,
exts: &[String],
) -> Option<String> {
if target.is_empty()
|| target.starts_with('/')
|| target.starts_with('#')
|| !is_internal_link(target)
{
return None;
}
let (path_part, suffix) = match target.find(['#', '?']) {
Some(i) => (&target[..i], &target[i..]),
None => (target, ""),
};
if path_part.is_empty() {
return None;
}
let had_dot = path_part.starts_with("./");
let trailing_slash = path_part.ends_with('/');
let md_ext = markdown_ext_suffix(path_part, exts);
let rel_for_resolve = match &md_ext {
Some(ext) => {
let base = path_part.strip_suffix(ext.as_str()).unwrap_or(path_part);
format!("{}/", base)
}
None => path_part.to_string(),
};
let mut abs = resolve_relative_url(old_url, &rel_for_resolve, old_is_index);
if normalize_url_path(&abs) == normalize_url_path(old_url) {
abs = normalize_url_path(new_url);
}
let new_core = compute_relative_path(new_folder, &abs);
if new_core == "." {
return None;
}
let mut out = String::new();
if had_dot && !new_core.starts_with("..") {
out.push_str("./");
}
out.push_str(&new_core);
if let Some(ext) = &md_ext {
out.push_str(ext);
} else if trailing_slash {
out.push('/');
}
out.push_str(suffix);
Some(out)
}
pub fn rewrite_moved_file_outbound_links(
old_url: &str,
old_is_index: bool,
new_url: &str,
new_is_index: bool,
exts: &[String],
content: &str,
) -> String {
let new_folder = folder_of(new_url, new_is_index);
let mut out = content.to_string();
if let Ok(re) =
Regex::new(r#"(?P<pre>\[[^\]]*\]\()(?P<url>[^)\s]+)(?P<rest>(?:\s+[^)]*)?)(?P<post>\))"#)
{
out = re
.replace_all(&out, |c: &Captures| {
match relocate_link_target(
&c["url"],
old_url,
old_is_index,
&new_folder,
new_url,
exts,
) {
Some(n) => format!("{}{}{}{}", &c["pre"], n, &c["rest"], &c["post"]),
None => c[0].to_string(),
}
})
.into_owned();
}
if let Ok(re) = Regex::new(r"(?m)(?P<pre>^[ \t]*\[[^\]]+\]:[ \t]*)(?P<url>\S+)(?P<post>.*)$") {
out = re
.replace_all(&out, |c: &Captures| {
match relocate_link_target(
&c["url"],
old_url,
old_is_index,
&new_folder,
new_url,
exts,
) {
Some(n) => format!("{}{}{}", &c["pre"], n, &c["post"]),
None => c[0].to_string(),
}
})
.into_owned();
}
if let Ok(re) = Regex::new(
r"(?P<pre>\[\[)(?P<url>[^\]|#]+)(?P<anchor>(?:#[^\]|]*)?)(?P<disp>(?:\|[^\]]*)?)(?P<post>\]\])",
) {
out = re
.replace_all(&out, |c: &Captures| {
let url = &c["url"];
if !url.contains('/') {
return c[0].to_string();
}
match relocate_link_target(url, old_url, old_is_index, &new_folder, new_url, exts) {
Some(n) => format!(
"{}{}{}{}{}",
&c["pre"], n, &c["anchor"], &c["disp"], &c["post"]
),
None => c[0].to_string(),
}
})
.into_owned();
}
out
}
pub fn rewrite_bare_wikilink(content: &str, old_name: &str, new_stem: &str) -> String {
if old_name.contains('/') || old_name.trim().is_empty() {
return content.to_string();
}
let esc = regex::escape(old_name.trim());
let re = match Regex::new(&format!(
r"(?i)(?P<pre>\[\[)\s*{esc}\s*(?P<anchor>(?:#[^\]|]*)?)(?P<disp>(?:\|[^\]]*)?)(?P<post>\]\])"
)) {
Ok(re) => re,
Err(_) => return content.to_string(),
};
re.replace_all(content, |c: &Captures| {
format!(
"{}{}{}{}{}",
&c["pre"], new_stem, &c["anchor"], &c["disp"], &c["post"]
)
})
.into_owned()
}
fn atomic_write(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
let parent = path.parent().unwrap_or_else(|| Path::new("."));
let file_name = path
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("file.md");
let tmp = parent.join(format!(".{file_name}.mbr-tmp"));
std::fs::write(&tmp, bytes)?;
match std::fs::rename(&tmp, path) {
Ok(()) => Ok(()),
Err(e) => {
let _ = std::fs::remove_file(&tmp);
Err(e)
}
}
}
fn markdown_files<'a>(
root_dir: &'a Path,
markdown_extensions: &'a [String],
ignore_dirs: &'a [String],
ignore_globs: &'a [String],
skip_abs: &'a HashSet<PathBuf>,
) -> impl Iterator<Item = (PathBuf, String)> + 'a {
WalkDir::new(root_dir)
.follow_links(true)
.into_iter()
.filter_entry(move |e| {
let path = e.path();
if path.is_dir()
&& let Some(name) = path.file_name().and_then(|n| n.to_str())
{
return !ignore_dirs.contains(&name.to_string());
}
true
})
.filter_map(|e| e.ok())
.filter_map(move |entry| {
let path = entry.path();
if !path.is_file() || skip_abs.contains(path) {
return None;
}
let ext = path
.extension()
.and_then(|e| e.to_str())
.unwrap_or("")
.to_lowercase();
if !markdown_extensions.contains(&ext) {
return None;
}
if should_ignore(path, ignore_dirs, ignore_globs) {
return None;
}
let url = compute_url_path(path, root_dir, markdown_extensions);
Some((path.to_path_buf(), url))
})
}
pub fn rewrite_inbound_links_for_move(
old_url: &str,
new_url: &str,
root_dir: &Path,
markdown_extensions: &[String],
ignore_dirs: &[String],
ignore_globs: &[String],
skip_abs: &HashSet<PathBuf>,
) -> std::io::Result<Vec<PathBuf>> {
if old_url.trim_matches('/').is_empty() {
return Ok(Vec::new());
}
let new_norm = new_url.trim_end_matches('/');
let mut folder_files: HashMap<String, Vec<PathBuf>> = HashMap::new();
for (path, source_url) in markdown_files(
root_dir,
markdown_extensions,
ignore_dirs,
ignore_globs,
skip_abs,
) {
if source_url.trim_end_matches('/') == new_norm {
continue;
}
let folder = get_folder_url_path(&source_url);
folder_files.entry(folder).or_default().push(path);
}
let mut changed = Vec::new();
for (folder, files) in &folder_files {
let patterns = compute_patterns_for_folder(folder, old_url);
if patterns.is_empty() {
continue;
}
let Ok(ac) = AhoCorasickBuilder::new()
.ascii_case_insensitive(true)
.match_kind(MatchKind::LeftmostFirst)
.build(&patterns)
else {
continue;
};
for path in files {
let Ok(content) = std::fs::read_to_string(path) else {
continue;
};
if !ac.is_match(&content) {
continue;
}
let rewritten = rewrite_links_for_move(old_url, new_url, folder, &content);
if rewritten != content {
atomic_write(path, rewritten.as_bytes())?;
changed.push(path.clone());
}
}
}
Ok(changed)
}
#[allow(clippy::too_many_arguments)]
pub fn rewrite_bare_wikilinks_for_rename(
delta: &[(String, String)],
old_url: &str,
root_dir: &Path,
markdown_extensions: &[String],
ignore_dirs: &[String],
ignore_globs: &[String],
index_file: &str,
wikilink_index: &WikilinkIndex,
skip_abs: &HashSet<PathBuf>,
) -> std::io::Result<Vec<PathBuf>> {
if delta.is_empty() {
return Ok(Vec::new());
}
let old_norm = normalize_url_path(old_url);
let mut changed = Vec::new();
for (path, _url) in markdown_files(
root_dir,
markdown_extensions,
ignore_dirs,
ignore_globs,
skip_abs,
) {
let Ok(content) = std::fs::read_to_string(&path) else {
continue;
};
let file_url = build_markdown_url_path(&path, root_dir, index_file);
let file_is_index = path
.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n == index_file);
let mut new_content = content.clone();
let mut file_changed = false;
for (name, new_stem) in delta {
if normalize_name(name) == normalize_name(new_stem) {
continue;
}
match wikilink_index.resolve_wikilink(name, &file_url, file_is_index) {
Some(u) if normalize_url_path(&u) == old_norm => {}
_ => continue,
}
let updated = rewrite_bare_wikilink(&new_content, name, new_stem);
if updated != new_content {
new_content = updated;
file_changed = true;
}
}
if file_changed {
atomic_write(&path, new_content.as_bytes())?;
changed.push(path);
}
}
Ok(changed)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn absolute_inline_link_rewritten() {
let content = "See [Guide](/docs/guide/).";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/notes/", content);
assert_eq!(out, "See [Guide](/docs/manual/).");
}
#[test]
fn relative_inline_link_rewritten() {
let content = "See [Guide](guide/).";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/docs/", content);
assert_eq!(out, "See [Guide](manual/).");
}
#[test]
fn dot_slash_relative_link_rewritten() {
let content = "See [Guide](./guide/).";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/docs/", content);
assert_eq!(out, "See [Guide](./manual/).");
}
#[test]
fn parent_traversal_relative_link_rewritten() {
let content = "See [Guide](../guide/).";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/docs/sub/", content);
assert_eq!(out, "See [Guide](../manual/).");
}
#[test]
fn md_extension_preserved() {
let content = "See [Guide](guide.md).";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/docs/", content);
assert_eq!(out, "See [Guide](manual.md).");
}
#[test]
fn trailing_slash_and_anchor_preserved() {
let content = "See [Guide](/docs/guide/#intro).";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/notes/", content);
assert_eq!(out, "See [Guide](/docs/manual/#intro).");
}
#[test]
fn query_string_preserved() {
let content = "See [Guide](/docs/guide?tab=1).";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/notes/", content);
assert_eq!(out, "See [Guide](/docs/manual?tab=1).");
}
#[test]
fn md_extension_with_anchor_preserved() {
let content = "See [Guide](guide.md#intro).";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/docs/", content);
assert_eq!(out, "See [Guide](manual.md#intro).");
}
#[test]
fn prefix_collision_not_rewritten() {
let content = "A [x](/docs/guide-2/) and [y](/docs/guide/).";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/notes/", content);
assert_eq!(out, "A [x](/docs/guide-2/) and [y](/docs/manual/).");
}
#[test]
fn prefix_collision_relative_not_rewritten() {
let content = "A [x](guide-2/) and [y](guide/).";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/docs/", content);
assert_eq!(out, "A [x](guide-2/) and [y](manual/).");
}
#[test]
fn reference_definition_rewritten_use_untouched() {
let content = "Intro [see][g] more.\n\n[g]: /docs/guide/\n";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/notes/", content);
assert_eq!(out, "Intro [see][g] more.\n\n[g]: /docs/manual/\n");
}
#[test]
fn reference_definition_prefix_collision_not_rewritten() {
let content = "[g]: /docs/guide-2/\n";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/notes/", content);
assert_eq!(out, "[g]: /docs/guide-2/\n");
}
#[test]
fn reference_definition_with_title_rewritten() {
let content = "[g]: /docs/guide \"The Guide\"\n";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/notes/", content);
assert_eq!(out, "[g]: /docs/manual \"The Guide\"\n");
}
#[test]
fn path_wiki_with_display_rewritten() {
let content = "See [[docs/guide|The Guide]].";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/", content);
assert_eq!(out, "See [[docs/manual|The Guide]].");
}
#[test]
fn path_wiki_with_anchor_rewritten() {
let content = "See [[docs/guide#intro]].";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/", content);
assert_eq!(out, "See [[docs/manual#intro]].");
}
#[test]
fn bare_wiki_left_untouched_by_move() {
let content = "See [[guide]].";
let out = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/docs/", content);
assert_eq!(out, "See [[guide]].");
}
#[test]
fn rewrite_is_idempotent() {
let content = "See [Guide](/docs/guide/) and [rel](guide/) and [[docs/guide]].";
let once = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/docs/", content);
let twice = rewrite_links_for_move("/docs/guide/", "/docs/manual/", "/docs/", &once);
assert_eq!(once, twice);
}
#[test]
fn moved_file_same_folder_leaves_relative_links() {
let exts = vec!["md".to_string()];
let content = "See [Other](other/) and [abs](/x/).";
let out = rewrite_moved_file_outbound_links(
"/docs/guide/",
false,
"/docs/manual/",
false,
&exts,
content,
);
assert_eq!(out, "See [Other](other/) and [abs](/x/).");
}
#[test]
fn moved_file_self_link_repointed() {
let exts = vec!["md".to_string()];
let content = "Back to [me](guide/).";
let out = rewrite_moved_file_outbound_links(
"/docs/guide/",
false,
"/docs/manual/",
false,
&exts,
content,
);
assert_eq!(out, "Back to [me](manual/).");
}
#[test]
fn moved_file_cross_folder_rebases_relative_links() {
let exts = vec!["md".to_string()];
let content = "See [Other](other/).";
let out = rewrite_moved_file_outbound_links(
"/a/guide/",
false,
"/b/guide/",
false,
&exts,
content,
);
assert_eq!(out, "See [Other](../a/other/).");
}
#[test]
fn moved_file_absolute_and_external_untouched() {
let exts = vec!["md".to_string()];
let content = "A [abs](/x/y/) and [ext](https://example.com/) and [anchor](#top).";
let out = rewrite_moved_file_outbound_links(
"/a/guide/",
false,
"/b/guide/",
false,
&exts,
content,
);
assert_eq!(out, content);
}
#[test]
fn moved_index_file_self_link_repointed() {
let exts = vec!["md".to_string()];
let content = "See [child](child/).";
let out =
rewrite_moved_file_outbound_links("/docs/", true, "/guides/", true, &exts, content);
assert_eq!(out, "See [child](../docs/child/).");
}
#[test]
fn bare_wikilink_rewritten_case_insensitive() {
let out = rewrite_bare_wikilink("See [[Guide]] now.", "guide", "manual");
assert_eq!(out, "See [[manual]] now.");
}
#[test]
fn bare_wikilink_preserves_anchor_and_display() {
let out = rewrite_bare_wikilink("See [[guide#intro|The Guide]].", "guide", "manual");
assert_eq!(out, "See [[manual#intro|The Guide]].");
}
#[test]
fn bare_wikilink_prefix_collision_untouched() {
let out = rewrite_bare_wikilink("See [[guide-2]] and [[guide]].", "guide", "manual");
assert_eq!(out, "See [[guide-2]] and [[manual]].");
}
#[test]
fn bare_wikilink_multiword_name() {
let out = rewrite_bare_wikilink("See [[Patrick Walsh]].", "Patrick Walsh", "pw");
assert_eq!(out, "See [[pw]].");
}
#[test]
fn index_base_computation_nonindex_folder() {
let bases = collect_move_bases("/docs/", "/docs/guide/");
assert!(bases.contains(&"/docs/guide".to_string()));
assert!(bases.contains(&"guide".to_string()));
assert!(bases.contains(&"./guide".to_string()));
}
#[test]
fn index_target_absolute_link_rewritten() {
let content = "Home [docs](/docs/).";
let out = rewrite_links_for_move("/docs/", "/guides/", "/notes/", content);
assert_eq!(out, "Home [docs](/guides/).");
}
}