use crate::doc::{Doc, DocMeta};
use crate::html;
use crate::permalink;
use comrak::nodes::{AstNode, NodeValue};
use std::collections::HashMap;
use std::path::{Component, Path, PathBuf};
pub fn build_stem_index(docs: &[DocMeta]) -> HashMap<String, Vec<DocMeta>> {
let mut index: HashMap<String, Vec<DocMeta>> = HashMap::new();
for doc in docs {
let stem = doc
.id_path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("");
index
.entry(slug::slugify(stem))
.or_default()
.push(doc.clone());
}
index
}
pub fn resolve_in_ast<'a>(
root: &'a AstNode<'a>,
source: &Doc,
stem_index: &HashMap<String, Vec<DocMeta>>,
) -> Vec<PathBuf> {
let mut links: Vec<PathBuf> = Vec::new();
let wikilinks: Vec<&'a AstNode<'a>> = root
.descendants()
.filter(|node| matches!(node.data.borrow().value, NodeValue::WikiLink(_)))
.collect();
for node in wikilinks {
let target = match &node.data.borrow().value {
NodeValue::WikiLink(w) => w.url.clone(),
_ => continue,
};
let display = node_text(node);
let replacement = match resolve(&target, &source.id_path, stem_index) {
Some(doc) => {
let url = permalink::to_url(&doc.output_path);
if !links.contains(&doc.id_path) {
links.push(doc.id_path.clone());
}
render_link(&url, &display)
}
None => render_nolink(&display),
};
for child in node.children().collect::<Vec<_>>() {
child.detach();
}
node.data.borrow_mut().value = NodeValue::HtmlInline(replacement);
}
links
}
fn node_text<'a>(node: &'a AstNode<'a>) -> String {
let mut s = String::new();
for child in node.descendants().skip(1) {
if let NodeValue::Text(ref t) = child.data.borrow().value {
s.push_str(t);
}
}
s
}
fn render_link(url: &str, display: &str) -> String {
format!(
r#"<a class="wikilink" href="{}">{}</a>"#,
html::escape(url),
html::escape(display)
)
}
fn render_nolink(display: &str) -> String {
format!(r#"<span class="nolink">{}</span>"#, html::escape(display))
}
fn split_prefix_stem(target: &str) -> (Option<&str>, &str) {
match target.rfind('/') {
Some(idx) => (Some(&target[..idx]), &target[idx + 1..]),
None => (None, target),
}
}
fn dir_distance(a: &Path, b: &Path) -> usize {
let ac: Vec<Component> = a.components().collect();
let bc: Vec<Component> = b.components().collect();
let common = ac.iter().zip(bc.iter()).take_while(|(x, y)| x == y).count();
(ac.len() - common) + (bc.len() - common)
}
fn prefix_matches(parent: &Path, prefix: &str) -> bool {
let prefix_slugs: Vec<String> = prefix
.split('/')
.filter(|s| !s.is_empty())
.map(slug::slugify)
.collect();
let parent_slugs: Vec<String> = parent
.components()
.filter_map(|c| match c {
Component::Normal(s) => s.to_str().map(slug::slugify),
_ => None,
})
.collect();
parent_slugs == prefix_slugs
}
fn resolve<'a>(
target: &str,
source_id_path: &Path,
stem_index: &'a HashMap<String, Vec<DocMeta>>,
) -> Option<&'a DocMeta> {
let (prefix, stem) = split_prefix_stem(target);
let stem_slug = slug::slugify(stem);
if stem_slug.is_empty() {
return None;
}
let empty = Path::new("");
let source_dir = source_id_path.parent().unwrap_or(empty);
let candidates = stem_index.get(&stem_slug)?;
let mut best: Option<(&DocMeta, usize)> = None;
for doc in candidates {
let cand_dir = doc.id_path.parent().unwrap_or(empty);
if let Some(p) = prefix
&& !prefix_matches(cand_dir, p)
{
continue;
}
let dist = dir_distance(source_dir, cand_dir);
best = match best {
None => Some((doc, dist)),
Some((curr, curr_dist)) => {
let better = dist < curr_dist || (dist == curr_dist && doc.id_path < curr.id_path);
if better {
Some((doc, dist))
} else {
Some((curr, curr_dist))
}
}
};
}
best.map(|(doc, _)| doc)
}
#[cfg(test)]
mod tests {
use super::*;
fn source_doc(id_path: &str) -> Doc {
Doc {
id_path: PathBuf::from(id_path),
output_path: PathBuf::from(id_path).with_extension("html"),
..Default::default()
}
}
fn doc_at(id_path: &str) -> DocMeta {
DocMeta::from(&source_doc(id_path))
}
fn doc_with_permalink(id_path: &str, output_path: &str) -> DocMeta {
let d = Doc {
id_path: PathBuf::from(id_path),
output_path: PathBuf::from(output_path),
..Default::default()
};
DocMeta::from(&d)
}
fn resolve_id(target: &str, source_id: &Path, docs: &[DocMeta]) -> Option<PathBuf> {
let idx = build_stem_index(docs);
resolve(target, source_id, &idx).map(|d| d.id_path.clone())
}
fn render_md(body: &str, source: &Doc, docs: &[DocMeta]) -> (String, Vec<PathBuf>) {
let arena = comrak::Arena::new();
let mut options = comrak::Options::default();
options.render.r#unsafe = true;
options.extension.wikilinks_title_after_pipe = true;
let root = comrak::parse_document(&arena, body, &options);
let stem_index = build_stem_index(docs);
let links = resolve_in_ast(root, source, &stem_index);
let mut out = String::new();
comrak::format_html(root, &options, &mut out).unwrap();
(out, links)
}
#[test]
fn resolves_same_dir() {
let source = source_doc("blog/a.md");
let docs = vec![DocMeta::from(&source), doc_at("blog/b.md")];
let (out, links) = render_md("see [[b]]", &source, &docs);
assert!(out.contains(r#"<a class="wikilink" href="/blog/b.html">b</a>"#));
assert_eq!(links, vec![PathBuf::from("blog/b.md")]);
}
#[test]
fn walks_to_parent() {
let source = source_doc("blog/2025/deep.md");
let docs = vec![DocMeta::from(&source), doc_at("hello.md")];
let (out, links) = render_md("see [[hello]]", &source, &docs);
assert!(out.contains(r#"href="/hello.html""#));
assert_eq!(links, vec![PathBuf::from("hello.md")]);
}
#[test]
fn walks_multiple_levels() {
let source = source_doc("a/b/c/d.md");
let docs = vec![DocMeta::from(&source), doc_at("top.md")];
let (out, _) = render_md("[[top]]", &source, &docs);
assert!(out.contains(r#"href="/top.html""#));
}
#[test]
fn uses_display_text() {
let source = source_doc("a.md");
let docs = vec![DocMeta::from(&source), doc_at("b.md")];
let (out, _) = render_md("[[b|click here]]", &source, &docs);
assert!(out.contains(r#"<a class="wikilink" href="/b.html">click here</a>"#));
}
#[test]
fn unresolved_emits_nolink_span() {
let source = source_doc("a.md");
let docs = vec![DocMeta::from(&source)];
let (out, links) = render_md("[[Missing]]", &source, &docs);
assert!(out.contains(r#"<span class="nolink">Missing</span>"#));
assert!(links.is_empty());
}
#[test]
fn records_links() {
let source = source_doc("a.md");
let docs = vec![DocMeta::from(&source), doc_at("b.md"), doc_at("c.md")];
let (_, links) = render_md("[[b]] and [[c]]", &source, &docs);
assert_eq!(links, vec![PathBuf::from("b.md"), PathBuf::from("c.md")]);
}
#[test]
fn dedups_links() {
let source = source_doc("a.md");
let docs = vec![DocMeta::from(&source), doc_at("b.md")];
let (_, links) = render_md("[[b]] [[b]] [[b|again]]", &source, &docs);
assert_eq!(links, vec![PathBuf::from("b.md")]);
}
#[test]
fn display_does_not_emit_raw_html() {
let source = source_doc("a.md");
let docs = vec![DocMeta::from(&source), doc_at("b.md")];
let (out, _) = render_md("[[b|<script>alert(1)</script>]]", &source, &docs);
assert!(!out.contains("<script>"));
}
#[test]
fn wikilink_inside_code_fence_is_not_linked() {
let source = source_doc("a.md");
let docs = vec![DocMeta::from(&source), doc_at("b.md")];
let (out, links) = render_md("```\n[[b]]\n```", &source, &docs);
assert!(!out.contains("wikilink"));
assert!(out.contains("[[b]]"));
assert!(links.is_empty());
}
#[test]
fn wikilink_inside_inline_code_is_not_linked() {
let source = source_doc("a.md");
let docs = vec![DocMeta::from(&source), doc_at("b.md")];
let (out, links) = render_md("use `[[b]]` syntax", &source, &docs);
assert!(!out.contains("wikilink"));
assert!(links.is_empty());
}
#[test]
fn uses_to_url_for_index_html_dirs() {
let source = source_doc("a.md");
let docs = vec![
DocMeta::from(&source),
doc_with_permalink("b.md", "blog/b/index.html"),
];
let (out, _) = render_md("[[b]]", &source, &docs);
assert!(out.contains(r#"href="/blog/b/""#));
}
#[test]
fn resolve_first_match_wins_closer_dir() {
let source = doc_at("blog/post.md");
let docs = vec![
doc_at("hello.md"), doc_at("blog/hello.md"), ];
let hit = resolve_id("hello", &source.id_path, &docs).unwrap();
assert_eq!(hit, PathBuf::from("blog/hello.md"));
}
#[test]
fn resolve_slugifies_target() {
let source = doc_at("a.md");
let docs = vec![doc_at("hello-world.md")];
let hit = resolve_id("Hello World", &source.id_path, &docs).unwrap();
assert_eq!(hit, PathBuf::from("hello-world.md"));
}
#[test]
fn resolve_returns_none_when_no_match() {
let source = doc_at("a.md");
let docs = vec![doc_at("other.md")];
assert!(resolve_id("missing", &source.id_path, &docs).is_none());
}
#[test]
fn resolve_finds_cross_subtree_neighbor() {
let source = doc_at("blog/2025/post.md");
let docs = vec![doc_at("reference/glossary.md")];
let hit = resolve_id("Glossary", &source.id_path, &docs).unwrap();
assert_eq!(hit, PathBuf::from("reference/glossary.md"));
}
#[test]
fn resolve_picks_nearest_when_ambiguous() {
let source = doc_at("blog/2025/post.md");
let docs = vec![doc_at("reference/glossary.md"), doc_at("blog/glossary.md")];
let hit = resolve_id("Glossary", &source.id_path, &docs).unwrap();
assert_eq!(hit, PathBuf::from("blog/glossary.md"));
}
#[test]
fn resolve_lexicographic_tiebreaker_on_equal_distance() {
let source = doc_at("root.md");
let docs = vec![doc_at("zeta/glossary.md"), doc_at("alpha/glossary.md")];
let hit = resolve_id("Glossary", &source.id_path, &docs).unwrap();
assert_eq!(hit, PathBuf::from("alpha/glossary.md"));
}
#[test]
fn resolve_explicit_prefix_picks_prefix_match_over_closer() {
let source = doc_at("blog/2025/post.md");
let docs = vec![doc_at("blog/glossary.md"), doc_at("reference/glossary.md")];
let hit = resolve_id("reference/Glossary", &source.id_path, &docs).unwrap();
assert_eq!(hit, PathBuf::from("reference/glossary.md"));
}
#[test]
fn resolve_explicit_prefix_no_fallback_to_bare_stem() {
let source = doc_at("a.md");
let docs = vec![doc_at("hello.md")];
assert!(resolve_id("missing/Hello", &source.id_path, &docs).is_none());
}
#[test]
fn resolve_slugifies_prefix_components() {
let source = doc_at("a.md");
let docs = vec![doc_at("blog-posts/hello.md")];
let hit = resolve_id("Blog Posts/Hello", &source.id_path, &docs).unwrap();
assert_eq!(hit, PathBuf::from("blog-posts/hello.md"));
}
#[test]
fn resolve_root_anchored_prefix() {
let source = doc_at("blog/post.md");
let docs = vec![doc_at("blog/hello.md"), doc_at("hello.md")];
let hit = resolve_id("/Hello", &source.id_path, &docs).unwrap();
assert_eq!(hit, PathBuf::from("hello.md"));
}
#[test]
fn dir_distance_basics() {
assert_eq!(
dir_distance(Path::new("blog/2025"), Path::new("blog/2025")),
0
);
assert_eq!(dir_distance(Path::new("blog/2025"), Path::new("blog")), 1);
assert_eq!(dir_distance(Path::new("blog/2025"), Path::new("")), 2);
assert_eq!(
dir_distance(Path::new("blog/2025"), Path::new("reference")),
3
);
assert_eq!(dir_distance(Path::new("a/b/c"), Path::new("a/x/y")), 4);
}
}