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
+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 =====
#[test]