diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index 2e7f976..1aecd5a 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -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` — 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` — 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 diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index ac08c30..70b2810 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -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, utf8: bool, ) -> Option { - let restrict_line = pos.map(|p| p.line); let mut victims: Vec = 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 { diff --git a/crates/nuwiki-lsp/tests/commands_checkboxes.rs b/crates/nuwiki-lsp/tests/commands_checkboxes.rs index 5028e00..363d951 100644 --- a/crates/nuwiki-lsp/tests/commands_checkboxes.rs +++ b/crates/nuwiki-lsp/tests/commands_checkboxes.rs @@ -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("[-]")); diff --git a/crates/nuwiki-lsp/tests/commands_coverage.rs b/crates/nuwiki-lsp/tests/commands_coverage.rs new file mode 100644 index 0000000..aea2edf --- /dev/null +++ b/crates/nuwiki-lsp/tests/commands_coverage.rs @@ -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}}
{{content}}
".into()), + "Home", + DiaryDate::from_ymd(2026, 5, 12).unwrap(), + &cfg.html, + Some(".wiki"), + HashMap::new(), + ) + .unwrap(); + assert!(html.contains("My Page")); + 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("")); + // 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}"); + } +} diff --git a/crates/nuwiki-lsp/tests/commands_lists.rs b/crates/nuwiki-lsp/tests/commands_lists.rs index 4c2a4c7..38ef2e8 100644 --- a/crates/nuwiki-lsp/tests/commands_lists.rs +++ b/crates/nuwiki-lsp/tests/commands_lists.rs @@ -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 ===== diff --git a/ftplugin/vimwiki.vim b/ftplugin/vimwiki.vim index cf1bf62..5bb3ae3 100644 --- a/ftplugin/vimwiki.vim +++ b/ftplugin/vimwiki.vim @@ -115,6 +115,21 @@ if !has('nvim') command! -buffer -nargs=? NuwikiGenerateTagLinks call nuwiki#commands#tags_generate_links() command! -buffer -nargs=1 NuwikiGoto call nuwiki#commands#wiki_goto_page() + " 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 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 gnt :call nuwiki#commands#next_task() nnoremap gln :call nuwiki#commands#cycle_list_item() xnoremap gln :call nuwiki#commands#cycle_list_item() - nnoremap glp :call nuwiki#commands#cycle_list_item() - xnoremap glp :call nuwiki#commands#cycle_list_item() + nnoremap glp :call nuwiki#commands#cycle_list_item_back() + xnoremap glp :call nuwiki#commands#cycle_list_item_back() nnoremap glx :call nuwiki#commands#reject_list_item() xnoremap glx :call nuwiki#commands#reject_list_item() nnoremap glh :call nuwiki#commands#list_change_level(-1, 0) @@ -173,7 +188,7 @@ if !has('nvim') nnoremap glr :call nuwiki#commands#list_renumber() nnoremap gLr :call nuwiki#commands#list_renumber_all() nnoremap gl :call nuwiki#commands#list_remove_done() - nnoremap gL :call nuwiki#commands#list_remove_done() + nnoremap gL :call nuwiki#commands#list_remove_done_all() nnoremap o :call nuwiki#commands#open_below_with_bullet() nnoremap O :call nuwiki#commands#open_above_with_bullet() @@ -355,5 +370,19 @@ command! -buffer -nargs=? NuwikiSearchTags lua require('nuwiki.commands' command! -buffer -nargs=? NuwikiGenerateTagLinks lua require('nuwiki.commands').tags_generate_links() command! -buffer -nargs=1 NuwikiGoto lua require('nuwiki.commands').wiki_goto_page() +" 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 (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) diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index 92923c1..bfeaa49 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -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` — 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` — remove done items across the whole buffer (no position). +function M.list_remove_done_all() exec('nuwiki.list.removeDone', uri_args()) end diff --git a/lua/nuwiki/keymaps.lua b/lua/nuwiki/keymaps.lua index d8d0c2c..eabbbb8 100644 --- a/lua/nuwiki/keymaps.lua +++ b/lua/nuwiki/keymaps.lua @@ -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', cmd.list_remove_done, - { desc = 'nuwiki: remove done items' }, bufnr) - map('n', 'gL', cmd.list_remove_done, + { desc = 'nuwiki: remove done items (current list)' }, bufnr) + map('n', 'gL', 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) diff --git a/scripts/test-keymaps-vim.sh b/scripts/test-keymaps-vim.sh index 77127c8..9f92311 100755 --- a/scripts/test-keymaps-vim.sh +++ b/scripts/test-keymaps-vim.sh @@ -33,6 +33,9 @@ VIMRC="$TMP/vimrc" cat > "$VIMRC" <', 'n'], ['', 'n'], ['', 'n'], ['', 'n'], + \ ['', 'n'], ['', 'n'], ['', 'n'], ['+', 'nx'], + \ ['', 'nx'], ['', 'nx'], ['', 'nx'], + \ ['gnt', 'n'], ['gln', 'nx'], ['glp', 'nx'], ['glx', 'nx'], + \ ['glh', 'n'], ['gll', 'n'], ['gLh', 'n'], ['gLl', 'n'], + \ ['glr', 'n'], ['gLr', 'n'], ['gl', 'n'], ['gL', 'n'], + \ ['o', 'n'], ['O', 'n'], + \ ['=', 'n'], ['-', 'n'], [']]', 'n'], ['[[', 'n'], + \ [']=', 'n'], ['[=', 'n'], [']u', 'n'], ['[u', 'n'], + \ ['gqq', 'n'], ['gq1', 'n'], ['gww', 'n'], ['gw1', 'n'], + \ ['', 'n'], ['', 'n'], + \ ['ww', 'n'], ['ws', 'n'], ['wi', 'n'], + \ ['ww', 'n'], ['wy', 'n'], + \ ['wt', 'n'], ['wi', 'n'], + \ ['wn', 'n'], ['wd', 'n'], ['wr', 'n'], + \ ['wh', 'n'], ['whh', 'n'], ['wha', 'n'], + \ ['wc', 'nx'], ['', 'n'], ['', 'n'], + \ ['<2-LeftMouse>', 'n'], ['', 'n'], ['', 'n'], + \ ['', 'n'], ['', 'n'], + \ ['ah', 'ox'], ['ih', 'ox'], ['aH', 'ox'], ['iH', 'ox'], + \ ['al', 'ox'], ['il', 'ox'], ['a\', 'ox'], ['i\', 'ox'], + \ ['ac', 'ox'], ['ic', 'ox'], + \ ['', 'i'], ['', 'i'], ['', 'i'], + \ ['', 'i'], ['', 'i'], + \ ['', 'i'], ['', 'i'], ['', '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) ===== diff --git a/scripts/test-keymaps.lua b/scripts/test-keymaps.lua index d15ba6e..f989438 100644 --- a/scripts/test-keymaps.lua +++ b/scripts/test-keymaps.lua @@ -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 + { '', 'n' }, { '', 'n' }, { '', 'n' }, { '', 'n' }, + { '', 'n' }, { '', 'n' }, { '', 'n' }, { '+', 'nx' }, + -- §6 Lists + { '', 'nx' }, { '', 'nx' }, { '', 'nx' }, + { 'gnt', 'n' }, { 'gln', 'nx' }, { 'glp', 'nx' }, { 'glx', 'nx' }, + { 'glh', 'n' }, { 'gll', 'n' }, { 'gLh', 'n' }, { 'gLl', 'n' }, + { 'glr', 'n' }, { 'gLr', 'n' }, { 'gl', 'n' }, { 'gL', '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' }, + { '', 'n' }, { '', 'n' }, + -- §6 Wiki / diary / export + { 'ww', 'n' }, { 'ws', 'n' }, { 'wi', 'n' }, + { 'ww', 'n' }, { 'wy', 'n' }, + { 'wt', 'n' }, { 'wi', 'n' }, + { 'wn', 'n' }, { 'wd', 'n' }, { 'wr', 'n' }, + { 'wh', 'n' }, { 'whh', 'n' }, { 'wha', 'n' }, + { 'wc', 'nx' }, { '', 'n' }, { '', 'n' }, + -- §6 Mouse (opt-in) + { '<2-LeftMouse>', 'n' }, { '', 'n' }, { '', 'n' }, + { '', 'n' }, { '', '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 + { '', 'i' }, { '', 'i' }, { '', 'i' }, + { '', 'i' }, { '', 'i' }, + { '', 'i' }, { '', 'i' }, { '', '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 current list / gL whole doc) ===== + + -- `gl` 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', + expect_lines = { + '- [ ] todo a', '', + 'paragraph', '', + '- [ ] todo b', '- [X] done b', + }, + }) + -- `gL` 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', + expect_lines = { + '- [ ] todo a', '', + 'paragraph', '', + '- [ ] todo b', + }, + }) -- ===== Next task (gnt) ===== diff --git a/scripts/test-keymaps.sh b/scripts/test-keymaps.sh index e3061aa..293df2d 100755 --- a/scripts/test-keymaps.sh +++ b/scripts/test-keymaps.sh @@ -38,7 +38,9 @@ cat > "$INIT" <