221 lines
7.2 KiB
Rust
221 lines
7.2 KiB
Rust
|
|
//! 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`. 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 legacy 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 the cross-wiki resolution semantics:
|
||
|
|
/// `[[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_wiki_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}");
|
||
|
|
}
|
||
|
|
}
|