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
+133
View File
@@ -60,6 +60,11 @@ pub const COMMANDS: &[&str] = &[
"nuwiki.export.allToHtmlForce",
"nuwiki.export.browse",
"nuwiki.export.rss",
"nuwiki.wiki.listAll",
"nuwiki.wiki.select",
"nuwiki.wiki.openIndex",
"nuwiki.wiki.tabOpenIndex",
"nuwiki.wiki.gotoPage",
];
pub(crate) async fn execute(
@@ -139,6 +144,17 @@ pub(crate) async fn execute(
export_current(backend, args, true).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.export.rss" => export_rss(backend, args).map(|o| o.map(CommandOutcome::Value)),
"nuwiki.wiki.listAll" => wiki_list_all(backend).map(|o| o.map(CommandOutcome::Value)),
"nuwiki.wiki.select" => wiki_list_all(backend).map(|o| o.map(CommandOutcome::Value)),
"nuwiki.wiki.openIndex" => {
wiki_open_index(backend, args, false).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.wiki.tabOpenIndex" => {
wiki_open_index(backend, args, true).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.wiki.gotoPage" => {
wiki_goto_page(backend, args).map(|o| o.map(CommandOutcome::Value))
}
other => Err(format!("unknown nuwiki command: {other}")),
}
}
@@ -600,6 +616,123 @@ fn tags_rebuild(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, St
})))
}
// ===== Phase 18: multi-wiki picker commands =====
fn wiki_list_all(backend: &Backend) -> Result<Option<Value>, String> {
let wikis = backend.wikis_snapshot();
let rows: Vec<Value> = wikis
.iter()
.enumerate()
.map(|(i, w)| {
serde_json::json!({
"id": i,
"name": w.config.name,
"root": w.config.root,
"syntax": w.config.syntax,
"file_extension": w.config.file_extension,
})
})
.collect();
Ok(Some(serde_json::json!(rows)))
}
fn resolve_wiki_selector(backend: &Backend, sel: Option<&Value>) -> Option<crate::wiki::Wiki> {
let wikis = backend.wikis_snapshot();
let Some(sel) = sel else {
return wikis.into_iter().next();
};
if let Some(idx) = sel.as_u64() {
return wikis.into_iter().nth(idx as usize);
}
if let Some(name) = sel.as_str() {
// Numeric strings are also accepted so the picker UI can pass
// back whatever it received without coercing.
if let Ok(idx) = name.parse::<usize>() {
if let Some(w) = wikis.get(idx).cloned() {
return Some(w);
}
}
return wikis.into_iter().find(|w| w.config.name == name);
}
None
}
fn wiki_open_index(
backend: &Backend,
args: Vec<Value>,
tab: bool,
) -> Result<Option<Value>, String> {
#[derive(Deserialize, Default)]
struct Args {
#[serde(default)]
wiki: Option<Value>,
}
let parsed: Args = match args.into_iter().next() {
Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}"))?,
None => Args::default(),
};
let Some(wiki) = resolve_wiki_selector(backend, parsed.wiki.as_ref()) else {
return Ok(None);
};
let index_path = wiki
.config
.root
.join(format!("index{}", wiki.config.file_extension));
let Ok(uri) = Url::from_file_path(&index_path) else {
return Ok(None);
};
Ok(Some(serde_json::json!({
"uri": uri,
"name": wiki.config.name,
"tab": tab,
})))
}
fn wiki_goto_page(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
#[derive(Deserialize)]
struct Args {
page: String,
#[serde(default)]
wiki: Option<Value>,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "nuwiki.wiki.gotoPage: missing { page } argument".to_string())?;
let parsed: Args = serde_json::from_value(raw)
.map_err(|e| format!("nuwiki.wiki.gotoPage: invalid args — {e}"))?;
let Some(wiki) = resolve_wiki_selector(backend, parsed.wiki.as_ref()) else {
return Ok(None);
};
// Prefer an indexed page first (canonical URI from the workspace
// index), then fall back to building the path from the page name.
let uri = {
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
idx.pages_by_name.get(&parsed.page).cloned()
};
let uri = uri.unwrap_or_else(|| {
// Build `<root>/<page>.wiki` directly. `page` may contain
// forward-slashes for subdirs.
let mut path = wiki.config.root.clone();
for seg in parsed.page.split('/') {
if !seg.is_empty() {
path.push(seg);
}
}
let path = path.with_extension(wiki.config.file_extension.trim_start_matches('.'));
Url::from_file_path(path)
.unwrap_or_else(|_| Url::parse("file:///nonexistent").expect("constant url"))
});
Ok(Some(serde_json::json!({
"uri": uri,
"page": parsed.page,
"wiki": wiki.config.name,
})))
}
// ===== Phase 17: HTML export commands =====
fn export_current(