//! 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_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, 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, 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 ===== #[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).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.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, true).unwrap(); let _ = one_edit_text(edit); }