phase 18: multi-wiki resolver + picker commands
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:
@@ -40,7 +40,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification.
|
|||||||
| 15 | Link health + TOC/index generation | ✅ done |
|
| 15 | Link health + TOC/index generation | ✅ done |
|
||||||
| 16 | Diary | ✅ done |
|
| 16 | Diary | ✅ done |
|
||||||
| 17 | HTML export commands | ✅ done |
|
| 17 | HTML export commands | ✅ done |
|
||||||
| 18 | Multi-wiki | ⏳ |
|
| 18 | Multi-wiki | ✅ done |
|
||||||
| 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ |
|
| 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ |
|
||||||
|
|
||||||
## Repository layout
|
## Repository layout
|
||||||
|
|||||||
@@ -1382,26 +1382,33 @@ Deferred because the value proposition over the existing
|
|||||||
`workspace/rename` flow is marginal and the editor-side support is
|
`workspace/rename` flow is marginal and the editor-side support is
|
||||||
inconsistent across clients.
|
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 —
|
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
|
- **Phase 19** — Editor glue v2: `:Vimwiki*` compat layer, default
|
||||||
keymaps, text objects, folding (P14 already resolved in favour of
|
keymaps, text objects, folding (P14 already resolved in favour of
|
||||||
LSP `foldingRange` + `foldexpr` fallback).
|
LSP `foldingRange` + `foldexpr` fallback).
|
||||||
|
|
||||||
Phase 17 (HTML export commands) shipped with the focused command
|
Earlier v1.1 phases now shipped:
|
||||||
surface (`currentToHtml` / `allToHtml[Force]` / `browse` / `rss`),
|
|
||||||
template-file + fallback resolution, the `{{date}}` / `{{root_path}}`
|
- Phase 17 (HTML export commands) — `currentToHtml`,
|
||||||
/ `{{toc}}` / `{{css}}` template variables, and `did_save` auto-export
|
`allToHtml[Force]`, `browse`, `rss`, template-file + fallback
|
||||||
when `auto_export = true`. `color_dic` integration with the
|
resolution, `{{date}}` / `{{root_path}}` / `{{toc}}` / `{{css}}`
|
||||||
`ColorNode` renderer is still open (it's only useful once `colorize`
|
template variables, and `did_save` auto-export when
|
||||||
from §13.1 lands), as is the §13.1 `link.pasteUrl` follow-up that
|
`auto_export = true`. `color_dic` integration with the `ColorNode`
|
||||||
depends on the same export config.
|
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<N>:Page]]` and
|
||||||
|
`[[wn.<Name>: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
|
### 13.4 §9 syntax checklist status
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,11 @@ pub const COMMANDS: &[&str] = &[
|
|||||||
"nuwiki.export.allToHtmlForce",
|
"nuwiki.export.allToHtmlForce",
|
||||||
"nuwiki.export.browse",
|
"nuwiki.export.browse",
|
||||||
"nuwiki.export.rss",
|
"nuwiki.export.rss",
|
||||||
|
"nuwiki.wiki.listAll",
|
||||||
|
"nuwiki.wiki.select",
|
||||||
|
"nuwiki.wiki.openIndex",
|
||||||
|
"nuwiki.wiki.tabOpenIndex",
|
||||||
|
"nuwiki.wiki.gotoPage",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub(crate) async fn execute(
|
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))
|
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.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}")),
|
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 =====
|
// ===== Phase 17: HTML export commands =====
|
||||||
|
|
||||||
fn export_current(
|
fn export_current(
|
||||||
|
|||||||
+102
-41
@@ -186,6 +186,73 @@ impl Backend {
|
|||||||
id_or_default.and_then(|id| self.wiki(id))
|
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
|
/// 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
|
/// the file from disk and re-parse. The closed-doc path is used by
|
||||||
/// cross-document operations (Phase 13 `workspace/rename` and link
|
/// cross-document operations (Phase 13 `workspace/rename` and link
|
||||||
@@ -573,33 +640,18 @@ impl LanguageServer for Backend {
|
|||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let location = match node {
|
let location = match node {
|
||||||
InlineNode::WikiLink(w) => {
|
InlineNode::WikiLink(w) => self.resolve_target_uri(&w.target, uri).map(|target| {
|
||||||
let wiki = self.wiki_for_uri(uri);
|
let target_range = w
|
||||||
let source_page =
|
.target
|
||||||
index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path()));
|
.anchor
|
||||||
let idx_guard = wiki
|
.as_deref()
|
||||||
.as_ref()
|
.and_then(|a| self.heading_range_for(&target, a, utf8))
|
||||||
.map(|w| w.index.read().expect("index lock poisoned"));
|
.unwrap_or_else(zero_range);
|
||||||
let target_uri = idx_guard
|
Location {
|
||||||
.as_ref()
|
uri: target,
|
||||||
.and_then(|idx| idx.resolve(&w.target, &source_page).cloned());
|
range: target_range,
|
||||||
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::ExternalLink(_) | InlineNode::RawUrl(_) => None,
|
InlineNode::ExternalLink(_) | InlineNode::RawUrl(_) => None,
|
||||||
_ => None,
|
_ => None,
|
||||||
};
|
};
|
||||||
@@ -677,21 +729,30 @@ impl LanguageServer for Backend {
|
|||||||
|
|
||||||
let (markdown, hover_span) = match node {
|
let (markdown, hover_span) = match node {
|
||||||
InlineNode::WikiLink(w) => {
|
InlineNode::WikiLink(w) => {
|
||||||
let wiki = self.wiki_for_uri(uri);
|
let body = match self.resolve_target_uri(&w.target, uri) {
|
||||||
let source_page =
|
Some(target_uri) => {
|
||||||
index::page_name_from_uri(uri, wiki.as_ref().map(|w| w.config.root.as_path()));
|
// Look up the page in whichever wiki owns the
|
||||||
let body = if let Some(wiki) = wiki.as_ref() {
|
// resolved URI — for interwiki targets this is
|
||||||
let idx = wiki.index.read().expect("index lock poisoned");
|
// the destination wiki, not the source.
|
||||||
let target_uri = idx.resolve(&w.target, &source_page).cloned();
|
let wiki = self.wiki_for_uri(&target_uri);
|
||||||
match target_uri.as_ref().and_then(|u| idx.page(u)) {
|
match wiki.as_ref() {
|
||||||
Some(page) => preview_for(page, w.target.anchor.as_deref()),
|
Some(wiki) => {
|
||||||
None => match &w.target.path {
|
let idx = wiki.index.read().expect("index lock poisoned");
|
||||||
Some(p) => format!("**{p}** — page not in index"),
|
match idx.page(&target_uri) {
|
||||||
None => "(unresolved link)".into(),
|
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 {
|
None => match &w.target.path {
|
||||||
"(no wiki configured)".into()
|
Some(p) => format!("**{p}** — page not in index"),
|
||||||
|
None => "(unresolved link)".into(),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
(body, w.span)
|
(body, w.span)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<N>:` / `wn.<Name>:` 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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user