1246c99c3c
Parallel-agent audit of the config batch found: - cycle_bullets used find_marker_span's third element as an indent column, but it's the ABSOLUTE byte offset after the marker — so depth (and the rotated glyph) was wrong for any item not near offset 0 (tests passed by coincidence). Fixed to use the marker span's column (the indentation). Regression test now places the list deep in the document. - :VimwikiColorize interpolated the colour into the template via an unescaped replacement: Lua gsub treats `%` specially, Vim substitute() treats `&`/`\`. Lua now uses a function replacement (verbatim); Vim escapes `\&~`. - render_swapped_table applied table_reduce_last_col before the column swap, so a swap involving the last column moved the narrow slot — now clamped after. - Fixed a stale toc_link_format doc comment (format 1 = [[#anchor]], not a full parent path). Verified-correct by the audit (no change needed): RSS rfc822/cdata/fidelity, prune_orphan_html reverse-mapping + guards, write_escaped_allowing/match_allowed_tag (multibyte-safe), substitute_emoji (byte-boundary-safe), resolve_target_uri create_link/dir_link ordering, all config defaults + From/constructors. Full rust suite + clippy clean; Neovim 307, Vim 301/18/21. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
341 lines
12 KiB
Rust
341 lines
12 KiB
Rust
//! 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, 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, 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, None, true).is_none());
|
|
}
|
|
|
|
#[test]
|
|
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 {
|
|
line: 0,
|
|
character: 0,
|
|
});
|
|
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, None, true).expect("edit");
|
|
let edits = &edit.changes.unwrap()[&uri()];
|
|
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, None, 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, None, true).expect("edit");
|
|
let edits = &edit.changes.unwrap()[&uri()];
|
|
assert_eq!(edits.len(), 1, "the nested done child is removed");
|
|
}
|
|
|
|
#[test]
|
|
fn remove_done_ranged_restricts_to_selected_lines() {
|
|
// The `:'<,'>VimwikiRemoveDone` form: a 0-indexed inclusive line range
|
|
// sweeps the whole doc but only deletes done/rejected items inside it.
|
|
// Lines: 0 `[X]`, 1 `[ ]`, 2 `[X]`, 3 `[-]`. Range 0..=1 keeps only the
|
|
// line-0 done item; the line-2/3 done+rejected items survive.
|
|
let src = "- [X] a\n- [ ] b\n- [X] c\n- [-] d\n";
|
|
let ast = parse(src);
|
|
let edit = ops::remove_done_edit(src, &ast, &uri(), None, Some((0, 1)), true).expect("edit");
|
|
let edits = &edit.changes.unwrap()[&uri()];
|
|
assert_eq!(edits.len(), 1, "only the done item on line 0 is in range");
|
|
}
|
|
|
|
#[test]
|
|
fn remove_done_ranged_returns_none_when_range_has_no_done() {
|
|
// Range covering only open items → nothing to delete.
|
|
let src = "- [X] a\n- [ ] b\n- [ ] c\n";
|
|
let ast = parse(src);
|
|
assert!(ops::remove_done_edit(src, &ast, &uri(), None, Some((1, 2)), true).is_none());
|
|
}
|
|
|
|
// ===== 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."));
|
|
}
|
|
|
|
// ===== removeCheckbox =====
|
|
|
|
#[test]
|
|
fn remove_checkbox_strips_single_item() {
|
|
let src = "- [ ] task\n- [X] done\n";
|
|
let ast = parse(src);
|
|
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).expect("edit");
|
|
let edits = &edit.changes.unwrap()[&uri()];
|
|
assert_eq!(edits.len(), 1);
|
|
assert_eq!(edits[0].new_text, "");
|
|
// Removes `[ ] ` — columns 2..6 on the first line.
|
|
assert_eq!(edits[0].range.start.line, 0);
|
|
assert_eq!(edits[0].range.start.character, 2);
|
|
assert_eq!(edits[0].range.end.character, 6);
|
|
}
|
|
|
|
#[test]
|
|
fn remove_checkbox_whole_list_strips_every_item() {
|
|
let src = "- [ ] a\n- [X] b\n- [-] c\n";
|
|
let ast = parse(src);
|
|
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, true, true).expect("edit");
|
|
let edits = &edit.changes.unwrap()[&uri()];
|
|
assert_eq!(edits.len(), 3);
|
|
for e in edits {
|
|
assert_eq!(e.new_text, "");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn remove_checkbox_none_without_checkbox() {
|
|
let src = "- plain item\n";
|
|
let ast = parse(src);
|
|
assert!(ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).is_none());
|
|
}
|
|
|
|
// ===== 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, false, &[]).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, false, &[]);
|
|
// Already at column 0 — dedent is a no-op.
|
|
assert!(edit.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_bullets_rotates_glyph_on_indent() {
|
|
// With cycle_bullets + bullet_types [-,*,#], indenting a `-` item (depth 0
|
|
// → 1) rotates the glyph to `*` (and dedenting back to `-`).
|
|
let bullets: Vec<String> = ["-", "*", "#"].iter().map(|s| s.to_string()).collect();
|
|
// Put the list well into the document so depth must come from the item's
|
|
// indentation, not its absolute byte offset (regression guard).
|
|
let src = "some intro paragraph text here\n\n- one\n- two\n";
|
|
let ast = parse(src);
|
|
let edit =
|
|
ops::change_level_edit(src, &ast, &uri(), 3, 1, false, true, true, &bullets).expect("edit");
|
|
let texts: Vec<String> = edit.changes.unwrap()[&uri()]
|
|
.iter()
|
|
.map(|e| e.new_text.clone())
|
|
.collect();
|
|
assert!(texts.contains(&" ".to_string()), "indent edit: {texts:?}");
|
|
assert!(texts.contains(&"*".to_string()), "glyph rotated to *: {texts:?}");
|
|
}
|
|
|
|
#[test]
|
|
fn cycle_bullets_off_leaves_glyph() {
|
|
let bullets: Vec<String> = ["-", "*", "#"].iter().map(|s| s.to_string()).collect();
|
|
let src = "- one\n- two\n";
|
|
let ast = parse(src);
|
|
let edit = ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true, false, &bullets)
|
|
.expect("edit");
|
|
let texts: Vec<String> = edit.changes.unwrap()[&uri()]
|
|
.iter()
|
|
.map(|e| e.new_text.clone())
|
|
.collect();
|
|
// Only the indent edit; no glyph rewrite.
|
|
assert!(!texts.iter().any(|t| t == "*"), "no glyph change: {texts:?}");
|
|
}
|
|
|
|
#[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, false, &[]).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.removeCheckbox",
|
|
"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, None, true).unwrap();
|
|
let _ = one_edit_text(edit);
|
|
}
|