From 9d34c8baa25794373e11e79824d47d6c54ddfb45 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Tue, 12 May 2026 14:37:11 +0000 Subject: [PATCH] =?UTF-8?q?feat(13.1-A):=20list=20rewriters=20=E2=80=94=20?= =?UTF-8?q?removeDone,=20renumber,=20changeSymbol/Level?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes the four-command list-rewriter cluster from SPEC §13.1. Server-side (commands.rs ops module): - `parse_symbol` / `render_marker` — round-trip between `:VimwikiListChangeSymbol` arg shapes (`-`, `1.`, `a)`, `i)`, `Dash`, `Numeric`, …) and the rendered marker text. `render_marker` knows how to spell `a/b/…/z/aa` and `i/ii/iv/v/…/MMM` for the alphabetic + roman variants. - `remove_done_edit` — walks every list (recursively through blockquotes and sublists), emits delete TextEdits for items whose checkbox is `Done` or `Rejected`. Item span gets extended through the trailing newline so we don't leave behind empty lines. Accepts an optional `position` to scope the operation to the item under cursor + its descendants. - `renumber_edit` — finds the list containing the cursor line (or every list when `whole_file: true`), re-sequences numeric markers in-place. Unordered lists are left alone. - `change_symbol_edit` — rewrites the leading marker on a single item, or every item in a list when `whole_list: true`. Uses `render_marker` so ordered variants get the right index. - `change_level_edit` — re-indents the marker line (or every line of the item subtree when `whole_subtree: true`) by ±2 spaces per level. Dedent clamps at column 0. - `find_list_at_line` + `find_marker_span` — pure helpers reused by the three above. Editor glue: - `lua/nuwiki/commands.lua` / `autoload/nuwiki/commands.vim` — removed all four "not yet implemented" stubs and wired them to the real LSP commands. Lua also exposes `list_change_lvl(direction)` for the `:VimwikiListChangeLvl decrease|increase` compat entry. - Keymaps for `glh` / `gll` / `gLh` / `gLl` (list level single + subtree), `glr` / `gLr` (renumber list + whole-file), `gl` / `gL` (remove done items) now hit the real commands instead of printing a "deferred" notification. Tests: 16 new in `cluster_a_list_rewriters.rs` covering symbol parsing, marker rendering (alpha + roman + numeric), removeDone shape, scoped removeDone, no-match cases, renumber sequencing, whole-file walk, changeSymbol single + whole-list, changeLevel single + subtree + clamp, and COMMANDS list completeness. Total 402 Rust tests pass; clippy clean; keymap harness still at 20/20. Co-Authored-By: Claude Opus 4.7 (1M context) --- autoload/nuwiki/commands.vim | 53 +- crates/nuwiki-lsp/src/commands.rs | 654 ++++++++++++++++++ .../tests/cluster_a_list_rewriters.rs | 219 ++++++ ftplugin/vimwiki.vim | 16 +- lua/nuwiki/commands.lua | 40 +- lua/nuwiki/keymaps.lua | 24 +- 6 files changed, 983 insertions(+), 23 deletions(-) create mode 100644 crates/nuwiki-lsp/tests/cluster_a_list_rewriters.rs diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index 5c8f1c0..c99e293 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -462,11 +462,56 @@ endfunction " ===== §13.1 deferred ===== -function! nuwiki#commands#list_change_lvl() abort - call s:notify_deferred(':VimwikiListChangeLvl') -endfunction +" §13.1 Cluster A — list rewriters. + function! nuwiki#commands#list_remove_done() abort - call s:notify_deferred(':VimwikiRemoveDone') + let l:args = [{ 'uri': s:buf_uri() }] + call s:exec('nuwiki.list.removeDone', l:args) +endfunction + +function! nuwiki#commands#list_renumber() abort + let l:p = s:cursor_position() + let l:args = [{ 'uri': l:p['textDocument']['uri'], 'position': l:p['position'] }] + call s:exec('nuwiki.list.renumber', l:args) +endfunction + +function! nuwiki#commands#list_renumber_all() abort + let l:p = s:cursor_position() + let l:args = [{ + \ 'uri': l:p['textDocument']['uri'], + \ 'position': l:p['position'], + \ 'whole_file': v:true, + \ }] + call s:exec('nuwiki.list.renumber', l:args) +endfunction + +function! nuwiki#commands#list_change_symbol(symbol, whole_list) abort + let l:p = s:cursor_position() + let l:args = [{ + \ 'uri': l:p['textDocument']['uri'], + \ 'position': l:p['position'], + \ 'symbol': a:symbol, + \ 'whole_list': a:whole_list ? v:true : v:false, + \ }] + call s:exec('nuwiki.list.changeSymbol', l:args) +endfunction + +function! nuwiki#commands#list_change_level(delta, whole_subtree) abort + let l:p = s:cursor_position() + let l:args = [{ + \ 'uri': l:p['textDocument']['uri'], + \ 'position': l:p['position'], + \ 'delta': a:delta, + \ 'whole_subtree': a:whole_subtree ? v:true : v:false, + \ }] + call s:exec('nuwiki.list.changeLevel', l:args) +endfunction + +" `:VimwikiListChangeLvl decrease|increase 0` compat entry. +function! nuwiki#commands#list_change_lvl(...) abort + let l:dir = a:0 >= 1 ? a:1 : 'increase' + let l:delta = (l:dir ==# 'increase' || l:dir ==# 'indent') ? 1 : -1 + call nuwiki#commands#list_change_level(l:delta, 0) endfunction function! nuwiki#commands#table_insert() abort call s:notify_deferred(':VimwikiTable') diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 86931b7..381398b 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -67,6 +67,10 @@ pub const COMMANDS: &[&str] = &[ "nuwiki.wiki.gotoPage", "nuwiki.link.pasteWikilink", "nuwiki.link.pasteUrl", + "nuwiki.list.removeDone", + "nuwiki.list.renumber", + "nuwiki.list.changeSymbol", + "nuwiki.list.changeLevel", ]; pub(crate) async fn execute( @@ -163,6 +167,16 @@ pub(crate) async fn execute( "nuwiki.link.pasteUrl" => { link_paste(backend, args, PasteKind::Url).map(|o| o.map(CommandOutcome::Edit)) } + "nuwiki.list.removeDone" => { + list_remove_done(backend, args).map(|o| o.map(CommandOutcome::Edit)) + } + "nuwiki.list.renumber" => list_renumber(backend, args).map(|o| o.map(CommandOutcome::Edit)), + "nuwiki.list.changeSymbol" => { + list_change_symbol(backend, args).map(|o| o.map(CommandOutcome::Edit)) + } + "nuwiki.list.changeLevel" => { + list_change_level(backend, args).map(|o| o.map(CommandOutcome::Edit)) + } other => Err(format!("unknown nuwiki command: {other}")), } } @@ -782,6 +796,139 @@ fn wiki_goto_page(backend: &Backend, args: Vec) -> Result, }))) } +// ===== §13.1 Cluster A: list rewriters ===== + +fn list_remove_done(backend: &Backend, args: Vec) -> Result, String> { + let p = parse_optional_uri_arg(args.clone())?; + // Accept both `{ uri }` (whole doc) and `{ uri, position }` (item under + // cursor + its descendants). Position is optional — when absent, we + // strip every done/rejected item in the doc. + let raw = args.into_iter().next().unwrap_or(serde_json::json!({})); + #[derive(Deserialize, Default)] + struct Args { + uri: Option, + #[serde(default)] + position: Option, + } + let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; + let uri = parsed + .uri + .or(p.uri) + .ok_or_else(|| "missing uri".to_string())?; + let doc = match backend.documents.get(&uri) { + Some(d) => d, + None => return Ok(None), + }; + let utf8 = backend.use_utf8.load(Ordering::Relaxed); + Ok(ops::remove_done_edit( + &doc.text, + &doc.ast, + &uri, + parsed.position, + utf8, + )) +} + +fn list_renumber(backend: &Backend, args: Vec) -> Result, String> { + #[derive(Deserialize)] + struct Args { + uri: Url, + #[serde(default)] + position: Option, + #[serde(default)] + whole_file: bool, + } + let raw = args + .into_iter() + .next() + .ok_or_else(|| "missing { uri } argument".to_string())?; + let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; + let doc = match backend.documents.get(&parsed.uri) { + Some(d) => d, + None => return Ok(None), + }; + let utf8 = backend.use_utf8.load(Ordering::Relaxed); + let (line, _) = match parsed.position { + Some(pos) => nav::lsp_to_byte_pos(pos, &doc.text, utf8), + None => (0, 0), + }; + Ok(ops::renumber_edit( + &doc.text, + &doc.ast, + &parsed.uri, + line, + parsed.whole_file, + utf8, + )) +} + +fn list_change_symbol( + backend: &Backend, + args: Vec, +) -> Result, String> { + #[derive(Deserialize)] + struct Args { + uri: Url, + position: LspPosition, + symbol: String, + #[serde(default)] + whole_list: bool, + } + let raw = args + .into_iter() + .next() + .ok_or_else(|| "missing { uri, position, symbol } argument".to_string())?; + let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; + let target = ops::parse_symbol(&parsed.symbol) + .ok_or_else(|| format!("unknown list symbol: {}", parsed.symbol))?; + let doc = match backend.documents.get(&parsed.uri) { + Some(d) => d, + None => return Ok(None), + }; + let utf8 = backend.use_utf8.load(Ordering::Relaxed); + let (line, _) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8); + Ok(ops::change_symbol_edit( + &doc.text, + &doc.ast, + &parsed.uri, + line, + target, + parsed.whole_list, + utf8, + )) +} + +fn list_change_level(backend: &Backend, args: Vec) -> Result, String> { + #[derive(Deserialize)] + struct Args { + uri: Url, + position: LspPosition, + delta: i32, + #[serde(default)] + whole_subtree: bool, + } + let raw = args + .into_iter() + .next() + .ok_or_else(|| "missing { uri, position, delta } argument".to_string())?; + let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?; + let doc = match backend.documents.get(&parsed.uri) { + Some(d) => d, + None => return Ok(None), + }; + let utf8 = backend.use_utf8.load(Ordering::Relaxed); + let (line, _) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8); + Ok(ops::change_level_edit( + &doc.text, + &doc.ast, + &parsed.uri, + line, + parsed.delta, + parsed.whole_subtree, + utf8, + )) +} + // ===== Phase 17: HTML export commands ===== fn export_current( @@ -1495,6 +1642,513 @@ pub mod ops { /// on the diagnostics module's internals. pub use crate::diagnostics::collect_wiki_links; + // ===== §13.1 Cluster A: list rewriters ===== + + use crate::edits::text_edit_delete; + use nuwiki_core::ast::ListSymbol; + + /// Parse a `:VimwikiListChangeSymbol` argument into a `ListSymbol`. + /// Accepts both the short markers (`-`, `*`, `#`, `1.`, …) and the + /// long variant names so wrappers can pass either shape. + pub fn parse_symbol(name: &str) -> Option { + match name { + "-" | "Dash" | "dash" => Some(ListSymbol::Dash), + "*" | "Star" | "star" => Some(ListSymbol::Star), + "#" | "Hash" | "hash" => Some(ListSymbol::Hash), + "1." | "Numeric" | "numeric" => Some(ListSymbol::Numeric), + "1)" | "NumericParen" | "numeric_paren" => Some(ListSymbol::NumericParen), + "a)" | "AlphaParen" | "alpha_paren" => Some(ListSymbol::AlphaParen), + "A)" | "AlphaUpperParen" | "alpha_upper_paren" => Some(ListSymbol::AlphaUpperParen), + "i)" | "RomanParen" | "roman_paren" => Some(ListSymbol::RomanParen), + "I)" | "RomanUpperParen" | "roman_upper_paren" => Some(ListSymbol::RomanUpperParen), + _ => None, + } + } + + /// Render a list marker for the given symbol + 1-based sequence index. + /// Indices only matter for ordered variants (`Numeric`, `AlphaParen`, …). + pub fn render_marker(symbol: ListSymbol, idx: usize) -> String { + match symbol { + ListSymbol::Dash => "-".to_string(), + ListSymbol::Star => "*".to_string(), + ListSymbol::Hash => "#".to_string(), + ListSymbol::Numeric => format!("{idx}."), + ListSymbol::NumericParen => format!("{idx})"), + ListSymbol::AlphaParen => alpha_marker(idx, false), + ListSymbol::AlphaUpperParen => alpha_marker(idx, true), + ListSymbol::RomanParen => format!("{})", roman_marker(idx, false)), + ListSymbol::RomanUpperParen => format!("{})", roman_marker(idx, true)), + } + } + + fn alpha_marker(idx: usize, upper: bool) -> String { + // Spreadsheet-style: 1 → a, 2 → b, …, 26 → z, 27 → aa. + let mut n = idx; + let mut buf = Vec::new(); + let base = if upper { b'A' } else { b'a' }; + while n > 0 { + let rem = (n - 1) % 26; + buf.push(base + rem as u8); + n = (n - 1) / 26; + } + let mut s: String = buf.iter().rev().map(|b| *b as char).collect(); + s.push(')'); + s + } + + fn roman_marker(idx: usize, upper: bool) -> String { + let map: [(usize, &str); 13] = [ + (1000, "m"), + (900, "cm"), + (500, "d"), + (400, "cd"), + (100, "c"), + (90, "xc"), + (50, "l"), + (40, "xl"), + (10, "x"), + (9, "ix"), + (5, "v"), + (4, "iv"), + (1, "i"), + ]; + let mut n = idx; + let mut out = String::new(); + for (val, sym) in map { + while n >= val { + out.push_str(sym); + n -= val; + } + } + if upper { + out.to_ascii_uppercase() + } else { + out + } + } + + /// Walk every list block at top level (recursing into blockquotes + /// and sublists) and call `f` with each item. + fn walk_list_items<'a, F: FnMut(&'a ListItemNode)>(blocks: &'a [BlockNode], f: &mut F) { + for block in blocks { + walk_list_items_block(block, f); + } + } + + fn walk_list_items_block<'a, F: FnMut(&'a ListItemNode)>(block: &'a BlockNode, f: &mut F) { + match block { + BlockNode::List(ListNode { items, .. }) => { + for item in items { + walk_list_items_in_item(item, f); + } + } + BlockNode::Blockquote(BlockquoteNode { children, .. }) => { + for c in children { + walk_list_items_block(c, f); + } + } + _ => {} + } + } + + fn walk_list_items_in_item<'a, F: FnMut(&'a ListItemNode)>(item: &'a ListItemNode, f: &mut F) { + f(item); + if let Some(sub) = &item.sublist { + for sub_it in &sub.items { + walk_list_items_in_item(sub_it, f); + } + } + } + + // ----- removeDone ----- + + /// 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. + pub fn remove_done_edit( + text: &str, + ast: &DocumentNode, + uri: &Url, + 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; + } + if matches!( + item.checkbox, + Some(nuwiki_core::ast::CheckboxState::Done) + | Some(nuwiki_core::ast::CheckboxState::Rejected), + ) { + victims.push(extend_to_line_end(text, item.span)); + } + }); + if victims.is_empty() { + return None; + } + let mut b = WorkspaceEditBuilder::new(); + for span in victims { + b.edit(uri.clone(), text_edit_delete(span, text, utf8)); + } + Some(b.build()) + } + + /// 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 { + let bytes = text.as_bytes(); + let mut end_off = span.end.offset.min(bytes.len()); + let mut end_line = span.end.line; + let mut end_col = span.end.column; + if end_off < bytes.len() && bytes[end_off] == b'\n' { + end_off += 1; + end_line += 1; + end_col = 0; + } + Span::new( + span.start, + AstPosition { + line: end_line, + column: end_col, + offset: end_off, + }, + ) + } + + // ----- renumber ----- + + /// Re-sequence numeric markers in the list containing `line`, or in + /// every list when `whole_file` is set. + pub fn renumber_edit( + text: &str, + ast: &DocumentNode, + uri: &Url, + line: u32, + whole_file: bool, + utf8: bool, + ) -> Option { + let mut edits: Vec<(Span, String)> = Vec::new(); + if whole_file { + for block in &ast.children { + renumber_in_block(block, text, &mut edits); + } + } else { + let list = find_list_at_line(ast, line)?; + renumber_in_list(list, text, &mut edits); + } + if edits.is_empty() { + return None; + } + let mut b = WorkspaceEditBuilder::new(); + for (span, new) in edits { + b.edit(uri.clone(), text_edit_replace(span, new, text, utf8)); + } + Some(b.build()) + } + + fn renumber_in_block(block: &BlockNode, text: &str, edits: &mut Vec<(Span, String)>) { + match block { + BlockNode::List(list) => renumber_in_list(list, text, edits), + BlockNode::Blockquote(BlockquoteNode { children, .. }) => { + for c in children { + renumber_in_block(c, text, edits); + } + } + _ => {} + } + } + + fn renumber_in_list(list: &ListNode, text: &str, edits: &mut Vec<(Span, String)>) { + for (idx, item) in list.items.iter().enumerate() { + if is_ordered_symbol(item.symbol) { + if let Some((span, _, _)) = find_marker_span(text, item.span) { + let new = render_marker(item.symbol, idx + 1); + edits.push((span, new)); + } + } + if let Some(sub) = &item.sublist { + renumber_in_list(sub, text, edits); + } + } + } + + fn is_ordered_symbol(s: ListSymbol) -> bool { + matches!( + s, + ListSymbol::Numeric + | ListSymbol::NumericParen + | ListSymbol::AlphaParen + | ListSymbol::AlphaUpperParen + | ListSymbol::RomanParen + | ListSymbol::RomanUpperParen + ) + } + + // ----- changeSymbol ----- + + /// Rewrite the leading marker on each list item. When `whole_list` + /// is true, every item in the same list (recursively through + /// sublists) gets the new symbol; otherwise only the item at `line` + /// is touched. + pub fn change_symbol_edit( + text: &str, + ast: &DocumentNode, + uri: &Url, + line: u32, + target: ListSymbol, + whole_list: bool, + utf8: bool, + ) -> Option { + let mut edits: Vec<(Span, String)> = Vec::new(); + if whole_list { + let list = find_list_at_line(ast, line)?; + rewrite_list_symbols(list, target, text, &mut edits); + } else { + let item = find_list_item_at(ast, line, 0)?; + if let Some((span, _, _)) = find_marker_span(text, item.span) { + edits.push((span, render_marker(target, 1))); + } + } + if edits.is_empty() { + return None; + } + let mut b = WorkspaceEditBuilder::new(); + for (span, new) in edits { + b.edit(uri.clone(), text_edit_replace(span, new, text, utf8)); + } + Some(b.build()) + } + + fn rewrite_list_symbols( + list: &ListNode, + target: ListSymbol, + text: &str, + edits: &mut Vec<(Span, String)>, + ) { + for (idx, item) in list.items.iter().enumerate() { + if let Some((span, _, _)) = find_marker_span(text, item.span) { + edits.push((span, render_marker(target, idx + 1))); + } + if let Some(sub) = &item.sublist { + rewrite_list_symbols(sub, target, text, edits); + } + } + } + + // ----- changeLevel ----- + + /// Indent (`delta > 0`) or dedent (`delta < 0`) the item at `line`. + /// When `whole_subtree` is set, all descendants get the same shift + /// so the visual tree stays consistent. + pub fn change_level_edit( + text: &str, + ast: &DocumentNode, + uri: &Url, + line: u32, + delta: i32, + whole_subtree: bool, + utf8: bool, + ) -> Option { + let item = find_list_item_at(ast, line, 0)?; + let mut edits: Vec<(Span, String)> = Vec::new(); + if whole_subtree { + collect_level_edits(item, text, delta, &mut edits); + } else { + // Only the first line of the item — the marker line — + // changes indentation. Sublists keep their own indentation + // (a partial shift would break the tree). + push_level_edit_for_line(item.span.start.line, text, delta, &mut edits); + } + if edits.is_empty() { + return None; + } + let mut b = WorkspaceEditBuilder::new(); + for (span, new) in edits { + b.edit(uri.clone(), text_edit_replace(span, new, text, utf8)); + } + Some(b.build()) + } + + fn collect_level_edits( + item: &ListItemNode, + text: &str, + delta: i32, + edits: &mut Vec<(Span, String)>, + ) { + for line in item.span.start.line..=item.span.end.line { + push_level_edit_for_line(line, text, delta, edits); + } + if let Some(sub) = &item.sublist { + for sub_it in &sub.items { + // Already covered by the line range, but recurse so we + // don't miss items past the parent's span.end (resilient + // to off-by-one quirks in the lexer). + collect_level_edits(sub_it, text, delta, edits); + } + } + } + + fn push_level_edit_for_line( + line_num: u32, + text: &str, + delta: i32, + edits: &mut Vec<(Span, String)>, + ) { + let bytes = text.as_bytes(); + // Locate the start offset of `line_num`. + let mut off = 0usize; + let mut cur_line: u32 = 0; + while cur_line < line_num && off < bytes.len() { + if bytes[off] == b'\n' { + cur_line += 1; + } + off += 1; + } + if cur_line != line_num { + return; + } + let mut ws_end = off; + while ws_end < bytes.len() && (bytes[ws_end] == b' ' || bytes[ws_end] == b'\t') { + ws_end += 1; + } + let current_ws = ws_end - off; + let new_ws = if delta < 0 { + current_ws.saturating_sub((-delta * 2) as usize) + } else { + current_ws + (delta * 2) as usize + }; + if new_ws == current_ws { + return; + } + let span = Span::new( + AstPosition { + line: line_num, + column: 0, + offset: off, + }, + AstPosition { + line: line_num, + column: current_ws as u32, + offset: ws_end, + }, + ); + edits.push((span, " ".repeat(new_ws))); + } + + // ----- shared helpers ----- + + /// Find the deepest list containing `line` (an item's line range). + /// Top-level lists win over nested ones unless the cursor is on a + /// sublist item. + pub fn find_list_at_line(ast: &DocumentNode, line: u32) -> Option<&ListNode> { + for block in &ast.children { + if let Some(list) = find_list_in_block(block, line) { + return Some(list); + } + } + None + } + + fn find_list_in_block(block: &BlockNode, line: u32) -> Option<&ListNode> { + match block { + BlockNode::List(list) => { + // First check sublists for a tighter match. + for item in &list.items { + if let Some(sub) = &item.sublist { + if (sub.span.start.line..=sub.span.end.line).contains(&line) { + if let Some(nested) = find_list_in_sublist(sub, line) { + return Some(nested); + } + return Some(sub); + } + } + } + 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_list_in_block(c, line) { + return Some(l); + } + } + None + } + _ => None, + } + } + + fn find_list_in_sublist(list: &ListNode, line: u32) -> Option<&ListNode> { + for item in &list.items { + if let Some(sub) = &item.sublist { + if (sub.span.start.line..=sub.span.end.line).contains(&line) { + return find_list_in_sublist(sub, line).or(Some(sub)); + } + } + } + None + } + + /// Find the leading marker substring (e.g. `"-"`, `"1."`, `"a)"`) + /// within the first line of an item's source span. Returns the + /// span covering only the marker characters, the marker text, and + /// the byte offset right after the marker (caller uses this to + /// extend edits if needed). + pub fn find_marker_span(text: &str, item_span: Span) -> Option<(Span, &str, usize)> { + let start = item_span.start.offset; + let end = item_span.end.offset.min(text.len()); + if start >= end { + return None; + } + let bytes = text.as_bytes(); + // Skip leading whitespace. + let mut i = start; + while i < end && (bytes[i] == b' ' || bytes[i] == b'\t') { + i += 1; + } + let marker_start = i; + // Marker = run of non-space chars before the first whitespace. + // For `-`, `*`, `#`: single char. For `1.`, `1)`, `a)`, …: 2+. + while i < end && !(bytes[i] as char).is_whitespace() { + i += 1; + } + if marker_start == i { + return None; + } + let marker_str = std::str::from_utf8(&bytes[marker_start..i]).ok()?; + // Sanity: only accept characters that could form a list marker. + if !marker_str + .chars() + .all(|c| c.is_alphanumeric() || matches!(c, '-' | '*' | '#' | '.' | ')')) + { + return None; + } + let col_offset = marker_start - start; + let span = Span::new( + AstPosition { + line: item_span.start.line, + column: item_span.start.column + col_offset as u32, + offset: marker_start, + }, + AstPosition { + line: item_span.start.line, + column: item_span.start.column + (i - start) as u32, + offset: i, + }, + ); + Some((span, marker_str, i)) + } + // ===== Phase 16: diary helpers ===== use crate::edits::op_create; diff --git a/crates/nuwiki-lsp/tests/cluster_a_list_rewriters.rs b/crates/nuwiki-lsp/tests/cluster_a_list_rewriters.rs new file mode 100644 index 0000000..4c2a4c7 --- /dev/null +++ b/crates/nuwiki-lsp/tests/cluster_a_list_rewriters.rs @@ -0,0 +1,219 @@ +//! §13.1 Cluster A — list rewriters: `removeDone`, `renumber`, +//! `changeSymbol`, `changeLevel`. + +use nuwiki_core::ast::ListSymbol; +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; +use nuwiki_lsp::commands::ops; +use tower_lsp::lsp_types::{Position as LspPosition, Url}; + +fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { + VimwikiSyntax::new().parse(src) +} + +fn one_edit_text(edit: tower_lsp::lsp_types::WorkspaceEdit) -> String { + let changes = edit.changes.expect("changes map"); + let (_, edits) = changes.into_iter().next().expect("≥1 uri"); + edits + .into_iter() + .map(|e| e.new_text) + .collect::>() + .join("\n") +} + +fn uri() -> Url { + Url::parse("file:///tmp/list.wiki").unwrap() +} + +// ===== parse_symbol + render_marker ===== + +#[test] +fn parse_symbol_accepts_short_and_long_names() { + assert_eq!(ops::parse_symbol("-"), Some(ListSymbol::Dash)); + assert_eq!(ops::parse_symbol("Dash"), Some(ListSymbol::Dash)); + assert_eq!(ops::parse_symbol("1."), Some(ListSymbol::Numeric)); + assert_eq!(ops::parse_symbol("Numeric"), Some(ListSymbol::Numeric)); + assert_eq!(ops::parse_symbol("a)"), Some(ListSymbol::AlphaParen)); + assert_eq!(ops::parse_symbol("i)"), Some(ListSymbol::RomanParen)); + assert!(ops::parse_symbol("nope").is_none()); +} + +#[test] +fn render_marker_handles_each_symbol() { + assert_eq!(ops::render_marker(ListSymbol::Dash, 1), "-"); + assert_eq!(ops::render_marker(ListSymbol::Star, 1), "*"); + assert_eq!(ops::render_marker(ListSymbol::Hash, 1), "#"); + assert_eq!(ops::render_marker(ListSymbol::Numeric, 1), "1."); + assert_eq!(ops::render_marker(ListSymbol::Numeric, 4), "4."); + assert_eq!(ops::render_marker(ListSymbol::NumericParen, 7), "7)"); + assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 1), "a)"); + assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 26), "z)"); + assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 27), "aa)"); + assert_eq!(ops::render_marker(ListSymbol::AlphaUpperParen, 2), "B)"); + assert_eq!(ops::render_marker(ListSymbol::RomanParen, 4), "iv)"); + assert_eq!(ops::render_marker(ListSymbol::RomanParen, 9), "ix)"); + assert_eq!(ops::render_marker(ListSymbol::RomanUpperParen, 14), "XIV)"); +} + +// ===== removeDone ===== + +#[test] +fn remove_done_strips_done_and_rejected_items() { + let src = "- [ ] todo\n- [X] done\n- [-] rejected\n- [ ] also todo\n"; + let ast = parse(src); + let edit = ops::remove_done_edit(src, &ast, &uri(), None, true).expect("edit"); + let changes = edit.changes.expect("changes"); + let edits = &changes[&uri()]; + assert_eq!(edits.len(), 2, "expected 2 deletions"); + // Each delete edit replaces a span with empty string. + for e in edits { + assert_eq!(e.new_text, ""); + } +} + +#[test] +fn remove_done_leaves_open_items_alone() { + let src = "- [ ] todo\n- [.] partial\n- [o] more\n"; + let ast = parse(src); + assert!(ops::remove_done_edit(src, &ast, &uri(), None, true).is_none()); +} + +#[test] +fn remove_done_returns_none_when_no_lists() { + let src = "= heading =\nparagraph\n"; + let ast = parse(src); + assert!(ops::remove_done_edit(src, &ast, &uri(), None, true).is_none()); +} + +#[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. + let src = "- [X] done one\n- [ ] todo\n- [X] done two\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 line-0 item should match"); +} + +// ===== renumber ===== + +#[test] +fn renumber_resequences_numeric_markers() { + let src = "5. first\n9. second\n2. third\n"; + let ast = parse(src); + let edit = ops::renumber_edit(src, &ast, &uri(), 0, false, true).expect("edit"); + let edits = &edit.changes.unwrap()[&uri()]; + // We expect 3 marker rewrites: 5.→1., 9.→2., 2.→3. + assert_eq!(edits.len(), 3); + let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect(); + assert!(texts.contains(&"1.")); + assert!(texts.contains(&"2.")); + assert!(texts.contains(&"3.")); +} + +#[test] +fn renumber_ignores_unordered_lists() { + let src = "- a\n- b\n- c\n"; + let ast = parse(src); + assert!(ops::renumber_edit(src, &ast, &uri(), 0, false, true).is_none()); +} + +#[test] +fn renumber_whole_file_walks_every_list() { + let src = "5. one\n\n2. two\n9. three\n"; + let ast = parse(src); + let edit = ops::renumber_edit(src, &ast, &uri(), 0, true, true).expect("edit"); + let edits = &edit.changes.unwrap()[&uri()]; + // Two separate lists — first has 1 numeric item, second has 2 → + // 3 marker rewrites total. + assert_eq!(edits.len(), 3); +} + +// ===== changeSymbol ===== + +#[test] +fn change_symbol_rewrites_just_the_current_item() { + let src = "- a\n- b\n- c\n"; + let ast = parse(src); + let edit = + ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Star, false, true).expect("edit"); + let edits = &edit.changes.unwrap()[&uri()]; + assert_eq!(edits.len(), 1); + assert_eq!(edits[0].new_text, "*"); +} + +#[test] +fn change_symbol_whole_list_rewrites_every_item() { + let src = "- a\n- b\n- c\n"; + let ast = parse(src); + let edit = ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Numeric, true, true) + .expect("edit"); + let edits = &edit.changes.unwrap()[&uri()]; + assert_eq!(edits.len(), 3); + let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect(); + assert!(texts.contains(&"1.")); + assert!(texts.contains(&"2.")); + assert!(texts.contains(&"3.")); +} + +// ===== changeLevel ===== + +#[test] +fn change_level_indents_single_item() { + let src = "- one\n- two\n"; + let ast = parse(src); + let edit = ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true).expect("edit"); + let edits = &edit.changes.unwrap()[&uri()]; + assert_eq!(edits.len(), 1); + // delta=1 → +2 spaces + assert_eq!(edits[0].new_text, " "); +} + +#[test] +fn change_level_dedents_clamps_to_zero() { + let src = "- root\n"; + let ast = parse(src); + let edit = ops::change_level_edit(src, &ast, &uri(), 0, -1, false, true); + // Already at column 0 — dedent is a no-op. + assert!(edit.is_none()); +} + +#[test] +fn change_level_whole_subtree_walks_descendants() { + let src = "- parent\n - child a\n - child b\n- sibling\n"; + let ast = parse(src); + let edit = ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true).expect("edit"); + let edits = &edit.changes.unwrap()[&uri()]; + // Parent + 2 children get indented. Sibling stays. + assert!(edits.len() >= 3, "got {} edits", edits.len()); +} + +// ===== COMMANDS list completeness ===== + +#[test] +fn commands_list_includes_cluster_a() { + let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); + for name in [ + "nuwiki.list.removeDone", + "nuwiki.list.renumber", + "nuwiki.list.changeSymbol", + "nuwiki.list.changeLevel", + ] { + assert!(names.contains(&name), "missing: {name}"); + } +} + +// Smoke a one_edit_text helper so it's exercised by at least one test. +#[test] +fn one_edit_text_round_trip() { + let src = "- [X] done\n"; + let ast = parse(src); + let edit = ops::remove_done_edit(src, &ast, &uri(), None, true).unwrap(); + let _ = one_edit_text(edit); +} diff --git a/ftplugin/vimwiki.vim b/ftplugin/vimwiki.vim index 2106d90..343a800 100644 --- a/ftplugin/vimwiki.vim +++ b/ftplugin/vimwiki.vim @@ -155,14 +155,14 @@ if !has('nvim') xnoremap glp :call nuwiki#commands#cycle_list_item() nnoremap glx :call nuwiki#commands#reject_list_item() xnoremap glx :call nuwiki#commands#reject_list_item() - nnoremap glh :echohl WarningMsgechom 'nuwiki: list level not yet implemented — see SPEC §13.1'echohl None - nnoremap gll :echohl WarningMsgechom 'nuwiki: list level not yet implemented — see SPEC §13.1'echohl None - nnoremap gLh :echohl WarningMsgechom 'nuwiki: list level subtree not yet implemented — see SPEC §13.1'echohl None - nnoremap gLl :echohl WarningMsgechom 'nuwiki: list level subtree not yet implemented — see SPEC §13.1'echohl None - nnoremap glr :echohl WarningMsgechom 'nuwiki: list renumber not yet implemented — see SPEC §13.1'echohl None - nnoremap gLr :echohl WarningMsgechom 'nuwiki: list renumber-all not yet implemented — see SPEC §13.1'echohl None - nnoremap gl :echohl WarningMsgechom 'nuwiki: :VimwikiRemoveSingleCB not yet implemented — see SPEC §13.1'echohl None - nnoremap gL :echohl WarningMsgechom 'nuwiki: :VimwikiRemoveCBInList not yet implemented — see SPEC §13.1'echohl None + nnoremap glh :call nuwiki#commands#list_change_level(-1, 0) + nnoremap gll :call nuwiki#commands#list_change_level(1, 0) + nnoremap gLh :call nuwiki#commands#list_change_level(-1, 1) + nnoremap gLl :call nuwiki#commands#list_change_level(1, 1) + 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 o :call nuwiki#commands#open_below_with_bullet() nnoremap O :call nuwiki#commands#open_above_with_bullet() diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index 6ce5a4c..b498e3f 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -437,9 +437,43 @@ local function _not_yet(name) end end -M.list_change_lvl = _not_yet(':VimwikiListChangeLvl') -M.list_renumber = _not_yet(':VimwikiRenumber') -M.list_remove_done = _not_yet(':VimwikiRemoveDone') +-- §13.1 Cluster A — list rewriters. + +function M.list_remove_done() + exec('nuwiki.list.removeDone', uri_args()) +end + +function M.list_renumber() + exec('nuwiki.list.renumber', pos_args()) +end + +function M.list_renumber_all() + local args = pos_args()[1] + args.whole_file = true + exec('nuwiki.list.renumber', { args }) +end + +function M.list_change_symbol(symbol, whole_list) + local args = pos_args()[1] + args.symbol = symbol + args.whole_list = whole_list and true or false + exec('nuwiki.list.changeSymbol', { args }) +end + +function M.list_change_level(delta, whole_subtree) + local args = pos_args()[1] + args.delta = delta + args.whole_subtree = whole_subtree and true or false + exec('nuwiki.list.changeLevel', { args }) +end + +-- Vimwiki's `:VimwikiListChangeLvl decrease|increase 0` keeps a single +-- entry point. Forward to changeLevel. +function M.list_change_lvl(direction) + local delta = (direction == 'increase' or direction == 'indent') and 1 or -1 + M.list_change_level(delta, false) +end + M.table_insert = _not_yet(':VimwikiTable') M.table_move_column_left = _not_yet(':VimwikiTableMoveColumnLeft') M.table_move_column_right = _not_yet(':VimwikiTableMoveColumnRight') diff --git a/lua/nuwiki/keymaps.lua b/lua/nuwiki/keymaps.lua index 194a518..cb908ae 100644 --- a/lua/nuwiki/keymaps.lua +++ b/lua/nuwiki/keymaps.lua @@ -220,14 +220,22 @@ function M.attach(bufnr, mappings) map('x', 'glp', cmd.cycle_list_item, { 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', deferred(':VimwikiListDecreaseLvl'), { desc = 'nuwiki: list dedent (deferred)' }, bufnr) - map('n', 'gll', deferred(':VimwikiListIncreaseLvl'), { desc = 'nuwiki: list indent (deferred)' }, bufnr) - map('n', 'gLh', deferred(':VimwikiListDecreaseLvlSubtree'), { desc = 'nuwiki: list dedent subtree (deferred)' }, bufnr) - map('n', 'gLl', deferred(':VimwikiListIncreaseLvlSubtree'), { desc = 'nuwiki: list indent subtree (deferred)' }, bufnr) - map('n', 'glr', deferred(':VimwikiRenumberList'), { desc = 'nuwiki: renumber list (deferred)' }, bufnr) - map('n', 'gLr', deferred(':VimwikiRenumberAllLists'), { desc = 'nuwiki: renumber all lists (deferred)' }, bufnr) - map('n', 'gl', deferred(':VimwikiRemoveSingleCB'), { desc = 'nuwiki: remove checkbox (deferred)' }, bufnr) - map('n', 'gL', deferred(':VimwikiRemoveCBInList'), { desc = 'nuwiki: remove all checkboxes (deferred)' }, bufnr) + map('n', 'glh', function() cmd.list_change_level(-1, false) end, + { desc = 'nuwiki: list dedent' }, bufnr) + map('n', 'gll', function() cmd.list_change_level(1, false) end, + { desc = 'nuwiki: list indent' }, bufnr) + map('n', 'gLh', function() cmd.list_change_level(-1, true) end, + { desc = 'nuwiki: list dedent subtree' }, bufnr) + map('n', 'gLl', function() cmd.list_change_level(1, true) end, + { desc = 'nuwiki: list indent subtree' }, bufnr) + map('n', 'glr', cmd.list_renumber, + { desc = 'nuwiki: renumber list' }, bufnr) + 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 (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) end