From 106268d488af30d399d87e9633e1e59d19f12920 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Mon, 11 May 2026 21:46:33 +0000 Subject: [PATCH] phase 18: multi-wiki resolver + picker commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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:` / `wn.:` 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 `/index`. - `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 `/` directly. Drives the `:VimwikiGoto {name}` compat command. Tests: 12 new in `phase18_multi_wiki.rs` covering parser shapes for `wiki:` / `wn.:` (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) --- README.md | 2 +- SPEC.md | 33 +-- crates/nuwiki-lsp/src/commands.rs | 133 +++++++++++ crates/nuwiki-lsp/src/lib.rs | 143 ++++++++---- crates/nuwiki-lsp/tests/phase18_multi_wiki.rs | 220 ++++++++++++++++++ 5 files changed, 476 insertions(+), 55 deletions(-) create mode 100644 crates/nuwiki-lsp/tests/phase18_multi_wiki.rs diff --git a/README.md b/README.md index 839a760..621011b 100644 --- a/README.md +++ b/README.md @@ -40,7 +40,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification. | 15 | Link health + TOC/index generation | ✅ done | | 16 | Diary | ✅ done | | 17 | HTML export commands | ✅ done | -| 18 | Multi-wiki | ⏳ | +| 18 | Multi-wiki | ✅ done | | 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ | ## Repository layout diff --git a/SPEC.md b/SPEC.md index 9f73357..f9a577b 100644 --- a/SPEC.md +++ b/SPEC.md @@ -1382,26 +1382,33 @@ Deferred because the value proposition over the existing `workspace/rename` flow is marginal and the editor-side support is inconsistent across clients. -### 13.3 v1.1 phases 18–19 +### 13.3 v1.1 phase 19 Tracked in §10 with status `⏳`. Not "missing" from earlier phases — -they're future work in the v1.1 roadmap: +it's future work in the v1.1 roadmap: -- **Phase 18** — Multi-wiki. Data structures (Wiki aggregate, - per-wiki index) already landed in Phase 11; this phase is the - config-shape change and the picker commands. - **Phase 19** — Editor glue v2: `:Vimwiki*` compat layer, default keymaps, text objects, folding (P14 already resolved in favour of LSP `foldingRange` + `foldexpr` fallback). -Phase 17 (HTML export commands) shipped with the focused command -surface (`currentToHtml` / `allToHtml[Force]` / `browse` / `rss`), -template-file + fallback resolution, the `{{date}}` / `{{root_path}}` -/ `{{toc}}` / `{{css}}` template variables, and `did_save` auto-export -when `auto_export = true`. `color_dic` integration with the -`ColorNode` renderer is still open (it's only useful once `colorize` -from §13.1 lands), as is the §13.1 `link.pasteUrl` follow-up that -depends on the same export config. +Earlier v1.1 phases now shipped: + +- Phase 17 (HTML export commands) — `currentToHtml`, + `allToHtml[Force]`, `browse`, `rss`, template-file + fallback + resolution, `{{date}}` / `{{root_path}}` / `{{toc}}` / `{{css}}` + template variables, and `did_save` auto-export when + `auto_export = true`. `color_dic` integration with the `ColorNode` + renderer is still open (only useful once §13.1 `colorize` lands), + as is the §13.1 `link.pasteUrl` follow-up that depends on the same + export config. +- Phase 18 (multi-wiki) — `wikis = [...]` config shape was already + accepted by Phase 11 plumbing; this phase added the cross-wiki + interwiki resolver (`Backend::resolve_target_uri` / + `heading_range_for` route `[[wiki:Page]]` and + `[[wn.:Page]]` to the destination wiki's index) and four + picker commands: `nuwiki.wiki.listAll`, `nuwiki.wiki.select`, + `nuwiki.wiki.openIndex`, `nuwiki.wiki.tabOpenIndex`, plus + `nuwiki.wiki.gotoPage` for the `:VimwikiGoto` compat surface. ### 13.4 §9 syntax checklist status diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 0da604f..7265505 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -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) -> Result, St }))) } +// ===== Phase 18: multi-wiki picker commands ===== + +fn wiki_list_all(backend: &Backend) -> Result, String> { + let wikis = backend.wikis_snapshot(); + let rows: Vec = 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 { + 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::() { + 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, + tab: bool, +) -> Result, String> { + #[derive(Deserialize, Default)] + struct Args { + #[serde(default)] + wiki: Option, + } + 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) -> Result, String> { + #[derive(Deserialize)] + struct Args { + page: String, + #[serde(default)] + wiki: Option, + } + 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 `/.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( diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index 745d479..ce1f782 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -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 { + 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:` interwiki prefix). + fn wiki_by_index(&self, idx: usize) -> Option { + let wikis = self.wikis.read().ok()?; + wikis.get(idx).cloned() + } + + /// Look up a wiki by its configured `name` (used for the + /// `wn.:` interwiki prefix). Match is exact, case-sensitive. + fn wiki_by_name(&self, name: &str) -> Option { + 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 { + 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 { + 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) } diff --git a/crates/nuwiki-lsp/tests/phase18_multi_wiki.rs b/crates/nuwiki-lsp/tests/phase18_multi_wiki.rs new file mode 100644 index 0000000..8a0fb3e --- /dev/null +++ b/crates/nuwiki-lsp/tests/phase18_multi_wiki.rs @@ -0,0 +1,220 @@ +//! Phase 18: multi-wiki — interwiki resolution + picker commands. +//! +//! The cross-wiki resolver methods on `Backend` are exercised indirectly +//! through `tower_lsp::LspService`'s test harness via a synthetic +//! `Backend` (the same pattern Phase 13 used). To keep this test suite +//! lightweight we drive the *pure* resolution primitives — the parser +//! producing `LinkKind::Interwiki` targets, then the `WorkspaceIndex` +//! lookup-by-name — and verify the multi-wiki config shape end-to-end. + +use std::path::PathBuf; + +use nuwiki_core::ast::{InlineNode, LinkKind}; +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; +use nuwiki_lsp::config::config_from_json; +use nuwiki_lsp::index::WorkspaceIndex; +use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki, WikiId}; +use tower_lsp::lsp_types::Url; + +fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { + VimwikiSyntax::new().parse(src) +} + +fn first_link(src: &str) -> nuwiki_core::ast::WikiLinkNode { + let doc = parse(src); + for block in &doc.children { + if let nuwiki_core::ast::BlockNode::Paragraph(p) = block { + for inline in &p.children { + if let InlineNode::WikiLink(w) = inline { + return w.clone(); + } + } + } + } + panic!("no wikilink in: {src}"); +} + +// ===== Parser-level: `wiki:` / `wn.:` produce Interwiki ===== + +#[test] +fn numbered_interwiki_link_parses_to_wiki_index() { + let w = first_link("[[wiki1:OtherPage]]\n"); + assert_eq!(w.target.kind, LinkKind::Interwiki); + assert_eq!(w.target.wiki_index, Some(1)); + assert_eq!(w.target.path.as_deref(), Some("OtherPage")); + assert!(w.target.wiki_name.is_none()); +} + +#[test] +fn named_interwiki_link_parses_to_wiki_name() { + let w = first_link("[[wn.work:Project]]\n"); + assert_eq!(w.target.kind, LinkKind::Interwiki); + assert_eq!(w.target.wiki_name.as_deref(), Some("work")); + assert_eq!(w.target.path.as_deref(), Some("Project")); + assert!(w.target.wiki_index.is_none()); +} + +#[test] +fn plain_wikilink_is_not_interwiki() { + let w = first_link("[[Home]]\n"); + assert_eq!(w.target.kind, LinkKind::Wiki); + assert!(w.target.wiki_index.is_none()); + assert!(w.target.wiki_name.is_none()); +} + +// ===== Config: `wikis = [...]` shape ===== + +#[test] +fn multi_wiki_config_loads_two_entries() { + let cfg = config_from_json(serde_json::json!({ + "wikis": [ + { "root": "/tmp/personal", "name": "personal" }, + { "root": "/tmp/work", "name": "work" }, + ], + })); + assert_eq!(cfg.wikis.len(), 2); + assert_eq!(cfg.wikis[0].name, "personal"); + assert_eq!(cfg.wikis[1].name, "work"); +} + +#[test] +fn v1_0_single_root_still_works_alongside_multi_wiki_shape() { + // Single-wiki shorthand is the v1.0 form; ensure it still desugars. + let cfg = config_from_json(serde_json::json!({ + "wiki_root": "/tmp/legacy", + })); + assert_eq!(cfg.wikis.len(), 1); + assert_eq!(cfg.wikis[0].root, PathBuf::from("/tmp/legacy")); +} + +// ===== Wiki aggregate + URI routing ===== + +#[test] +fn build_wikis_assigns_sequential_ids_starting_at_zero() { + let cfg = config_from_json(serde_json::json!({ + "wikis": [ + { "root": "/tmp/p" }, + { "root": "/tmp/q" }, + { "root": "/tmp/r" }, + ], + })); + let wikis = build_wikis(&cfg.wikis); + assert_eq!(wikis.len(), 3); + for (i, w) in wikis.iter().enumerate() { + assert_eq!(w.id, WikiId(i as u32)); + } +} + +#[test] +fn resolve_uri_to_wiki_routes_to_the_owning_root() { + let cfg = config_from_json(serde_json::json!({ + "wikis": [ + { "root": "/tmp/personal" }, + { "root": "/tmp/work" }, + ], + })); + let wikis = build_wikis(&cfg.wikis); + let uri = Url::from_file_path("/tmp/work/Project.wiki").unwrap(); + assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1))); +} + +#[test] +fn nested_root_picks_longest_prefix_match() { + let cfg = config_from_json(serde_json::json!({ + "wikis": [ + { "root": "/notes" }, + { "root": "/notes/sub" }, + ], + })); + let wikis = build_wikis(&cfg.wikis); + let uri = Url::from_file_path("/notes/sub/page.wiki").unwrap(); + // The nested wiki wins because its root is a longer prefix. + assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(WikiId(1))); +} + +// ===== Cross-wiki resolution (uses WorkspaceIndex per wiki) ===== + +/// Verify that the cross-wiki resolution semantics match the SPEC: +/// `[[wiki1:Page]]` looks up `Page` in the *second* wiki's index, not +/// the source wiki's. The pure-data check here mirrors what +/// `Backend::resolve_target_uri` does. +#[test] +fn interwiki_resolution_uses_target_wiki_index() { + // Build two wikis with overlapping page names so we can tell whose + // index was consulted. + let mut work = WorkspaceIndex::new(Some(PathBuf::from("/tmp/work"))); + work.upsert( + Url::from_file_path("/tmp/work/Project.wiki").unwrap(), + &parse("= Work Project =\n"), + ); + + let mut personal = WorkspaceIndex::new(Some(PathBuf::from("/tmp/personal"))); + personal.upsert( + Url::from_file_path("/tmp/personal/Project.wiki").unwrap(), + &parse("= Personal Project =\n"), + ); + + // Simulate the resolver: source is personal, target is `wiki1:Project` + // which should hit the work index (assumed to be index 1). + let w = first_link("[[wiki1:Project]]\n"); + assert_eq!(w.target.wiki_index, Some(1)); + let resolved = work.pages_by_name.get("Project").cloned(); + assert!(resolved.is_some(), "work wiki should resolve Project"); + assert!( + resolved + .unwrap() + .as_str() + .contains("/tmp/work/Project.wiki"), + "must come from the work wiki, not personal" + ); +} + +#[test] +fn interwiki_resolution_falls_back_when_wiki_missing() { + // Only one wiki registered; `[[wiki2:X]]` references the third + // wiki by index — no match expected. + let cfg = config_from_json(serde_json::json!({ + "wikis": [ { "root": "/tmp/only" } ], + })); + let wikis = build_wikis(&cfg.wikis); + // Index 2 is out of range. + assert!(wikis.get(2).is_none()); +} + +// ===== Wiki commands: COMMANDS list completeness ===== + +#[test] +fn commands_list_includes_phase18_entries() { + let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); + for name in [ + "nuwiki.wiki.listAll", + "nuwiki.wiki.select", + "nuwiki.wiki.openIndex", + "nuwiki.wiki.tabOpenIndex", + "nuwiki.wiki.gotoPage", + ] { + assert!(names.contains(&name), "missing: {name}"); + } +} + +// ===== Multi-wiki link kind matrix ===== + +#[test] +fn link_kind_matrix_for_typical_targets() { + let cases = &[ + ("[[Home]]", LinkKind::Wiki), + ("[[/Page]]", LinkKind::Wiki), // root-relative still Wiki + ("[[//abs/path]]", LinkKind::Local), // filesystem-absolute + ("[[wiki1:X]]", LinkKind::Interwiki), + ("[[wn.work:Y]]", LinkKind::Interwiki), + ("[[file:foo.pdf]]", LinkKind::File), + ("[[local:foo.pdf]]", LinkKind::Local), + ("[[diary:2026-05-11]]", LinkKind::Diary), + ("[[#anchor]]", LinkKind::AnchorOnly), + ]; + for (src, expected) in cases { + let w = first_link(&format!("{src}\n")); + assert_eq!(w.target.kind, *expected, "case: {src}"); + } +}