diff --git a/crates/nuwiki-lsp/src/diagnostics.rs b/crates/nuwiki-lsp/src/diagnostics.rs index adc54b8..2610959 100644 --- a/crates/nuwiki-lsp/src/diagnostics.rs +++ b/crates/nuwiki-lsp/src/diagnostics.rs @@ -166,9 +166,7 @@ pub fn classify_link( match link.target.kind { LinkKind::Wiki => { let path = link.target.path.as_deref()?; - let normalized = index.strip_link_extension(path); - let page = index.page_by_name(normalized); - match page { + match index.page_for_wiki_target(path, source_page, link.target.is_absolute) { None => Some(BrokenLinkKind::MissingPage(path.to_string())), Some(page) => { if let Some(anchor) = &link.target.anchor { @@ -229,8 +227,7 @@ pub fn classify_outgoing( match link.kind { LinkKind::Wiki => { let path = link.target_page.as_deref()?; - let normalized = index.strip_link_extension(path); - match index.page_by_name(normalized) { + match index.page_for_wiki_target(path, source_page, link.is_absolute) { None => Some(BrokenLinkKind::MissingPage(path.to_string())), Some(page) => { if let Some(anchor) = &link.anchor { @@ -262,23 +259,7 @@ pub fn classify_outgoing( } LinkKind::File | LinkKind::Local => { let path = link.target_page.as_deref()?; - // OutgoingLink doesn't carry `is_absolute`; recover from the - // string itself — `//path` and any other root-anchored form - // start with `/`. Anything else is relative to the source URI's - // parent directory. - let candidate = PathBuf::from(path); - let resolved = if candidate.is_absolute() { - candidate - } else if let Ok(base) = source_uri.to_file_path() { - match base.parent() { - Some(dir) => dir.join(&candidate), - None => candidate, - } - } else if let Some(root) = index.root.as_ref() { - root.join(&candidate) - } else { - return None; - }; + let resolved = resolve_file_path(Some(source_uri), index, path, link.is_absolute)?; if resolved.exists() { None } else { diff --git a/crates/nuwiki-lsp/src/index.rs b/crates/nuwiki-lsp/src/index.rs index d5f7f21..1354069 100644 --- a/crates/nuwiki-lsp/src/index.rs +++ b/crates/nuwiki-lsp/src/index.rs @@ -83,6 +83,10 @@ pub struct OutgoingLink { pub anchor: Option, pub kind: LinkKind, pub span: Span, + /// Mirrors `LinkTarget::is_absolute` so `classify_outgoing` and the + /// `links` query can distinguish `[[Page]]` (source-relative) from + /// `[[/Page]]` (root-relative) without re-parsing. + pub is_absolute: bool, } #[derive(Debug, Clone)] @@ -174,9 +178,9 @@ impl WorkspaceIndex { let ext = self.file_extension.as_deref(); for link in &page.outgoing { if let Some(tgt) = &link.target_page { - let norm = strip_wiki_extension(tgt, ext); + let key = canonical_link_name(tgt, &name, link.is_absolute, ext); self.backlinks - .entry(norm.to_string()) + .entry(key) .or_default() .push(Backlink { source_uri: uri.clone(), @@ -227,13 +231,13 @@ impl WorkspaceIndex { /// Resolve a `LinkTarget` to a page URI in this workspace. /// `source_page` is the name of the page the link originates from — - /// needed for `AnchorOnly` resolution. + /// needed for `AnchorOnly` resolution and source-relative `Wiki` links. pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> { match target.kind { - LinkKind::Wiki => target.path.as_deref().and_then(|p| { - let normalized = self.strip_link_extension(p); - self.pages_by_name.get(normalized) - }), + LinkKind::Wiki => target + .path + .as_deref() + .and_then(|p| self.resolve_wiki_path(p, source_page, target.is_absolute)), LinkKind::AnchorOnly => self.pages_by_name.get(source_page), // Interwiki / Diary / File / Local / Raw aren't resolved // against the workspace index here. The handlers fall back to @@ -242,6 +246,32 @@ impl WorkspaceIndex { } } + /// Look up a wiki link target by name. Tries source-relative first + /// (vimwiki's default — `[[Page]]` in `posts/index.wiki` opens + /// `posts/Page.wiki`) then falls back to root-relative. Skips the + /// source-relative attempt for explicitly absolute (`[[/Page]]`) and + /// for source pages already at the wiki root (`source_page` with no + /// `/`). Strips the configured `.wiki` extension and collapses any + /// `.`/`..` segments before lookup. + pub fn resolve_wiki_path( + &self, + path: &str, + source_page: &str, + is_absolute: bool, + ) -> Option<&Url> { + let normalized = self.strip_link_extension(path); + if !is_absolute { + if let Some(slash) = source_page.rfind('/') { + let candidate = collapse_dots(&format!("{}/{}", &source_page[..slash], normalized)); + if let Some(uri) = self.pages_by_name.get(&candidate) { + return Some(uri); + } + } + } + let root_candidate = collapse_dots(normalized); + self.pages_by_name.get(&root_candidate) + } + pub fn page(&self, uri: &Url) -> Option<&IndexedPage> { self.pages_by_uri.get(uri) } @@ -252,6 +282,19 @@ impl WorkspaceIndex { .and_then(|u| self.pages_by_uri.get(u)) } + /// Resolve a wiki link target to the page it points at. Source-relative + /// first, then root-relative — mirrors [`Self::resolve_wiki_path`] but + /// returns the `IndexedPage` so anchor checks have everything they need. + pub fn page_for_wiki_target( + &self, + path: &str, + source_page: &str, + is_absolute: bool, + ) -> Option<&IndexedPage> { + self.resolve_wiki_path(path, source_page, is_absolute) + .and_then(|u| self.pages_by_uri.get(u)) + } + pub fn page_names(&self) -> Vec { let mut v: Vec = self.pages_by_name.keys().cloned().collect(); v.sort(); @@ -437,6 +480,7 @@ fn index_inlines(inlines: &[InlineNode], page: &mut IndexedPage) { anchor: w.target.anchor.clone(), kind: w.target.kind, span: w.span, + is_absolute: w.target.is_absolute, }); if let Some(desc) = &w.description { index_inlines(desc, page); @@ -498,6 +542,50 @@ fn inline_to_text_into(inlines: &[InlineNode], out: &mut String) { // ===== Filesystem walking ===== +/// Canonicalise a wikilink target into the page name the workspace index +/// would key it by. Mirrors [`WorkspaceIndex::resolve_wiki_path`]'s preference +/// (source-relative when not explicitly absolute, root-relative otherwise) but +/// without requiring the target to already be indexed — useful for backlinks +/// where the target may be added to the index after the source. +/// +/// Collapses `.` and `..` segments so `posts/../foo` → `foo`. `..` at the +/// root of the wiki is treated as a no-op (the wiki has no parent in this +/// model — interwiki crossings use the dedicated `[[wiki:Page]]` syntax). +pub fn canonical_link_name( + path: &str, + source_page: &str, + is_absolute: bool, + file_extension: Option<&str>, +) -> String { + let normalized = strip_wiki_extension(path, file_extension); + let joined = if !is_absolute { + if let Some(slash) = source_page.rfind('/') { + format!("{}/{}", &source_page[..slash], normalized) + } else { + normalized.to_string() + } + } else { + normalized.to_string() + }; + collapse_dots(&joined) +} + +/// Collapse `.` and `..` segments in a `/`-separated path string. Doesn't +/// touch URI escapes — these are workspace-relative page names, not URLs. +pub fn collapse_dots(s: &str) -> String { + let mut parts: Vec<&str> = Vec::new(); + for seg in s.split('/') { + match seg { + "" | "." => continue, + ".." => { + parts.pop(); + } + _ => parts.push(seg), + } + } + parts.join("/") +} + /// Strip a wiki file extension from a link target path without allocating. /// /// `file_extension` may be `".wiki"` (with leading dot) or `"wiki"` (without). diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index f086728..d2ce7ef 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -466,6 +466,112 @@ fn collect_wiki_links_finds_nested() { } } +// ===== Source-relative wiki links (vimwiki default) ===== + +#[test] +fn wiki_link_resolves_source_relative_first() { + let root = "/tmp/srcrel1"; + let idx = build_index( + root, + &[ + ("tips/index", "[[llm-wiki-pattern|LLM]]\n"), + ("tips/llm-wiki-pattern", "= LLM =\n"), + ], + ); + let src = "[[llm-wiki-pattern|LLM]]\n"; + let ast = parse(src); + let diags = diagnostics::link_health( + &ast, + src, + Some(&Url::from_file_path(format!("{root}/tips/index.wiki")).unwrap()), + &idx, + "tips/index", + LinkSeverity::Warning, + true, + ); + assert!(diags.is_empty(), "expected no diagnostics, got: {:?}", diags); +} + +#[test] +fn wiki_link_falls_back_to_root_relative() { + // A source-relative miss should fall through to root-relative — this + // keeps `[[posts/foo]]` from `index.wiki` working even though there's + // no `posts/posts/foo`. + let root = "/tmp/srcrel2"; + let idx = build_index( + root, + &[ + ("index", "[[posts/foo|Foo]]\n"), + ("posts/foo", "= Foo =\n"), + ], + ); + let src = "[[posts/foo|Foo]]\n"; + let ast = parse(src); + let diags = diagnostics::link_health( + &ast, + src, + Some(&home_uri(root)), + &idx, + "index", + LinkSeverity::Warning, + true, + ); + assert!(diags.is_empty(), "got: {:?}", diags); +} + +#[test] +fn wiki_link_absolute_skips_source_relative() { + // `[[/Page]]` is explicitly root-relative even from a subdir. + let root = "/tmp/srcrel3"; + let idx = build_index( + root, + &[ + ("tips/index", "[[/RootPage]]\n"), + ("RootPage", "= Root =\n"), + // Intentionally also create tips/RootPage so a source-relative + // resolution would (wrongly) prefer it — the absolute form must + // ignore source-relative. + ("tips/RootPage", "= Tips Root =\n"), + ], + ); + let target = idx + .resolve_wiki_path("RootPage", "tips/index", true) + .expect("absolute link should resolve"); + assert!( + target.as_str().ends_with("RootPage.wiki"), + "expected root RootPage, got {target}", + ); + assert!( + !target.as_str().contains("/tips/RootPage"), + "absolute link should skip tips/RootPage, got {target}", + ); +} + +#[test] +fn wiki_link_parent_dir_collapses() { + // `[[../Other]]` from `posts/foo` resolves to `Other` at the wiki root. + let root = "/tmp/srcrel4"; + let idx = build_index( + root, + &[ + ("posts/foo", "[[../Other|Other]]\n"), + ("Other", "= Other =\n"), + ], + ); + let src = "[[../Other|Other]]\n"; + let ast = parse(src); + let diags = diagnostics::link_health( + &ast, + src, + Some(&Url::from_file_path(format!("{root}/posts/foo.wiki")).unwrap()), + &idx, + "posts/foo", + LinkSeverity::Warning, + true, + ); + assert!(diags.is_empty(), "got: {:?}", diags); +} + // ===== Anchor matching against raw heading text ===== #[test]