fix(lsp): resolve wikilinks source-relative first, with .. collapsing

`[[llm-wiki-pattern]]` in `tips/index.wiki` should resolve to
`tips/llm-wiki-pattern.wiki` — vimwiki's default is source-relative
for non-absolute targets. The index keyed every page by its
workspace-relative path, so the link looked up `llm-wiki-pattern`
(root-relative) and missed the sibling. Goto-definition then
synthesised a non-existent root URL, and `nuwiki.link` diagnostics
flagged every such link as missing-page.

  - `WorkspaceIndex::resolve_wiki_path` and `page_for_wiki_target`
    centralise the lookup: try `{source_dir}/{path}` first, fall back
    to root-relative. Used by goto-definition, diagnostics, and the
    workspace `checkLinks` walker.
  - `collapse_dots` resolves `.` / `..` segments before lookup so
    `[[../Other]]` from `posts/foo` correctly hits `Other` at the
    wiki root (and is reported broken if no such page exists, rather
    than silently going to nowhere).
  - `canonical_link_name` applies the same rule when keying the
    backlinks index, so backlinks queried by the resolved page name
    pick up references that were written source-relative.
  - `OutgoingLink` carries `is_absolute` now so `classify_outgoing`
    (driven from cached index data, not the live AST) gets the same
    treatment without re-parsing the source.
  - `classify_outgoing`'s File/Local branch now reuses
    `resolve_file_path` instead of duplicating the `//absolute` /
    relative-to-source logic inline.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-05-26 22:30:32 -03:00
parent 859d69e4fe
commit f477f6e6a7
3 changed files with 204 additions and 29 deletions
+3 -22
View File
@@ -166,9 +166,7 @@ pub fn classify_link(
match link.target.kind { match link.target.kind {
LinkKind::Wiki => { LinkKind::Wiki => {
let path = link.target.path.as_deref()?; let path = link.target.path.as_deref()?;
let normalized = index.strip_link_extension(path); match index.page_for_wiki_target(path, source_page, link.target.is_absolute) {
let page = index.page_by_name(normalized);
match page {
None => Some(BrokenLinkKind::MissingPage(path.to_string())), None => Some(BrokenLinkKind::MissingPage(path.to_string())),
Some(page) => { Some(page) => {
if let Some(anchor) = &link.target.anchor { if let Some(anchor) = &link.target.anchor {
@@ -229,8 +227,7 @@ pub fn classify_outgoing(
match link.kind { match link.kind {
LinkKind::Wiki => { LinkKind::Wiki => {
let path = link.target_page.as_deref()?; let path = link.target_page.as_deref()?;
let normalized = index.strip_link_extension(path); match index.page_for_wiki_target(path, source_page, link.is_absolute) {
match index.page_by_name(normalized) {
None => Some(BrokenLinkKind::MissingPage(path.to_string())), None => Some(BrokenLinkKind::MissingPage(path.to_string())),
Some(page) => { Some(page) => {
if let Some(anchor) = &link.anchor { if let Some(anchor) = &link.anchor {
@@ -262,23 +259,7 @@ pub fn classify_outgoing(
} }
LinkKind::File | LinkKind::Local => { LinkKind::File | LinkKind::Local => {
let path = link.target_page.as_deref()?; let path = link.target_page.as_deref()?;
// OutgoingLink doesn't carry `is_absolute`; recover from the let resolved = resolve_file_path(Some(source_uri), index, path, link.is_absolute)?;
// 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;
};
if resolved.exists() { if resolved.exists() {
None None
} else { } else {
+95 -7
View File
@@ -83,6 +83,10 @@ pub struct OutgoingLink {
pub anchor: Option<String>, pub anchor: Option<String>,
pub kind: LinkKind, pub kind: LinkKind,
pub span: Span, 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)] #[derive(Debug, Clone)]
@@ -174,9 +178,9 @@ impl WorkspaceIndex {
let ext = self.file_extension.as_deref(); let ext = self.file_extension.as_deref();
for link in &page.outgoing { for link in &page.outgoing {
if let Some(tgt) = &link.target_page { 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 self.backlinks
.entry(norm.to_string()) .entry(key)
.or_default() .or_default()
.push(Backlink { .push(Backlink {
source_uri: uri.clone(), source_uri: uri.clone(),
@@ -227,13 +231,13 @@ impl WorkspaceIndex {
/// Resolve a `LinkTarget` to a page URI in this workspace. /// Resolve a `LinkTarget` to a page URI in this workspace.
/// `source_page` is the name of the page the link originates from — /// `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> { pub fn resolve(&self, target: &LinkTarget, source_page: &str) -> Option<&Url> {
match target.kind { match target.kind {
LinkKind::Wiki => target.path.as_deref().and_then(|p| { LinkKind::Wiki => target
let normalized = self.strip_link_extension(p); .path
self.pages_by_name.get(normalized) .as_deref()
}), .and_then(|p| self.resolve_wiki_path(p, source_page, target.is_absolute)),
LinkKind::AnchorOnly => self.pages_by_name.get(source_page), LinkKind::AnchorOnly => self.pages_by_name.get(source_page),
// Interwiki / Diary / File / Local / Raw aren't resolved // Interwiki / Diary / File / Local / Raw aren't resolved
// against the workspace index here. The handlers fall back to // 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> { pub fn page(&self, uri: &Url) -> Option<&IndexedPage> {
self.pages_by_uri.get(uri) self.pages_by_uri.get(uri)
} }
@@ -252,6 +282,19 @@ impl WorkspaceIndex {
.and_then(|u| self.pages_by_uri.get(u)) .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<String> { pub fn page_names(&self) -> Vec<String> {
let mut v: Vec<String> = self.pages_by_name.keys().cloned().collect(); let mut v: Vec<String> = self.pages_by_name.keys().cloned().collect();
v.sort(); v.sort();
@@ -437,6 +480,7 @@ fn index_inlines(inlines: &[InlineNode], page: &mut IndexedPage) {
anchor: w.target.anchor.clone(), anchor: w.target.anchor.clone(),
kind: w.target.kind, kind: w.target.kind,
span: w.span, span: w.span,
is_absolute: w.target.is_absolute,
}); });
if let Some(desc) = &w.description { if let Some(desc) = &w.description {
index_inlines(desc, page); index_inlines(desc, page);
@@ -498,6 +542,50 @@ fn inline_to_text_into(inlines: &[InlineNode], out: &mut String) {
// ===== Filesystem walking ===== // ===== 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. /// Strip a wiki file extension from a link target path without allocating.
/// ///
/// `file_extension` may be `".wiki"` (with leading dot) or `"wiki"` (without). /// `file_extension` may be `".wiki"` (with leading dot) or `"wiki"` (without).
+106
View File
@@ -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 ===== // ===== Anchor matching against raw heading text =====
#[test] #[test]