Files
nuwiki/crates/nuwiki-lsp/tests/commands_coverage.rs
T
gffranco 94cb58064d
CI / cargo fmt --check (push) Successful in 35s
CI / cargo clippy (push) Successful in 39s
CI / cargo test (push) Successful in 50s
CI / editor keymaps (push) Successful in 1m25s
feat(toc): insert a fresh TOC at the cursor line
:NuwikiTOC inserted a new table of contents at the top of the file. It now
inserts at the cursor line instead, so you can place the TOC where you want
it (a blank line separates it from preceding text). An existing TOC is
still refreshed in place, and auto_toc-on-save is unaffected.

The Vim/Lua clients send the 0-based cursor line with nuwiki.toc.generate;
the server threads it into toc_edit (clamped to the document) and falls
back to the top of the file when absent. toc_rebuild_edit passes None.

Tests: toc_edit_inserts_at_cursor_line +
toc_edit_cursor_line_clamped_and_existing_replaced_in_place. 575 passed,
clippy clean, keymap harnesses green. Doc updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 00:51:40 +00:00

592 lines
21 KiB
Rust

//! Command-coverage suite: at least one behavioural test for every
//! documented `:Nuwiki*` / `:Vimwiki*` command (see the COMMANDS section
//! of `doc/nuwiki.txt`).
//!
//! The user-facing commands funnel through `workspace/executeCommand`
//! into the Backend dispatcher, or — for follow/backlinks/rename — through
//! the standard LSP requests. The Backend itself owns a live `Client` and
//! can't be built in an integration test, so each case drives the *pure*
//! building-block the command relies on (the same functions the dispatcher
//! wraps). This file is organised to mirror the doc's command groups so a
//! reader can confirm every command has coverage.
use std::collections::HashMap;
use std::path::PathBuf;
use nuwiki_core::ast::{InlineNode, LinkKind, LinkTarget};
use nuwiki_core::date::DiaryDate;
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
use nuwiki_core::syntax::SyntaxPlugin;
use nuwiki_lsp::commands::{export_ops, ops, COMMANDS};
use nuwiki_lsp::config::WikiConfig;
use nuwiki_lsp::index::WorkspaceIndex;
use nuwiki_lsp::nav::find_inline_at;
use nuwiki_lsp::rename::{apply_links_space_char, build_new_uri, rewrite_wikilink_target};
use nuwiki_lsp::wiki::{build_wikis, resolve_uri_to_wiki};
use nuwiki_lsp::{diary, export};
use tower_lsp::lsp_types::Url;
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
VimwikiSyntax::new().parse(src)
}
fn wiki_cfg(root: &str) -> WikiConfig {
WikiConfig::from_root(PathBuf::from(root))
}
/// Build a workspace index rooted at `root` with the given `name -> source`
/// pages, mirroring the helper used by the other command suites.
fn build_index(root: &str, pages: &[(&str, &str)]) -> WorkspaceIndex {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from(root)));
for (name, src) in pages {
let uri = Url::from_file_path(format!("{root}/{name}.wiki")).unwrap();
idx.upsert(uri, &parse(src));
}
idx
}
fn diary_index(root: &str, dates: &[&str]) -> WorkspaceIndex {
let mut idx =
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
for d in dates {
let uri = Url::from_file_path(format!("{root}/diary/{d}.wiki")).unwrap();
idx.upsert(uri, &parse("= entry =\n"));
}
idx
}
// =====================================================================
// Group 1: Wiki / navigation
// =====================================================================
// :NuwikiIndex — open wiki N's index page.
#[test]
fn nuwiki_index_resolves_index_page() {
let idx = build_index("/wiki", &[("index", "= Home =\n"), ("Other", "= O =\n")]);
let page = idx.page_by_name("index").expect("index page indexed");
assert!(page.uri.as_str().ends_with("/wiki/index.wiki"));
}
// :NuwikiTabIndex — same target as :NuwikiIndex, opened in a new tab.
// The tab-vs-current split is an editor concern; the resolved target is
// identical, and both verbs are advertised as executeCommands.
#[test]
fn nuwiki_tab_index_advertised_alongside_index() {
assert!(COMMANDS.contains(&"nuwiki.wiki.openIndex"));
assert!(COMMANDS.contains(&"nuwiki.wiki.tabOpenIndex"));
}
// :NuwikiUISelect — pick a wiki from the configured list.
#[test]
fn nuwiki_ui_select_lists_configured_wikis() {
let cfgs = vec![wiki_cfg("/a/personal"), wiki_cfg("/b/work")];
let wikis = build_wikis(&cfgs);
assert_eq!(wikis.len(), 2);
assert_eq!(wikis[0].config.name, "personal");
assert_eq!(wikis[1].config.name, "work");
// Selection by URI picks the wiki that actually owns the file.
let uri = Url::from_file_path("/b/work/Page.wiki").unwrap();
assert_eq!(resolve_uri_to_wiki(&wikis, &uri), Some(wikis[1].id));
}
// :NuwikiGoto {page} — open `{page}.wiki` by name.
#[test]
fn nuwiki_goto_opens_page_by_name() {
let idx = build_index("/wiki", &[("index", "= H =\n"), ("Recipes", "= R =\n")]);
let page = idx
.page_by_name("Recipes")
.expect("page resolvable by name");
assert!(page.uri.as_str().ends_with("/wiki/Recipes.wiki"));
assert!(idx.page_by_name("Missing").is_none());
}
// :NuwikiFollowLink — follow the link under the cursor.
#[test]
fn nuwiki_follow_link_resolves_target_under_cursor() {
let idx = build_index(
"/wiki",
&[("Home", "see [[About]]\n"), ("About", "= A =\n")],
);
let doc = parse("see [[About]]\n");
// Byte col 8 sits inside "[[About]]" (starts at byte 4).
let node = find_inline_at(&doc, 0, 8).expect("wikilink under cursor");
let InlineNode::WikiLink(w) = node else {
panic!("expected a wikilink, got {node:?}");
};
let about = Url::from_file_path("/wiki/About.wiki").unwrap();
assert_eq!(idx.resolve(&w.target, "Home"), Some(&about));
}
// :NuwikiBacklinks — every reference to the current page.
#[test]
fn nuwiki_backlinks_lists_referrers() {
let idx = build_index(
"/wiki",
&[
("Home", "[[About]]\n"),
("Contact", "see [[About]] too\n"),
("About", "= A =\n"),
],
);
let back = idx.backlinks_for("About");
assert_eq!(back.len(), 2);
assert!(idx.backlinks_for("Home").is_empty());
}
// :NuwikiNextLink / :NuwikiPrevLink — jump to the next / previous wikilink.
// The jump itself is editor-side cursor movement; what the server provides
// is the ordered set of links on the page.
#[test]
fn nuwiki_next_prev_link_walk_links_in_document_order() {
let doc = parse("intro [[First]] mid [[Second]] end [[Third]]\n");
let links = ops::collect_wiki_links(&doc);
let targets: Vec<&str> = links
.iter()
.filter_map(|l| l.target.path.as_deref())
.collect();
assert_eq!(targets, vec!["First", "Second", "Third"]);
}
// :NuwikiBaddLink — add the target of the link under the cursor to the
// buffer list. The editor :badds whatever URI the link resolves to.
#[test]
fn nuwiki_badd_link_resolves_target_uri() {
let idx = build_index(
"/wiki",
&[("Home", "[[Notes/Todo]]\n"), ("Notes/Todo", "= T =\n")],
);
let target = LinkTarget {
kind: LinkKind::Wiki,
path: Some("Notes/Todo".into()),
..Default::default()
};
let expected = Url::from_file_path("/wiki/Notes/Todo.wiki").unwrap();
assert_eq!(idx.resolve(&target, "Home"), Some(&expected));
}
// :NuwikiRenameFile — rename the page and rewrite every inbound link.
#[test]
fn nuwiki_rename_file_builds_uri_and_rewrites_links() {
let new_uri = build_new_uri(&PathBuf::from("/wiki"), "New Name", ".wiki").unwrap();
assert!(new_uri.as_str().ends_with("/wiki/New%20Name.wiki"));
assert_eq!(
rewrite_wikilink_target("[[Old Name|caption]]", "New Name"),
Some("[[New Name|caption]]".to_string())
);
}
// links_space_char rewrites spaces in the link target / on-disk path while
// the default of a single space keeps the name verbatim.
#[test]
fn links_space_char_replaces_spaces_in_target_path() {
// Default " " — identity, spaces preserved.
assert_eq!(apply_links_space_char("My Page", " "), "My Page");
// Custom char — spaces become the configured glyph.
assert_eq!(apply_links_space_char("My Page", "_"), "My_Page");
// Subdir separators are untouched; only spaces within segments change.
assert_eq!(
apply_links_space_char("sub dir/My Page", "_"),
"sub_dir/My_Page"
);
// The transformed name flows into both the new URI and the rewritten link.
let renamed = apply_links_space_char("New Page", "-");
let new_uri = build_new_uri(&PathBuf::from("/wiki"), &renamed, ".wiki").unwrap();
assert!(new_uri.as_str().ends_with("/wiki/New-Page.wiki"));
assert_eq!(
rewrite_wikilink_target("[[Old Name|caption]]", &renamed),
Some("[[New-Page|caption]]".to_string())
);
}
// :NuwikiDeleteFile — delete the current page and its on-disk file.
#[test]
fn nuwiki_delete_file_emits_delete_workspace_edit() {
use tower_lsp::lsp_types::{DocumentChangeOperation, DocumentChanges, ResourceOp};
let uri = Url::parse("file:///wiki/Doomed.wiki").unwrap();
let mut b = nuwiki_lsp::edits::WorkspaceEditBuilder::new();
b.file_op(nuwiki_lsp::edits::op_delete(uri.clone()));
let we = b.build();
let DocumentChanges::Operations(ops) = we.document_changes.unwrap() else {
panic!("expected resource operations");
};
assert!(matches!(
&ops[0],
DocumentChangeOperation::Op(ResourceOp::Delete(d)) if d.uri == uri
));
}
// =====================================================================
// Group 2: Diary
// =====================================================================
// :NuwikiMakeDiaryNote — open today's entry.
#[test]
fn nuwiki_make_diary_note_targets_todays_file() {
let cfg = wiki_cfg("/wiki");
let today = DiaryDate::from_ymd(2026, 5, 12).unwrap();
let uri = diary::uri_for_date(&cfg, &today).unwrap();
assert!(uri.as_str().ends_with("/diary/2026-05-12.wiki"));
}
// :NuwikiMakeYesterdayDiaryNote / :NuwikiMakeTomorrowDiaryNote — step the
// diary back / forward by one period at the daily cadence.
#[test]
fn nuwiki_yesterday_and_tomorrow_target_adjacent_files() {
let cfg = wiki_cfg("/wiki");
let yesterday = DiaryDate::from_ymd(2026, 5, 11).unwrap();
let tomorrow = DiaryDate::from_ymd(2026, 5, 13).unwrap();
assert!(diary::uri_for_date(&cfg, &yesterday)
.unwrap()
.as_str()
.ends_with("/diary/2026-05-11.wiki"));
assert!(diary::uri_for_date(&cfg, &tomorrow)
.unwrap()
.as_str()
.ends_with("/diary/2026-05-13.wiki"));
}
// :NuwikiDiaryIndex — open the diary index page.
#[test]
fn nuwiki_diary_index_resolves_index_uri() {
let cfg = wiki_cfg("/wiki");
let uri = diary::index_uri(&cfg).unwrap();
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
}
// :NuwikiDiaryGenerateLinks — rebuild the diary index from disk entries.
#[test]
fn nuwiki_diary_generate_links_builds_grouped_body() {
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-04-30"]);
let entries = diary::list_entries(&idx);
let body = diary::build_index_body(
&entries,
"diary",
"Diary",
diary::DiarySort::Desc,
1,
&[],
0,
);
assert!(body.starts_with("= Diary ="));
assert!(body.contains("2026-05-12"));
assert!(body.contains("2026-05-10"));
assert!(body.contains("2026-04-30"));
// Newest entry appears before the older one within the same month.
let p12 = body.find("2026-05-12").unwrap();
let p10 = body.find("2026-05-10").unwrap();
assert!(p12 < p10, "expected newest-first ordering");
}
// :NuwikiDiaryNextDay / :NuwikiDiaryPrevDay — walk indexed entries.
#[test]
fn nuwiki_diary_next_and_prev_day_walk_entries() {
let idx = diary_index("/wiki", &["2026-05-10", "2026-05-12", "2026-05-15"]);
let from = DiaryDate::from_ymd(2026, 5, 12).unwrap();
assert_eq!(
diary::next_entry(&idx, &from).unwrap().date.format(),
"2026-05-15"
);
assert_eq!(
diary::prev_entry(&idx, &from).unwrap().date.format(),
"2026-05-10"
);
}
// =====================================================================
// Group 3: Page generation
// =====================================================================
// :NuwikiTOC — generate / refresh the table of contents.
#[test]
fn nuwiki_toc_generates_nested_contents() {
let src = "= Top =\n== Sub ==\ntext\n";
let doc = parse(src);
let uri = Url::from_file_path("/wiki/Page.wiki").unwrap();
let edit =
ops::toc_edit(src, &doc, &uri, true, "Contents", 1, 0, 0, None).expect("toc edit produced");
assert!(edit.changes.is_some() || edit.document_changes.is_some());
let toc = ops::build_toc_text(
&[
(1, "Top".into(), "top".into()),
(2, "Sub".into(), "sub".into()),
],
"Contents",
1,
0,
0,
);
assert!(toc.contains("= Contents ="));
assert!(toc.contains("- [[#top|Top]]"));
assert!(toc.contains(" - [[#sub|Sub]]"));
}
// :NuwikiGenerateLinks — insert a flat list of every page in the wiki.
#[test]
fn nuwiki_generate_links_lists_all_pages_excluding_current() {
let pages = vec!["About".to_string(), "Home".to_string(), "Notes".to_string()];
let text = ops::build_links_text(&pages, "Links", 1, Some("Home"), 0, None);
assert!(text.contains("= Links ="));
assert!(text.contains("- [[About]]"));
assert!(text.contains("- [[Notes]]"));
assert!(!text.contains("[[Home]]"), "current page excluded");
let src = "= Existing =\n";
let doc = parse(src);
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
assert!(ops::links_edit(
src,
&doc,
&uri,
"Home",
&pages,
true,
"Generated Links",
1,
0,
None
)
.is_some());
}
// :NuwikiCheckLinks — surface broken links across the workspace.
#[test]
fn nuwiki_check_links_distinguishes_broken_from_resolvable() {
let idx = build_index(
"/wiki",
&[("Home", "[[About]] [[Ghost]]\n"), ("About", "= A =\n")],
);
let good = LinkTarget {
kind: LinkKind::Wiki,
path: Some("About".into()),
..Default::default()
};
let broken = LinkTarget {
kind: LinkKind::Wiki,
path: Some("Ghost".into()),
..Default::default()
};
assert!(idx.resolve(&good, "Home").is_some());
assert!(
idx.resolve(&broken, "Home").is_none(),
"broken link unresolved"
);
}
// =====================================================================
// Group 4: Tags
// =====================================================================
// :NuwikiSearchTags {tag} — every `:tag:` occurrence.
#[test]
fn nuwiki_search_tags_finds_occurrences() {
let idx = build_index(
"/wiki",
&[("Home", ":alpha:beta:\n"), ("Work", ":alpha:\n")],
);
let hits = ops::tag_hits(&idx, "alpha");
assert_eq!(hits.len(), 2);
assert!(hits.iter().all(|h| h.name == "alpha"));
assert!(ops::tag_hits(&idx, "nonexistent").is_empty());
}
// :NuwikiGenerateTagLinks [tag] — section linking every page with a tag.
#[test]
fn nuwiki_generate_tag_links_builds_section() {
let idx = build_index(
"/wiki",
&[
("Home", ":alpha:\n"),
("Work", ":alpha:\n"),
("Misc", ":beta:\n"),
],
);
let by_tag = ops::tag_pages_snapshot(&idx);
let single = ops::build_tag_links_text(&by_tag, Some("alpha"), "Generated Tags", 1, 0).unwrap();
assert!(single.starts_with("= Tag: alpha ="));
assert!(single.contains("- [[Home]]"));
assert!(single.contains("- [[Work]]"));
// No-arg variant emits a section per tag.
let all = ops::build_tag_links_text(&by_tag, None, "Generated Tags", 1, 0).unwrap();
assert!(all.starts_with("= Generated Tags ="));
assert!(all.contains("== alpha =="));
assert!(all.contains("== beta =="));
let src = "= Home =\n";
let doc = parse(src);
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
assert!(ops::tag_links_edit(
src,
&doc,
&uri,
Some("alpha"),
&by_tag,
true,
"Generated Tags",
1,
0
)
.is_some());
}
// :NuwikiRebuildTags — force a full workspace re-index. A rebuild must
// reflect the current on-disk tags, dropping stale ones.
#[test]
fn nuwiki_rebuild_tags_reflects_current_state() {
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/wiki")));
let uri = Url::from_file_path("/wiki/Home.wiki").unwrap();
idx.upsert(uri.clone(), &parse(":old:\n"));
assert_eq!(idx.tags_for("old").len(), 1);
// Re-index after the page changed on disk.
idx.upsert(uri, &parse(":new:\n"));
assert!(idx.tags_for("old").is_empty(), "stale tag dropped");
assert_eq!(idx.tags_for("new").len(), 1);
}
// =====================================================================
// Group 5: HTML export
// =====================================================================
// :Nuwiki2HTML — render the current page to HTML.
#[test]
fn nuwiki_2html_renders_page() {
let cfg = wiki_cfg("/wiki");
let doc = parse("%title My Page\n= One =\nbody text\n");
let html = export::render_page_html(
&doc,
Some("<title>{{title}}</title><main>{{content}}</main>".into()),
"Home",
DiaryDate::from_ymd(2026, 5, 12).unwrap(),
&cfg.html,
Some(".wiki"),
HashMap::new(),
)
.unwrap();
assert!(html.contains("<title>My Page</title>"));
assert!(html.contains("body text"));
}
// :Nuwiki2HTMLBrowse — render then open in the browser. The render output
// is identical to :Nuwiki2HTML; the browse step is an editor-side open.
#[test]
fn nuwiki_2html_browse_shares_render_and_is_advertised() {
assert!(COMMANDS.contains(&"nuwiki.export.browse"));
let cfg = wiki_cfg("/wiki");
let doc = parse("= Page =\nhi\n");
let html = export::render_page_html(
&doc,
Some("{{content}}".into()),
"Page",
DiaryDate::today_utc(),
&cfg.html,
Some(".wiki"),
HashMap::new(),
)
.unwrap();
assert!(html.contains("hi"));
}
// :NuwikiAll2HTML[!] — export every page; honour the exclude list and
// per-page output paths.
#[test]
fn nuwiki_all2html_maps_output_paths_and_excludes() {
let cfg = wiki_cfg("/wiki");
assert!(export::output_path_for(&cfg.html, "Home").ends_with("Home.html"));
assert!(
export::output_path_for(&cfg.html, "diary/2026-05-12").ends_with("diary/2026-05-12.html")
);
let patterns = vec!["_drafts/**".to_string()];
assert!(export::is_excluded(&patterns, "_drafts/secret"));
assert!(!export::is_excluded(&patterns, "Home"));
// Both incremental and forced (`!`) variants are advertised.
assert!(COMMANDS.contains(&"nuwiki.export.allToHtml"));
assert!(COMMANDS.contains(&"nuwiki.export.allToHtmlForce"));
}
// :NuwikiRss — write `rss.xml` summarising recent diary entries.
#[test]
fn nuwiki_rss_writes_feed_file() {
let root = std::env::temp_dir().join(format!("nuwiki-cov-rss-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&root);
let cfg = WikiConfig::from_root(root.clone());
let entries = vec![
diary::DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 10).unwrap(),
uri: Url::from_file_path(root.join("diary/2026-05-10.wiki")).unwrap(),
},
diary::DiaryEntry {
date: DiaryDate::from_ymd(2026, 5, 12).unwrap(),
uri: Url::from_file_path(root.join("diary/2026-05-12.wiki")).unwrap(),
},
];
let path = export_ops::write_rss(&cfg, &entries).unwrap();
assert!(path.ends_with("rss.xml"));
let xml = std::fs::read_to_string(&path).unwrap();
assert!(xml.contains("<rss version=\"2.0\""));
// Newest entry listed first.
let p12 = xml.find("2026-05-12").unwrap();
let p10 = xml.find("2026-05-10").unwrap();
assert!(p12 < p10);
let _ = std::fs::remove_dir_all(&root);
}
// =====================================================================
// Group 6: Other
// =====================================================================
// :NuwikiInstall — install/re-install the `nuwiki-ls` binary. This is an
// editor-side action (binary download / cargo build); it is deliberately
// NOT an LSP executeCommand, so the server must not advertise it.
#[test]
fn nuwiki_install_is_editor_only_not_an_lsp_command() {
assert!(!COMMANDS.iter().any(|c| c.contains("install")));
}
// =====================================================================
// Command-surface cross-check: every documented command that maps to an
// executeCommand handler is actually advertised by the server.
// =====================================================================
#[test]
fn every_documented_lsp_command_is_advertised() {
let expected = [
"nuwiki.wiki.openIndex", // :NuwikiIndex
"nuwiki.wiki.tabOpenIndex", // :NuwikiTabIndex
"nuwiki.wiki.listAll", // :NuwikiUISelect
"nuwiki.wiki.gotoPage", // :NuwikiGoto
"nuwiki.file.delete", // :NuwikiDeleteFile
"nuwiki.diary.openToday", // :NuwikiMakeDiaryNote
"nuwiki.diary.openYesterday", // :NuwikiMakeYesterdayDiaryNote
"nuwiki.diary.openTomorrow", // :NuwikiMakeTomorrowDiaryNote
"nuwiki.diary.openIndex", // :NuwikiDiaryIndex
"nuwiki.diary.generateIndex", // :NuwikiDiaryGenerateLinks
"nuwiki.diary.next", // :NuwikiDiaryNextDay
"nuwiki.diary.prev", // :NuwikiDiaryPrevDay
"nuwiki.toc.generate", // :NuwikiTOC
"nuwiki.links.generate", // :NuwikiGenerateLinks
"nuwiki.workspace.checkLinks", // :NuwikiCheckLinks
"nuwiki.tags.search", // :NuwikiSearchTags
"nuwiki.tags.generateLinks", // :NuwikiGenerateTagLinks
"nuwiki.tags.rebuild", // :NuwikiRebuildTags
"nuwiki.export.currentToHtml", // :Nuwiki2HTML
"nuwiki.export.browse", // :Nuwiki2HTMLBrowse
"nuwiki.export.allToHtml", // :NuwikiAll2HTML
"nuwiki.export.rss", // :NuwikiRss
];
for cmd in expected {
assert!(COMMANDS.contains(&cmd), "server must advertise {cmd}");
}
}