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:
@@ -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 {
|
||||
|
||||
@@ -83,6 +83,10 @@ pub struct OutgoingLink {
|
||||
pub anchor: Option<String>,
|
||||
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<String> {
|
||||
let mut v: Vec<String> = 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).
|
||||
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user