Files
nuwiki/crates/nuwiki-lsp/tests/cluster_a_list_rewriters.rs
T
gffranco 9d34c8baa2 feat(13.1-A): list rewriters — removeDone, renumber, changeSymbol/Level
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<Space>` / `gL<Space>` (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) <noreply@anthropic.com>
2026-05-12 14:37:11 +00:00

220 lines
7.5 KiB
Rust

//! §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::<Vec<_>>()
.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);
}