phase 18: multi-wiki resolver + picker commands
CI / cargo fmt --check (push) Successful in 29s
CI / cargo clippy (push) Successful in 1m19s
CI / cargo test (push) Successful in 1m14s

The Phase 11 plumbing already landed the data structures (`Wiki`
aggregate, per-wiki `WorkspaceIndex`, `wikis = [...]` config shape
with v1.0 desugaring). Phase 18 closes the user-visible gap: cross-
wiki link resolution + the picker command surface.

Cross-wiki resolution:
- `Backend::wikis_snapshot` — clone the Vec under the read lock
  so commands can drop it before awaiting.
- `Backend::wiki_by_index` / `wiki_by_name` — pair with the
  parser's `wiki<N>:` / `wn.<Name>:` interwiki shapes.
- `Backend::resolve_target_uri(target, source_uri)` — single entry
  point for cross-wiki page lookup:
    - `Wiki` / `AnchorOnly` → source wiki's index (Phase 8
      behaviour preserved).
    - `Interwiki` → routes to the wiki referenced by
      `wiki_index` / `wiki_name`, then resolves the page in that
      wiki's `pages_by_name`. Returns `None` when no wiki matches
      or the page isn't indexed.
- `Backend::heading_range_for(target_uri, anchor)` — anchor lookup
  on the target wiki's index. Phase 8's `goto_definition` was
  computing this against the *source* wiki's index, which broke for
  interwiki anchors; both `goto_definition` and `hover` are
  refactored onto the new pair.

Commands:
- `nuwiki.wiki.listAll` — returns `[{ id, name, root, syntax,
  file_extension }]`. Drives the picker UI.
- `nuwiki.wiki.select` — alias of `listAll` per SPEC §12.9
  (selection is client-side; the server just exposes the list).
- `nuwiki.wiki.openIndex` — args `{ wiki? }` accepting an index id
  (number), name (string), or numeric string. Returns
  `{ uri, name, tab: false }` for `<root>/index<file_extension>`.
- `nuwiki.wiki.tabOpenIndex` — same payload but `tab: true` so
  the client knows to open in a new tab/split.
- `nuwiki.wiki.gotoPage` — args `{ page, wiki? }`. Prefers an
  indexed URI when the page is already known; otherwise builds
  `<root>/<page><file_extension>` directly. Drives the
  `:VimwikiGoto {name}` compat command.

Tests: 12 new in `phase18_multi_wiki.rs` covering parser shapes for
`wiki<N>:` / `wn.<Name>:` (and that plain `[[Home]]` stays
`LinkKind::Wiki`), multi-wiki config loading + v1.0 single-root
desugaring, `WikiId` sequential assignment, longest-prefix URI
routing for nested roots, the cross-wiki resolution semantics
(target wiki's index wins when both wikis define the same page
name), missing-wiki fallback, the COMMANDS list completeness, and a
link-kind matrix covering all nine kinds. Total 369 tests pass;
fmt + clippy clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 21:46:33 +00:00
parent d1b807b7d1
commit 106268d488
5 changed files with 476 additions and 55 deletions
+102 -41
View File
@@ -186,6 +186,73 @@ impl Backend {
id_or_default.and_then(|id| self.wiki(id))
}
/// Snapshot of every registered wiki — held by value so callers can
/// drop the lock before awaiting or building responses.
pub(crate) fn wikis_snapshot(&self) -> Vec<Wiki> {
self.wikis.read().map(|g| g.clone()).unwrap_or_default()
}
/// Look up a wiki by its position in the `wikis` Vec (used for the
/// `wiki<N>:` interwiki prefix).
fn wiki_by_index(&self, idx: usize) -> Option<Wiki> {
let wikis = self.wikis.read().ok()?;
wikis.get(idx).cloned()
}
/// Look up a wiki by its configured `name` (used for the
/// `wn.<Name>:` interwiki prefix). Match is exact, case-sensitive.
fn wiki_by_name(&self, name: &str) -> Option<Wiki> {
let wikis = self.wikis.read().ok()?;
wikis.iter().find(|w| w.config.name == name).cloned()
}
/// Resolve a `LinkTarget` to the destination page's URI.
///
/// - `LinkKind::Wiki` / `LinkKind::AnchorOnly` — uses the source
/// document's wiki index (same as Phase 8 behaviour).
/// - `LinkKind::Interwiki` — routes to the wiki referenced by
/// `wiki_index` or `wiki_name`, then looks up the page in that
/// wiki's index. This is the Phase 18 cross-wiki bridge.
///
/// Returns `None` when no wiki matches or the page isn't indexed.
fn resolve_target_uri(
&self,
target: &nuwiki_core::ast::LinkTarget,
source_uri: &Url,
) -> Option<Url> {
use nuwiki_core::ast::LinkKind;
match target.kind {
LinkKind::Interwiki => {
let target_wiki = if let Some(idx) = target.wiki_index {
self.wiki_by_index(idx)?
} else if let Some(name) = target.wiki_name.as_deref() {
self.wiki_by_name(name)?
} else {
return None;
};
let path = target.path.as_deref()?;
let idx = target_wiki.index.read().ok()?;
idx.pages_by_name.get(path).cloned()
}
_ => {
let source_wiki = self.wiki_for_uri(source_uri)?;
let source_page =
index::page_name_from_uri(source_uri, Some(source_wiki.config.root.as_path()));
let idx = source_wiki.index.read().ok()?;
idx.resolve(target, &source_page).cloned()
}
}
}
/// Compute the LSP `Range` for an anchor (heading or tag) on the
/// page at `target_uri`. Resolves the *target* wiki, so interwiki
/// anchors land at the correct heading in the cross-wiki page.
fn heading_range_for(&self, target_uri: &Url, anchor: &str, utf8: bool) -> Option<Range> {
let wiki = self.wiki_for_uri(target_uri)?;
let idx = wiki.index.read().ok()?;
heading_range_in(&idx, target_uri, anchor, utf8)
}
/// Return live state if `uri` is open in the editor, otherwise read
/// the file from disk and re-parse. The closed-doc path is used by
/// cross-document operations (Phase 13 `workspace/rename` and link
@@ -573,33 +640,18 @@ impl LanguageServer for Backend {
return Ok(None);
};
let location = match node {
InlineNode::WikiLink(w) => {
let wiki = self.wiki_for_uri(uri);
let source_page =
index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path()));
let idx_guard = wiki
.as_ref()
.map(|w| w.index.read().expect("index lock poisoned"));
let target_uri = idx_guard
.as_ref()
.and_then(|idx| idx.resolve(&w.target, &source_page).cloned());
target_uri.map(|target| {
let target_range = w
.target
.anchor
.as_deref()
.and_then(|a| {
idx_guard
.as_ref()
.and_then(|idx| heading_range_in(idx, &target, a, utf8))
})
.unwrap_or_else(zero_range);
Location {
uri: target,
range: target_range,
}
})
}
InlineNode::WikiLink(w) => self.resolve_target_uri(&w.target, uri).map(|target| {
let target_range = w
.target
.anchor
.as_deref()
.and_then(|a| self.heading_range_for(&target, a, utf8))
.unwrap_or_else(zero_range);
Location {
uri: target,
range: target_range,
}
}),
InlineNode::ExternalLink(_) | InlineNode::RawUrl(_) => None,
_ => None,
};
@@ -677,21 +729,30 @@ impl LanguageServer for Backend {
let (markdown, hover_span) = match node {
InlineNode::WikiLink(w) => {
let wiki = self.wiki_for_uri(uri);
let source_page =
index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path()));
let body = if let Some(wiki) = wiki.as_ref() {
let idx = wiki.index.read().expect("index lock poisoned");
let target_uri = idx.resolve(&w.target, &source_page).cloned();
match target_uri.as_ref().and_then(|u| idx.page(u)) {
Some(page) => preview_for(page, w.target.anchor.as_deref()),
None => match &w.target.path {
Some(p) => format!("**{p}** — page not in index"),
None => "(unresolved link)".into(),
},
let body = match self.resolve_target_uri(&w.target, uri) {
Some(target_uri) => {
// Look up the page in whichever wiki owns the
// resolved URI — for interwiki targets this is
// the destination wiki, not the source.
let wiki = self.wiki_for_uri(&target_uri);
match wiki.as_ref() {
Some(wiki) => {
let idx = wiki.index.read().expect("index lock poisoned");
match idx.page(&target_uri) {
Some(page) => preview_for(page, w.target.anchor.as_deref()),
None => match &w.target.path {
Some(p) => format!("**{p}** — page not in index"),
None => "(unresolved link)".into(),
},
}
}
None => "(no wiki configured)".into(),
}
}
} else {
"(no wiki configured)".into()
None => match &w.target.path {
Some(p) => format!("**{p}** — page not in index"),
None => "(unresolved link)".into(),
},
};
(body, w.span)
}