//! 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, 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 = ["-", "*", "#"].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 = 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 = ["-", "*", "#"].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 = 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); }