Docs #1
@@ -478,6 +478,13 @@ endfunction
|
||||
function! nuwiki#commands#cycle_list_item() abort
|
||||
call s:exec_pos('nuwiki.list.cycleCheckbox')
|
||||
endfunction
|
||||
" `glp` cycles backward — same command, `reverse` flag in the payload.
|
||||
function! nuwiki#commands#cycle_list_item_back() abort
|
||||
let l:p = s:cursor_position()
|
||||
let l:args = [{ 'uri': l:p['textDocument']['uri'],
|
||||
\ 'position': l:p['position'], 'reverse': v:true }]
|
||||
call s:exec('nuwiki.list.cycleCheckbox', l:args)
|
||||
endfunction
|
||||
function! nuwiki#commands#reject_list_item() abort
|
||||
call s:exec_pos('nuwiki.list.rejectCheckbox')
|
||||
endfunction
|
||||
@@ -631,7 +638,16 @@ endfunction
|
||||
|
||||
" §13.1 Cluster A — list rewriters.
|
||||
|
||||
" `gl<Space>` — remove done items from the current list only. Passing the
|
||||
" cursor position scopes the server to the contiguous list block under it.
|
||||
function! nuwiki#commands#list_remove_done() abort
|
||||
let l:p = s:cursor_position()
|
||||
let l:args = [{ 'uri': l:p['textDocument']['uri'], 'position': l:p['position'] }]
|
||||
call s:exec('nuwiki.list.removeDone', l:args)
|
||||
endfunction
|
||||
|
||||
" `gL<Space>` — remove done items across the whole buffer (no position).
|
||||
function! nuwiki#commands#list_remove_done_all() abort
|
||||
let l:args = [{ 'uri': s:buf_uri() }]
|
||||
call s:exec('nuwiki.list.removeDone', l:args)
|
||||
endfunction
|
||||
|
||||
@@ -87,7 +87,17 @@ pub(crate) async fn execute(
|
||||
Ok(list_checkbox(backend, args, ops::toggle_state)?.map(CommandOutcome::Edit))
|
||||
}
|
||||
"nuwiki.list.cycleCheckbox" => {
|
||||
Ok(list_checkbox(backend, args, ops::cycle_state)?.map(CommandOutcome::Edit))
|
||||
let reverse = args
|
||||
.first()
|
||||
.and_then(|v| v.get("reverse"))
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
let mutate = if reverse {
|
||||
ops::cycle_state_back
|
||||
} else {
|
||||
ops::cycle_state
|
||||
};
|
||||
Ok(list_checkbox(backend, args, mutate)?.map(CommandOutcome::Edit))
|
||||
}
|
||||
"nuwiki.list.rejectCheckbox" => {
|
||||
Ok(list_checkbox(backend, args, ops::reject_state)?.map(CommandOutcome::Edit))
|
||||
@@ -1233,6 +1243,20 @@ pub mod ops {
|
||||
}
|
||||
}
|
||||
|
||||
/// Exact inverse of [`cycle_state`] — used by the `glp` mapping
|
||||
/// ("cycle the checkbox state backward").
|
||||
pub fn cycle_state_back(current: &str) -> Option<&'static str> {
|
||||
match current {
|
||||
"[ ]" => Some("[X]"),
|
||||
"[X]" => Some("[O]"),
|
||||
"[O]" => Some("[o]"),
|
||||
"[o]" => Some("[.]"),
|
||||
"[.]" => Some("[ ]"),
|
||||
"[-]" => Some("[ ]"),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Toggle the `[-]` "rejected" state.
|
||||
pub fn reject_state(current: &str) -> Option<&'static str> {
|
||||
match current {
|
||||
@@ -2095,7 +2119,9 @@ pub mod ops {
|
||||
|
||||
/// Delete every checkbox item whose state is `Done` or `Rejected`,
|
||||
/// cascading into their sublists. When `pos` is `Some`, restrict to
|
||||
/// the list containing `pos`'s line; otherwise sweep the whole doc.
|
||||
/// the contiguous list block containing `pos`'s line (the "current
|
||||
/// list" — including its nested sublists); otherwise sweep the whole
|
||||
/// doc.
|
||||
pub fn remove_done_edit(
|
||||
text: &str,
|
||||
ast: &DocumentNode,
|
||||
@@ -2103,16 +2129,8 @@ pub mod ops {
|
||||
pos: Option<LspPosition>,
|
||||
utf8: bool,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
let restrict_line = pos.map(|p| p.line);
|
||||
let mut victims: Vec<Span> = Vec::new();
|
||||
walk_list_items(&ast.children, &mut |item| {
|
||||
let matches_scope = match restrict_line {
|
||||
None => true,
|
||||
Some(line) => (item.span.start.line..=item.span.end.line).contains(&line),
|
||||
};
|
||||
if !matches_scope {
|
||||
return;
|
||||
}
|
||||
let mut collect = |item: &ListItemNode| {
|
||||
if matches!(
|
||||
item.checkbox,
|
||||
Some(nuwiki_core::ast::CheckboxState::Done)
|
||||
@@ -2120,7 +2138,19 @@ pub mod ops {
|
||||
) {
|
||||
victims.push(extend_to_line_end(text, item.span));
|
||||
}
|
||||
});
|
||||
};
|
||||
match pos {
|
||||
None => walk_list_items(&ast.children, &mut collect),
|
||||
Some(p) => {
|
||||
// "Current list" = the top-level contiguous list block the
|
||||
// cursor sits in. Walk just that block (and its sublists),
|
||||
// leaving done items in sibling lists untouched.
|
||||
let list = find_top_list_at_line(ast, p.line)?;
|
||||
for item in &list.items {
|
||||
walk_list_items_in_item(item, &mut collect);
|
||||
}
|
||||
}
|
||||
}
|
||||
if victims.is_empty() {
|
||||
return None;
|
||||
}
|
||||
@@ -2131,6 +2161,40 @@ pub mod ops {
|
||||
Some(b.build())
|
||||
}
|
||||
|
||||
/// Find the top-level (contiguous) list block whose span contains
|
||||
/// `line`. Unlike [`find_list_at_line`], this does not descend into
|
||||
/// sublists — it returns the outermost list so callers operate on the
|
||||
/// whole list block the cursor belongs to.
|
||||
fn find_top_list_at_line(ast: &DocumentNode, line: u32) -> Option<&ListNode> {
|
||||
for block in &ast.children {
|
||||
if let Some(list) = find_top_list_in_block(block, line) {
|
||||
return Some(list);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn find_top_list_in_block(block: &BlockNode, line: u32) -> Option<&ListNode> {
|
||||
match block {
|
||||
BlockNode::List(list) => {
|
||||
if (list.span.start.line..=list.span.end.line).contains(&line) {
|
||||
Some(list)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
|
||||
for c in children {
|
||||
if let Some(l) = find_top_list_in_block(c, line) {
|
||||
return Some(l);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Extend a span to include the trailing newline (so deletion removes
|
||||
/// the empty line that would otherwise be left behind).
|
||||
fn extend_to_line_end(text: &str, span: Span) -> Span {
|
||||
|
||||
@@ -47,6 +47,24 @@ fn cycle_state_walks_progression() {
|
||||
assert_eq!(ops::cycle_state("[-]"), Some("[ ]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cycle_state_back_is_exact_inverse() {
|
||||
// `glp` walks the progression in reverse — every step must undo the
|
||||
// matching `cycle_state` step.
|
||||
assert_eq!(ops::cycle_state_back("[ ]"), Some("[X]"));
|
||||
assert_eq!(ops::cycle_state_back("[X]"), Some("[O]"));
|
||||
assert_eq!(ops::cycle_state_back("[O]"), Some("[o]"));
|
||||
assert_eq!(ops::cycle_state_back("[o]"), Some("[.]"));
|
||||
assert_eq!(ops::cycle_state_back("[.]"), Some("[ ]"));
|
||||
assert_eq!(ops::cycle_state_back("[-]"), Some("[ ]"));
|
||||
// Composing forward then backward returns to the start for every
|
||||
// in-cycle marker.
|
||||
for s in ["[ ]", "[.]", "[o]", "[O]", "[X]"] {
|
||||
let fwd = ops::cycle_state(s).unwrap();
|
||||
assert_eq!(ops::cycle_state_back(fwd), Some(s));
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reject_state_toggles_dash_marker() {
|
||||
assert_eq!(ops::reject_state("[ ]"), Some("[-]"));
|
||||
|
||||
@@ -0,0 +1,532 @@
|
||||
//! Command-coverage suite: at least one behavioural test for every
|
||||
//! documented `:Nuwiki*` / `:Vimwiki*` command (see `doc/nuwiki.txt`
|
||||
//! §5 COMMANDS).
|
||||
//!
|
||||
//! 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::{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())
|
||||
);
|
||||
}
|
||||
|
||||
// :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");
|
||||
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).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",
|
||||
);
|
||||
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", Some("Home"));
|
||||
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).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")).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).unwrap();
|
||||
assert!(all.starts_with("= 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).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}");
|
||||
}
|
||||
}
|
||||
@@ -86,10 +86,10 @@ fn remove_done_returns_none_when_no_lists() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_scoped_by_position_to_one_item() {
|
||||
// With `position` set, only items whose line range contains the
|
||||
// cursor line are considered. So a done item on line 1 is removed
|
||||
// by cursor on line 1, but a done item on line 3 is left alone.
|
||||
fn remove_done_scoped_by_position_to_current_list() {
|
||||
// With `position` set, the scope is the contiguous list block the
|
||||
// cursor sits in — every done item in that list is removed, not just
|
||||
// the one under the cursor.
|
||||
let src = "- [X] done one\n- [ ] todo\n- [X] done two\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
@@ -98,7 +98,37 @@ fn remove_done_scoped_by_position_to_one_item() {
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "only the line-0 item should match");
|
||||
assert_eq!(edits.len(), 2, "both done items in the current list match");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_position_leaves_other_lists_untouched() {
|
||||
// Two separate list blocks split by a paragraph. Cursor in the first
|
||||
// list removes only that list's done items; the second list's done
|
||||
// item survives.
|
||||
let src = "- [X] done a\n- [ ] todo a\n\nparagraph\n\n- [ ] todo b\n- [X] done b\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "only the first list's done item is removed");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_done_position_cascades_into_sublists() {
|
||||
// The current-list scope includes nested sublists of the block.
|
||||
let src = "- [ ] parent\n - [X] done child\n - [ ] open child\n";
|
||||
let ast = parse(src);
|
||||
let pos = Some(LspPosition {
|
||||
line: 0,
|
||||
character: 0,
|
||||
});
|
||||
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1, "the nested done child is removed");
|
||||
}
|
||||
|
||||
// ===== renumber =====
|
||||
|
||||
+32
-3
@@ -115,6 +115,21 @@ if !has('nvim')
|
||||
command! -buffer -nargs=? NuwikiGenerateTagLinks call nuwiki#commands#tags_generate_links(<q-args>)
|
||||
command! -buffer -nargs=1 NuwikiGoto call nuwiki#commands#wiki_goto_page(<q-args>)
|
||||
|
||||
" Canonical :Nuwiki* names that mirror the documented surface (doc §5).
|
||||
" The older :Nuwiki* spellings above stay defined for back-compat.
|
||||
command! -buffer NuwikiNextLink call nuwiki#commands#link_next()
|
||||
command! -buffer NuwikiPrevLink call nuwiki#commands#link_prev()
|
||||
command! -buffer NuwikiBaddLink call nuwiki#commands#badd_link()
|
||||
command! -buffer NuwikiMakeDiaryNote call nuwiki#commands#diary_today()
|
||||
command! -buffer NuwikiMakeYesterdayDiaryNote call nuwiki#commands#diary_yesterday()
|
||||
command! -buffer NuwikiMakeTomorrowDiaryNote call nuwiki#commands#diary_tomorrow()
|
||||
command! -buffer NuwikiDiaryGenerateLinks call nuwiki#commands#diary_generate_index()
|
||||
command! -buffer NuwikiDiaryNextDay call nuwiki#commands#diary_next()
|
||||
command! -buffer NuwikiDiaryPrevDay call nuwiki#commands#diary_prev()
|
||||
command! -buffer Nuwiki2HTML call nuwiki#commands#export_current()
|
||||
command! -buffer Nuwiki2HTMLBrowse call nuwiki#commands#export_browse()
|
||||
command! -buffer -bang NuwikiAll2HTML if <bang>0 | call nuwiki#commands#export_all_force() | else | call nuwiki#commands#export_all() | endif
|
||||
|
||||
" ===== Default key mappings (parity with upstream vimwiki) =====
|
||||
"
|
||||
" Users can opt out by setting `g:nuwiki_no_default_mappings = 1`
|
||||
@@ -162,8 +177,8 @@ if !has('nvim')
|
||||
nnoremap <silent><buffer> gnt :call nuwiki#commands#next_task()<CR>
|
||||
nnoremap <silent><buffer> gln :call nuwiki#commands#cycle_list_item()<CR>
|
||||
xnoremap <silent><buffer> gln :<C-u>call nuwiki#commands#cycle_list_item()<CR>
|
||||
nnoremap <silent><buffer> glp :call nuwiki#commands#cycle_list_item()<CR>
|
||||
xnoremap <silent><buffer> glp :<C-u>call nuwiki#commands#cycle_list_item()<CR>
|
||||
nnoremap <silent><buffer> glp :call nuwiki#commands#cycle_list_item_back()<CR>
|
||||
xnoremap <silent><buffer> glp :<C-u>call nuwiki#commands#cycle_list_item_back()<CR>
|
||||
nnoremap <silent><buffer> glx :call nuwiki#commands#reject_list_item()<CR>
|
||||
xnoremap <silent><buffer> glx :<C-u>call nuwiki#commands#reject_list_item()<CR>
|
||||
nnoremap <silent><buffer> glh :call nuwiki#commands#list_change_level(-1, 0)<CR>
|
||||
@@ -173,7 +188,7 @@ if !has('nvim')
|
||||
nnoremap <silent><buffer> glr :call nuwiki#commands#list_renumber()<CR>
|
||||
nnoremap <silent><buffer> gLr :call nuwiki#commands#list_renumber_all()<CR>
|
||||
nnoremap <silent><buffer> gl<Space> :call nuwiki#commands#list_remove_done()<CR>
|
||||
nnoremap <silent><buffer> gL<Space> :call nuwiki#commands#list_remove_done()<CR>
|
||||
nnoremap <silent><buffer> gL<Space> :call nuwiki#commands#list_remove_done_all()<CR>
|
||||
nnoremap <silent><buffer> o :call nuwiki#commands#open_below_with_bullet()<CR>
|
||||
nnoremap <silent><buffer> O :call nuwiki#commands#open_above_with_bullet()<CR>
|
||||
|
||||
@@ -355,5 +370,19 @@ command! -buffer -nargs=? NuwikiSearchTags lua require('nuwiki.commands'
|
||||
command! -buffer -nargs=? NuwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links(<q-args>)
|
||||
command! -buffer -nargs=1 NuwikiGoto lua require('nuwiki.commands').wiki_goto_page(<q-args>)
|
||||
|
||||
" Canonical :Nuwiki* names that mirror the documented surface (doc §5).
|
||||
" The older :Nuwiki* spellings above stay defined for back-compat.
|
||||
command! -buffer NuwikiNextLink lua require('nuwiki.commands').link_next()
|
||||
command! -buffer NuwikiPrevLink lua require('nuwiki.commands').link_prev()
|
||||
command! -buffer NuwikiBaddLink lua require('nuwiki.commands').badd_link()
|
||||
command! -buffer NuwikiMakeDiaryNote lua require('nuwiki.commands').diary_today()
|
||||
command! -buffer NuwikiMakeYesterdayDiaryNote lua require('nuwiki.commands').diary_yesterday()
|
||||
command! -buffer NuwikiMakeTomorrowDiaryNote lua require('nuwiki.commands').diary_tomorrow()
|
||||
command! -buffer NuwikiDiaryNextDay lua require('nuwiki.commands').diary_next()
|
||||
command! -buffer NuwikiDiaryPrevDay lua require('nuwiki.commands').diary_prev()
|
||||
command! -buffer Nuwiki2HTML lua require('nuwiki.commands').export_current()
|
||||
command! -buffer Nuwiki2HTMLBrowse lua require('nuwiki.commands').export_browse()
|
||||
command! -buffer -bang NuwikiAll2HTML execute (<bang>0 ? "lua require('nuwiki.commands').export_all_force()" : "lua require('nuwiki.commands').export_all()")
|
||||
|
||||
" Phase 19 buffer attach: keymaps + text objects + folding.
|
||||
lua require('nuwiki.ftplugin').attach(0)
|
||||
|
||||
@@ -323,6 +323,12 @@ end
|
||||
|
||||
M.toggle_list_item = _exec_pos('nuwiki.list.toggleCheckbox')
|
||||
M.cycle_list_item = _exec_pos('nuwiki.list.cycleCheckbox')
|
||||
-- `glp` cycles backward — same command, `reverse = true` in the payload.
|
||||
function M.cycle_list_item_back()
|
||||
local a = pos_args()
|
||||
a[1].reverse = true
|
||||
exec('nuwiki.list.cycleCheckbox', a)
|
||||
end
|
||||
M.reject_list_item = _exec_pos('nuwiki.list.rejectCheckbox')
|
||||
M.heading_add_level = _exec_pos('nuwiki.heading.addLevel')
|
||||
M.heading_remove_level = _exec_pos('nuwiki.heading.removeLevel')
|
||||
@@ -479,7 +485,14 @@ end
|
||||
|
||||
-- §13.1 Cluster A — list rewriters.
|
||||
|
||||
-- `gl<Space>` — remove done items from the current list only. Passing the
|
||||
-- cursor position scopes the server to the contiguous list block under it.
|
||||
function M.list_remove_done()
|
||||
exec('nuwiki.list.removeDone', pos_args())
|
||||
end
|
||||
|
||||
-- `gL<Space>` — remove done items across the whole buffer (no position).
|
||||
function M.list_remove_done_all()
|
||||
exec('nuwiki.list.removeDone', uri_args())
|
||||
end
|
||||
|
||||
|
||||
@@ -218,8 +218,8 @@ function M.attach(bufnr, mappings)
|
||||
map('n', 'gnt', cmd.next_task, { desc = 'nuwiki: next unfinished task' }, bufnr)
|
||||
map('n', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr)
|
||||
map('x', 'gln', cmd.cycle_list_item, { desc = 'nuwiki: increment checkbox' }, bufnr)
|
||||
map('n', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: decrement checkbox' }, bufnr)
|
||||
map('x', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: decrement checkbox' }, bufnr)
|
||||
map('n', 'glp', cmd.cycle_list_item_back, { desc = 'nuwiki: decrement checkbox' }, bufnr)
|
||||
map('x', 'glp', cmd.cycle_list_item_back, { desc = 'nuwiki: decrement checkbox' }, bufnr)
|
||||
map('n', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
|
||||
map('x', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
|
||||
map('n', 'glh', function() cmd.list_change_level(-1, false) end,
|
||||
@@ -235,8 +235,8 @@ function M.attach(bufnr, mappings)
|
||||
map('n', 'gLr', cmd.list_renumber_all,
|
||||
{ desc = 'nuwiki: renumber all lists' }, bufnr)
|
||||
map('n', 'gl<Space>', cmd.list_remove_done,
|
||||
{ desc = 'nuwiki: remove done items' }, bufnr)
|
||||
map('n', 'gL<Space>', cmd.list_remove_done,
|
||||
{ desc = 'nuwiki: remove done items (current list)' }, bufnr)
|
||||
map('n', 'gL<Space>', cmd.list_remove_done_all,
|
||||
{ desc = 'nuwiki: remove done items (whole doc)' }, bufnr)
|
||||
map('n', 'o', open_below_with_bullet, { desc = 'nuwiki: open below + bullet' }, bufnr)
|
||||
map('n', 'O', open_above_with_bullet, { desc = 'nuwiki: open above + bullet' }, bufnr)
|
||||
|
||||
@@ -33,6 +33,9 @@ VIMRC="$TMP/vimrc"
|
||||
cat > "$VIMRC" <<EOF
|
||||
set nocompatible
|
||||
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath
|
||||
" Enable the opt-in mouse mappings before the ftplugin loads so the
|
||||
" harness can assert the full documented mapping surface (doc §6 mouse).
|
||||
let g:nuwiki_mouse_mappings = 1
|
||||
filetype plugin indent on
|
||||
syntax enable
|
||||
" ftdetect/nuwiki.vim already maps *.wiki → vimwiki via \`set filetype\`.
|
||||
|
||||
@@ -95,6 +95,107 @@ else
|
||||
call s:record(0, 'cmd.NuwikiIndex_defined', ':NuwikiIndex not registered')
|
||||
endif
|
||||
|
||||
" ===== Documented command surface (doc/nuwiki.txt §5) =====
|
||||
" Every command must be reachable in BOTH the canonical :Nuwiki* form and
|
||||
" the vimwiki-compatible :Vimwiki* form. The suffixes below are the §5 list
|
||||
" minus the prefix. :NuwikiInstall is the one exception — it's a nuwiki-only
|
||||
" maintenance command with no :Vimwiki* alias, so it's checked separately.
|
||||
|
||||
let s:both_forms = [
|
||||
\ 'Index', 'TabIndex', 'UISelect', 'Goto', 'FollowLink', 'Backlinks',
|
||||
\ 'NextLink', 'PrevLink', 'BaddLink', 'RenameFile', 'DeleteFile',
|
||||
\ 'MakeDiaryNote', 'MakeYesterdayDiaryNote', 'MakeTomorrowDiaryNote',
|
||||
\ 'DiaryIndex', 'DiaryGenerateLinks', 'DiaryNextDay', 'DiaryPrevDay',
|
||||
\ 'TOC', 'GenerateLinks', 'CheckLinks', 'SearchTags', 'GenerateTagLinks',
|
||||
\ 'RebuildTags', '2HTML', '2HTMLBrowse', 'All2HTML', 'Rss',
|
||||
\ ]
|
||||
for s:suffix in s:both_forms
|
||||
for s:prefix in ['Nuwiki', 'Vimwiki']
|
||||
let s:cmd = s:prefix . s:suffix
|
||||
let s:ok = exists(':' . s:cmd) == 2
|
||||
call s:record(s:ok ? 1 : 0, 'surface.' . s:cmd,
|
||||
\ s:ok ? '' : ':' . s:cmd . ' not registered')
|
||||
endfor
|
||||
endfor
|
||||
let s:ok = exists(':NuwikiInstall') == 2
|
||||
call s:record(s:ok ? 1 : 0, 'surface.NuwikiInstall',
|
||||
\ s:ok ? '' : ':NuwikiInstall not registered')
|
||||
|
||||
" ===== Pure-VimL command invocation (cursor movement) =====
|
||||
" :NuwikiNextLink / :NuwikiPrevLink wrap search('\[\[', …) so they run
|
||||
" entirely in VimL and move the cursor without touching the LSP.
|
||||
|
||||
call s:set_buf(['intro [[A]] more [[B]] end'])
|
||||
call cursor(1, 1)
|
||||
silent NuwikiNextLink
|
||||
call s:record(
|
||||
\ col('.') == 7 ? 1 : 0,
|
||||
\ 'invoke.NuwikiNextLink_jumps_to_first_link',
|
||||
\ 'col=' . col('.'))
|
||||
silent NuwikiNextLink
|
||||
call s:record(
|
||||
\ col('.') == 18 ? 1 : 0,
|
||||
\ 'invoke.NuwikiNextLink_jumps_to_second_link',
|
||||
\ 'col=' . col('.'))
|
||||
silent NuwikiPrevLink
|
||||
call s:record(
|
||||
\ col('.') == 7 ? 1 : 0,
|
||||
\ 'invoke.NuwikiPrevLink_jumps_back',
|
||||
\ 'col=' . col('.'))
|
||||
|
||||
" The :Vimwiki* alias must drive the identical motion.
|
||||
call cursor(1, 1)
|
||||
silent VimwikiNextLink
|
||||
call s:record(
|
||||
\ col('.') == 7 ? 1 : 0,
|
||||
\ 'invoke.VimwikiNextLink_jumps_to_first_link',
|
||||
\ 'col=' . col('.'))
|
||||
|
||||
" ===== Documented mapping surface (doc §6/§7/§8) =====
|
||||
" Every documented mapping must resolve to a buffer-local mapping in the
|
||||
" mode(s) the doc lists it under. The mouse group is opt-in — the shell
|
||||
" wrapper sets g:nuwiki_mouse_mappings before the ftplugin loads.
|
||||
|
||||
function! s:has_map(lhs, mode) abort
|
||||
let l:d = maparg(a:lhs, a:mode, 0, 1)
|
||||
return !empty(l:d) && get(l:d, 'buffer', 0)
|
||||
endfunction
|
||||
|
||||
let s:mapping_surface = [
|
||||
\ ['<CR>', 'n'], ['<S-CR>', 'n'], ['<C-CR>', 'n'], ['<C-S-CR>', 'n'],
|
||||
\ ['<BS>', 'n'], ['<Tab>', 'n'], ['<S-Tab>', 'n'], ['+', 'nx'],
|
||||
\ ['<C-Space>', 'nx'], ['<C-@>', 'nx'], ['<Nul>', 'nx'],
|
||||
\ ['gnt', 'n'], ['gln', 'nx'], ['glp', 'nx'], ['glx', 'nx'],
|
||||
\ ['glh', 'n'], ['gll', 'n'], ['gLh', 'n'], ['gLl', 'n'],
|
||||
\ ['glr', 'n'], ['gLr', 'n'], ['gl<Space>', 'n'], ['gL<Space>', 'n'],
|
||||
\ ['o', 'n'], ['O', 'n'],
|
||||
\ ['=', 'n'], ['-', 'n'], [']]', 'n'], ['[[', 'n'],
|
||||
\ [']=', 'n'], ['[=', 'n'], [']u', 'n'], ['[u', 'n'],
|
||||
\ ['gqq', 'n'], ['gq1', 'n'], ['gww', 'n'], ['gw1', 'n'],
|
||||
\ ['<A-Left>', 'n'], ['<A-Right>', 'n'],
|
||||
\ ['<Leader>ww', 'n'], ['<Leader>ws', 'n'], ['<Leader>wi', 'n'],
|
||||
\ ['<Leader>w<Leader>w', 'n'], ['<Leader>w<Leader>y', 'n'],
|
||||
\ ['<Leader>w<Leader>t', 'n'], ['<Leader>w<Leader>i', 'n'],
|
||||
\ ['<Leader>wn', 'n'], ['<Leader>wd', 'n'], ['<Leader>wr', 'n'],
|
||||
\ ['<Leader>wh', 'n'], ['<Leader>whh', 'n'], ['<Leader>wha', 'n'],
|
||||
\ ['<Leader>wc', 'nx'], ['<C-Down>', 'n'], ['<C-Up>', 'n'],
|
||||
\ ['<2-LeftMouse>', 'n'], ['<S-2-LeftMouse>', 'n'], ['<C-2-LeftMouse>', 'n'],
|
||||
\ ['<MiddleMouse>', 'n'], ['<RightMouse>', 'n'],
|
||||
\ ['ah', 'ox'], ['ih', 'ox'], ['aH', 'ox'], ['iH', 'ox'],
|
||||
\ ['al', 'ox'], ['il', 'ox'], ['a\', 'ox'], ['i\', 'ox'],
|
||||
\ ['ac', 'ox'], ['ic', 'ox'],
|
||||
\ ['<CR>', 'i'], ['<Tab>', 'i'], ['<S-Tab>', 'i'],
|
||||
\ ['<C-D>', 'i'], ['<C-T>', 'i'],
|
||||
\ ['<C-L><C-J>', 'i'], ['<C-L><C-K>', 'i'], ['<C-L><C-M>', 'i'],
|
||||
\ ]
|
||||
for s:entry in s:mapping_surface
|
||||
for s:m in split(s:entry[1], '\zs')
|
||||
let s:ok = s:has_map(s:entry[0], s:m)
|
||||
call s:record(s:ok ? 1 : 0, 'map[' . s:m . '].' . s:entry[0],
|
||||
\ s:ok ? '' : s:entry[0] . ' (' . s:m . ') not mapped buffer-local')
|
||||
endfor
|
||||
endfor
|
||||
|
||||
" ===== Header nav (pure VimL) =====
|
||||
|
||||
call s:run('headers.]]_jumps_to_next', {
|
||||
@@ -121,6 +222,12 @@ call s:run('headers.]=_jumps_to_next_sibling', {
|
||||
\ 'keys': ']=',
|
||||
\ 'expect_cursor_line': 4,
|
||||
\ })
|
||||
call s:run('headers.[=_jumps_to_prev_sibling', {
|
||||
\ 'lines': ['= A =', '== child ==', 'body', '= B ='],
|
||||
\ 'cursor': [4, 1],
|
||||
\ 'keys': '[=',
|
||||
\ 'expect_cursor_line': 1,
|
||||
\ })
|
||||
|
||||
" ===== Link nav (pure VimL) =====
|
||||
|
||||
|
||||
@@ -123,6 +123,105 @@ vim.defer_fn(function()
|
||||
end
|
||||
record(true, 'lsp.attached', 'client=' .. client.name)
|
||||
|
||||
-- ===== Documented command surface (doc/nuwiki.txt §5) =====
|
||||
-- Every command must be reachable in BOTH the canonical :Nuwiki* form
|
||||
-- and the vimwiki-compatible :Vimwiki* form. The suffixes below are the
|
||||
-- §5 list minus the prefix. :NuwikiInstall is the one exception — a
|
||||
-- nuwiki-only maintenance command with no :Vimwiki* alias.
|
||||
local both_forms = {
|
||||
'Index', 'TabIndex', 'UISelect', 'Goto', 'FollowLink', 'Backlinks',
|
||||
'NextLink', 'PrevLink', 'BaddLink', 'RenameFile', 'DeleteFile',
|
||||
'MakeDiaryNote', 'MakeYesterdayDiaryNote', 'MakeTomorrowDiaryNote',
|
||||
'DiaryIndex', 'DiaryGenerateLinks', 'DiaryNextDay', 'DiaryPrevDay',
|
||||
'TOC', 'GenerateLinks', 'CheckLinks', 'SearchTags', 'GenerateTagLinks',
|
||||
'RebuildTags', '2HTML', '2HTMLBrowse', 'All2HTML', 'Rss',
|
||||
}
|
||||
for _, suffix in ipairs(both_forms) do
|
||||
for _, prefix in ipairs({ 'Nuwiki', 'Vimwiki' }) do
|
||||
local cmd = prefix .. suffix
|
||||
local exists = vim.fn.exists(':' .. cmd) == 2
|
||||
record(exists, 'surface.' .. cmd, exists and '' or (':' .. cmd .. ' not registered'))
|
||||
end
|
||||
end
|
||||
do
|
||||
local exists = vim.fn.exists(':NuwikiInstall') == 2
|
||||
record(exists, 'surface.NuwikiInstall',
|
||||
exists and '' or ':NuwikiInstall not registered')
|
||||
end
|
||||
|
||||
-- ===== Pure-Lua command invocation (cursor movement) =====
|
||||
-- :NuwikiNextLink / :NuwikiPrevLink wrap the link-search motion and run
|
||||
-- without the LSP, so we can assert exact cursor movement here.
|
||||
do
|
||||
local ok, err = pcall(function()
|
||||
set_buf({ 'intro [[A]] more [[B]] end' })
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
vim.cmd('NuwikiNextLink')
|
||||
local c1 = vim.api.nvim_win_get_cursor(0)[2]
|
||||
if c1 ~= 6 then error('NextLink #1 col=' .. c1 .. ' want 6') end
|
||||
vim.cmd('NuwikiNextLink')
|
||||
local c2 = vim.api.nvim_win_get_cursor(0)[2]
|
||||
if c2 ~= 17 then error('NextLink #2 col=' .. c2 .. ' want 17') end
|
||||
vim.cmd('VimwikiPrevLink')
|
||||
local c3 = vim.api.nvim_win_get_cursor(0)[2]
|
||||
if c3 ~= 6 then error('PrevLink col=' .. c3 .. ' want 6') end
|
||||
end)
|
||||
record(ok, 'invoke.next_prev_link_cursor_movement', ok and '' or tostring(err))
|
||||
end
|
||||
|
||||
-- ===== Documented mapping surface (doc §6/§7/§8) =====
|
||||
-- Every documented mapping must resolve to a buffer-local mapping in
|
||||
-- the mode(s) the doc lists it under. `modes` is a string of mode
|
||||
-- letters: n/x/o/i. The mouse group is opt-in — the harness enables it
|
||||
-- via setup({ mappings = { mouse = true } }).
|
||||
local function has_map(lhs, mode)
|
||||
local d = vim.fn.maparg(lhs, mode, false, true)
|
||||
return next(d) ~= nil and d.buffer == 1
|
||||
end
|
||||
local mapping_surface = {
|
||||
-- §6 Links
|
||||
{ '<CR>', 'n' }, { '<S-CR>', 'n' }, { '<C-CR>', 'n' }, { '<C-S-CR>', 'n' },
|
||||
{ '<BS>', 'n' }, { '<Tab>', 'n' }, { '<S-Tab>', 'n' }, { '+', 'nx' },
|
||||
-- §6 Lists
|
||||
{ '<C-Space>', 'nx' }, { '<C-@>', 'nx' }, { '<Nul>', 'nx' },
|
||||
{ 'gnt', 'n' }, { 'gln', 'nx' }, { 'glp', 'nx' }, { 'glx', 'nx' },
|
||||
{ 'glh', 'n' }, { 'gll', 'n' }, { 'gLh', 'n' }, { 'gLl', 'n' },
|
||||
{ 'glr', 'n' }, { 'gLr', 'n' }, { 'gl<Space>', 'n' }, { 'gL<Space>', 'n' },
|
||||
{ 'o', 'n' }, { 'O', 'n' },
|
||||
-- §6 Headers
|
||||
{ '=', 'n' }, { '-', 'n' }, { ']]', 'n' }, { '[[', 'n' },
|
||||
{ ']=', 'n' }, { '[=', 'n' }, { ']u', 'n' }, { '[u', 'n' },
|
||||
-- §6 Tables
|
||||
{ 'gqq', 'n' }, { 'gq1', 'n' }, { 'gww', 'n' }, { 'gw1', 'n' },
|
||||
{ '<A-Left>', 'n' }, { '<A-Right>', 'n' },
|
||||
-- §6 Wiki / diary / export
|
||||
{ '<Leader>ww', 'n' }, { '<Leader>ws', 'n' }, { '<Leader>wi', 'n' },
|
||||
{ '<Leader>w<Leader>w', 'n' }, { '<Leader>w<Leader>y', 'n' },
|
||||
{ '<Leader>w<Leader>t', 'n' }, { '<Leader>w<Leader>i', 'n' },
|
||||
{ '<Leader>wn', 'n' }, { '<Leader>wd', 'n' }, { '<Leader>wr', 'n' },
|
||||
{ '<Leader>wh', 'n' }, { '<Leader>whh', 'n' }, { '<Leader>wha', 'n' },
|
||||
{ '<Leader>wc', 'nx' }, { '<C-Down>', 'n' }, { '<C-Up>', 'n' },
|
||||
-- §6 Mouse (opt-in)
|
||||
{ '<2-LeftMouse>', 'n' }, { '<S-2-LeftMouse>', 'n' }, { '<C-2-LeftMouse>', 'n' },
|
||||
{ '<MiddleMouse>', 'n' }, { '<RightMouse>', 'n' },
|
||||
-- §7 Text objects (operator-pending + visual)
|
||||
{ 'ah', 'ox' }, { 'ih', 'ox' }, { 'aH', 'ox' }, { 'iH', 'ox' },
|
||||
{ 'al', 'ox' }, { 'il', 'ox' }, { 'a\\', 'ox' }, { 'i\\', 'ox' },
|
||||
{ 'ac', 'ox' }, { 'ic', 'ox' },
|
||||
-- §8 Insert-mode
|
||||
{ '<CR>', 'i' }, { '<Tab>', 'i' }, { '<S-Tab>', 'i' },
|
||||
{ '<C-D>', 'i' }, { '<C-T>', 'i' },
|
||||
{ '<C-L><C-J>', 'i' }, { '<C-L><C-K>', 'i' }, { '<C-L><C-M>', 'i' },
|
||||
}
|
||||
for _, entry in ipairs(mapping_surface) do
|
||||
local lhs, modes = entry[1], entry[2]
|
||||
for m in modes:gmatch('.') do
|
||||
local ok = has_map(lhs, m)
|
||||
record(ok, 'map[' .. m .. '].' .. lhs,
|
||||
ok and '' or (lhs .. ' (' .. m .. ') not mapped buffer-local'))
|
||||
end
|
||||
end
|
||||
|
||||
-- ===== Lists =====
|
||||
|
||||
run('list.toggle_via_C-Space', {
|
||||
@@ -167,6 +266,84 @@ vim.defer_fn(function()
|
||||
expect_line = '- [ ] done',
|
||||
expect_at = 1,
|
||||
})
|
||||
-- `glp` must cycle BACKWARD (regression for the glp==gln bug). From
|
||||
-- `[.]` the backward step lands on `[ ]`, whereas `gln` would advance
|
||||
-- to `[o]`.
|
||||
run('list.cycle_back_via_glp', {
|
||||
lines = { '- [.] task' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'glp',
|
||||
expect_line = '- [ ] task',
|
||||
expect_at = 1,
|
||||
})
|
||||
run('list.cycle_back_via_glp_wraps', {
|
||||
-- `[ ]` backward wraps to the top of the progression (`[X]`).
|
||||
lines = { '- [ ] task' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'glp',
|
||||
expect_line = '- [X] task',
|
||||
expect_at = 1,
|
||||
})
|
||||
|
||||
-- ===== Change level (glh/gll) =====
|
||||
|
||||
run('list.indent_via_gll', {
|
||||
lines = { '- item' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'gll',
|
||||
expect_line = ' - item',
|
||||
expect_at = 1,
|
||||
})
|
||||
run('list.dedent_via_glh', {
|
||||
lines = { ' - item' },
|
||||
cursor = { 1, 2 },
|
||||
keys = 'glh',
|
||||
expect_line = '- item',
|
||||
expect_at = 1,
|
||||
})
|
||||
|
||||
-- ===== Renumber (glr / gLr) =====
|
||||
|
||||
run('list.renumber_via_glr', {
|
||||
lines = { '5. one', '9. two', '2. three' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'glr',
|
||||
expect_lines = { '1. one', '2. two', '3. three' },
|
||||
})
|
||||
|
||||
-- ===== Remove done (gl<Space> current list / gL<Space> whole doc) =====
|
||||
|
||||
-- `gl<Space>` removes done items from the cursor's list only; the
|
||||
-- second list's done item survives.
|
||||
run('list.remove_done_current_list_via_gl_space', {
|
||||
lines = {
|
||||
'- [X] done a', '- [ ] todo a', '',
|
||||
'paragraph', '',
|
||||
'- [ ] todo b', '- [X] done b',
|
||||
},
|
||||
cursor = { 1, 0 },
|
||||
keys = 'gl<Space>',
|
||||
expect_lines = {
|
||||
'- [ ] todo a', '',
|
||||
'paragraph', '',
|
||||
'- [ ] todo b', '- [X] done b',
|
||||
},
|
||||
})
|
||||
-- `gL<Space>` sweeps the whole buffer regardless of cursor position.
|
||||
run('list.remove_done_whole_buffer_via_gL_space', {
|
||||
lines = {
|
||||
'- [X] done a', '- [ ] todo a', '',
|
||||
'paragraph', '',
|
||||
'- [ ] todo b', '- [X] done b',
|
||||
},
|
||||
cursor = { 1, 0 },
|
||||
keys = 'gL<Space>',
|
||||
expect_lines = {
|
||||
'- [ ] todo a', '',
|
||||
'paragraph', '',
|
||||
'- [ ] todo b',
|
||||
},
|
||||
})
|
||||
|
||||
-- ===== Next task (gnt) =====
|
||||
|
||||
|
||||
@@ -38,7 +38,9 @@ cat > "$INIT" <<EOF
|
||||
-- Keep the harness self-contained; no plugin manager required.
|
||||
vim.opt.runtimepath:prepend('$REPO_ROOT')
|
||||
vim.g.nuwiki_binary_path = '$BIN'
|
||||
require('nuwiki').setup({ wiki_root = '$TMP/wiki' })
|
||||
-- Enable the opt-in mouse mappings so the harness can assert the full
|
||||
-- documented mapping surface (doc §6 mouse group).
|
||||
require('nuwiki').setup({ wiki_root = '$TMP/wiki', mappings = { mouse = true } })
|
||||
EOF
|
||||
|
||||
RESULTS="$TMP/results.txt"
|
||||
|
||||
Reference in New Issue
Block a user