2026-05-13 01:30:55 +00:00
|
|
|
//! Checkbox state transitions and the `checkbox_edit` rewriter that
|
|
|
|
|
//! emits `WorkspaceEdit`s for `nuwiki.list.toggleCheckbox`,
|
|
|
|
|
//! `nuwiki.list.cycleCheckbox`, and `nuwiki.list.rejectCheckbox`.
|
2026-05-11 17:41:11 +00:00
|
|
|
|
|
|
|
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
|
|
|
|
use nuwiki_core::syntax::SyntaxPlugin;
|
|
|
|
|
use nuwiki_lsp::commands::ops;
|
|
|
|
|
use tower_lsp::lsp_types::{DocumentChanges, Url};
|
|
|
|
|
|
|
|
|
|
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
|
|
|
|
VimwikiSyntax::new().parse(src)
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn dummy_uri() -> Url {
|
|
|
|
|
Url::parse("file:///tmp/note.wiki").unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn one_text_edit(edit: tower_lsp::lsp_types::WorkspaceEdit) -> tower_lsp::lsp_types::TextEdit {
|
|
|
|
|
let changes = edit.changes.expect("expected changes map");
|
|
|
|
|
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
|
|
|
|
assert_eq!(edits.len(), 1, "expected exactly one text edit");
|
|
|
|
|
edits.into_iter().next().unwrap()
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn doc_changes_for(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Option<DocumentChanges> {
|
|
|
|
|
edit.document_changes
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== state transitions =====
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn toggle_state_round_trip() {
|
|
|
|
|
assert_eq!(ops::toggle_state("[ ]"), Some("[X]"));
|
|
|
|
|
assert_eq!(ops::toggle_state("[X]"), Some("[ ]"));
|
|
|
|
|
assert_eq!(ops::toggle_state("[.]"), Some("[X]"));
|
|
|
|
|
assert_eq!(ops::toggle_state("[-]"), Some("[ ]"));
|
|
|
|
|
assert_eq!(ops::toggle_state("[?]"), None);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn cycle_state_walks_progression() {
|
|
|
|
|
assert_eq!(ops::cycle_state("[ ]"), Some("[.]"));
|
|
|
|
|
assert_eq!(ops::cycle_state("[.]"), Some("[o]"));
|
|
|
|
|
assert_eq!(ops::cycle_state("[o]"), Some("[O]"));
|
|
|
|
|
assert_eq!(ops::cycle_state("[O]"), Some("[X]"));
|
|
|
|
|
assert_eq!(ops::cycle_state("[X]"), Some("[ ]"));
|
|
|
|
|
assert_eq!(ops::cycle_state("[-]"), Some("[ ]"));
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 13:56:28 -03:00
|
|
|
#[test]
|
|
|
|
|
fn cycle_state_back_is_exact_inverse() {
|
|
|
|
|
// `glp` walks the progression in reverse — every step must undo the
|
|
|
|
|
// matching `cycle_state` step.
|
|
|
|
|
assert_eq!(ops::cycle_state_back("[ ]"), Some("[X]"));
|
|
|
|
|
assert_eq!(ops::cycle_state_back("[X]"), Some("[O]"));
|
|
|
|
|
assert_eq!(ops::cycle_state_back("[O]"), Some("[o]"));
|
|
|
|
|
assert_eq!(ops::cycle_state_back("[o]"), Some("[.]"));
|
|
|
|
|
assert_eq!(ops::cycle_state_back("[.]"), Some("[ ]"));
|
|
|
|
|
assert_eq!(ops::cycle_state_back("[-]"), Some("[ ]"));
|
|
|
|
|
// Composing forward then backward returns to the start for every
|
|
|
|
|
// in-cycle marker.
|
|
|
|
|
for s in ["[ ]", "[.]", "[o]", "[O]", "[X]"] {
|
|
|
|
|
let fwd = ops::cycle_state(s).unwrap();
|
|
|
|
|
assert_eq!(ops::cycle_state_back(fwd), Some(s));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 17:41:11 +00:00
|
|
|
#[test]
|
|
|
|
|
fn reject_state_toggles_dash_marker() {
|
|
|
|
|
assert_eq!(ops::reject_state("[ ]"), Some("[-]"));
|
|
|
|
|
assert_eq!(ops::reject_state("[X]"), Some("[-]"));
|
|
|
|
|
assert_eq!(ops::reject_state("[-]"), Some("[ ]"));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== checkbox_edit =====
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn toggle_empty_checkbox_to_done() {
|
|
|
|
|
let src = "- [ ] task\n";
|
|
|
|
|
let doc = parse(src);
|
2026-05-14 15:00:46 +00:00
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state, true)
|
2026-05-11 17:41:11 +00:00
|
|
|
.expect("toggle edit");
|
|
|
|
|
let te = one_text_edit(edit);
|
|
|
|
|
assert_eq!(te.new_text, "[X]");
|
|
|
|
|
assert_eq!(te.range.start.character, 2); // bytes 2..5 = "[ ]"
|
|
|
|
|
assert_eq!(te.range.end.character, 5);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn cycle_advances_partial_states() {
|
|
|
|
|
let src = "- [.] half\n";
|
|
|
|
|
let doc = parse(src);
|
2026-05-14 15:00:46 +00:00
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::cycle_state, true)
|
2026-05-11 17:41:11 +00:00
|
|
|
.expect("cycle edit");
|
|
|
|
|
let te = one_text_edit(edit);
|
|
|
|
|
assert_eq!(te.new_text, "[o]");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn reject_replaces_empty_with_dash() {
|
|
|
|
|
let src = "* [ ] thing\n";
|
|
|
|
|
let doc = parse(src);
|
2026-05-14 15:00:46 +00:00
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::reject_state, true)
|
2026-05-11 17:41:11 +00:00
|
|
|
.expect("reject edit");
|
|
|
|
|
let te = one_text_edit(edit);
|
|
|
|
|
assert_eq!(te.new_text, "[-]");
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn checkbox_edit_returns_none_on_plain_list_item() {
|
|
|
|
|
// No `[…]` at all → no edit.
|
|
|
|
|
let src = "- plain item\n";
|
|
|
|
|
let doc = parse(src);
|
2026-05-14 15:00:46 +00:00
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state, true);
|
2026-05-11 17:41:11 +00:00
|
|
|
assert!(edit.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn checkbox_edit_returns_none_off_list() {
|
|
|
|
|
let src = "just a paragraph\n";
|
|
|
|
|
let doc = parse(src);
|
2026-05-14 15:00:46 +00:00
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state, true);
|
2026-05-11 17:41:11 +00:00
|
|
|
assert!(edit.is_none());
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn checkbox_edit_finds_item_in_sublist() {
|
|
|
|
|
let src = "- top\n - [ ] nested\n";
|
|
|
|
|
let doc = parse(src);
|
|
|
|
|
// Cursor on line 1, the nested item.
|
2026-05-14 15:00:46 +00:00
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 5, true, ops::toggle_state, true)
|
2026-05-11 17:41:11 +00:00
|
|
|
.expect("edit on nested item");
|
|
|
|
|
let te = one_text_edit(edit);
|
|
|
|
|
assert_eq!(te.new_text, "[X]");
|
|
|
|
|
assert_eq!(te.range.start.line, 1);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// ===== sanity: workspace edit shape =====
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn checkbox_edit_uses_changes_map_not_document_changes() {
|
|
|
|
|
let src = "- [ ] task\n";
|
|
|
|
|
let doc = parse(src);
|
2026-05-14 15:24:35 +00:00
|
|
|
let edit =
|
|
|
|
|
ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state, true).unwrap();
|
2026-05-11 17:41:11 +00:00
|
|
|
assert!(doc_changes_for(edit.clone()).is_none());
|
|
|
|
|
assert!(edit.changes.is_some());
|
|
|
|
|
}
|
2026-05-14 15:00:46 +00:00
|
|
|
|
|
|
|
|
// ===== parent propagation (vimwiki listsyms_propagate) =====
|
|
|
|
|
|
|
|
|
|
/// Collect the (line, new_text) pairs for every TextEdit in the result,
|
|
|
|
|
/// sorted by line. Lets propagation tests assert the parent edit
|
|
|
|
|
/// without caring about edit ordering.
|
|
|
|
|
fn edits_by_line(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Vec<(u32, String)> {
|
|
|
|
|
let changes = edit.changes.expect("expected changes map");
|
|
|
|
|
let (_, edits) = changes.into_iter().next().expect("at least one uri");
|
|
|
|
|
let mut pairs: Vec<(u32, String)> = edits
|
|
|
|
|
.into_iter()
|
|
|
|
|
.map(|e| (e.range.start.line, e.new_text))
|
|
|
|
|
.collect();
|
|
|
|
|
pairs.sort_by_key(|(line, _)| *line);
|
|
|
|
|
pairs
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn propagate_one_of_four_done_marks_parent_quarter() {
|
|
|
|
|
// Toggle the first child of a 4-child parent. Parent should land at
|
|
|
|
|
// [.] (Quarter, 25%).
|
|
|
|
|
let src = "\
|
|
|
|
|
- [ ] root
|
|
|
|
|
- [ ] sub1
|
|
|
|
|
- [ ] sub2
|
|
|
|
|
- [ ] sub3
|
|
|
|
|
- [ ] sub4
|
|
|
|
|
";
|
|
|
|
|
let doc = parse(src);
|
|
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 6, true, ops::toggle_state, true)
|
|
|
|
|
.expect("toggle edit");
|
|
|
|
|
let pairs = edits_by_line(edit);
|
|
|
|
|
assert_eq!(pairs, vec![(0, "[.]".into()), (1, "[X]".into())]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn propagate_two_of_four_done_marks_parent_half() {
|
|
|
|
|
// sub1 is already done; toggling sub2 → done leaves rate=50% → [o].
|
|
|
|
|
let src = "\
|
|
|
|
|
- [ ] root
|
|
|
|
|
- [X] sub1
|
|
|
|
|
- [ ] sub2
|
|
|
|
|
- [ ] sub3
|
|
|
|
|
- [ ] sub4
|
|
|
|
|
";
|
|
|
|
|
let doc = parse(src);
|
|
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 6, true, ops::toggle_state, true)
|
|
|
|
|
.expect("toggle edit");
|
|
|
|
|
let pairs = edits_by_line(edit);
|
|
|
|
|
assert_eq!(pairs, vec![(0, "[o]".into()), (2, "[X]".into())]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn propagate_three_of_four_done_marks_parent_three_quarters() {
|
|
|
|
|
let src = "\
|
|
|
|
|
- [ ] root
|
|
|
|
|
- [X] sub1
|
|
|
|
|
- [X] sub2
|
|
|
|
|
- [ ] sub3
|
|
|
|
|
- [ ] sub4
|
|
|
|
|
";
|
|
|
|
|
let doc = parse(src);
|
|
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 3, 6, true, ops::toggle_state, true)
|
|
|
|
|
.expect("toggle edit");
|
|
|
|
|
let pairs = edits_by_line(edit);
|
|
|
|
|
assert_eq!(pairs, vec![(0, "[O]".into()), (3, "[X]".into())]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn propagate_all_done_marks_parent_done() {
|
|
|
|
|
let src = "\
|
|
|
|
|
- [ ] root
|
|
|
|
|
- [X] sub1
|
|
|
|
|
- [X] sub2
|
|
|
|
|
- [X] sub3
|
|
|
|
|
- [ ] sub4
|
|
|
|
|
";
|
|
|
|
|
let doc = parse(src);
|
|
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 4, 6, true, ops::toggle_state, true)
|
|
|
|
|
.expect("toggle edit");
|
|
|
|
|
let pairs = edits_by_line(edit);
|
|
|
|
|
assert_eq!(pairs, vec![(0, "[X]".into()), (4, "[X]".into())]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn propagate_untoggle_drops_parent_back() {
|
|
|
|
|
// Untoggling the only completed child should put parent back to [ ].
|
|
|
|
|
let src = "\
|
|
|
|
|
- [.] root
|
|
|
|
|
- [X] sub1
|
|
|
|
|
- [ ] sub2
|
|
|
|
|
- [ ] sub3
|
|
|
|
|
- [ ] sub4
|
|
|
|
|
";
|
|
|
|
|
let doc = parse(src);
|
|
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 6, true, ops::toggle_state, true)
|
|
|
|
|
.expect("toggle edit");
|
|
|
|
|
let pairs = edits_by_line(edit);
|
|
|
|
|
assert_eq!(pairs, vec![(0, "[ ]".into()), (1, "[ ]".into())]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn propagate_disabled_only_emits_leaf_edit() {
|
|
|
|
|
let src = "\
|
|
|
|
|
- [ ] root
|
|
|
|
|
- [ ] sub1
|
|
|
|
|
- [ ] sub2
|
|
|
|
|
- [ ] sub3
|
|
|
|
|
- [ ] sub4
|
|
|
|
|
";
|
|
|
|
|
let doc = parse(src);
|
2026-05-14 15:24:35 +00:00
|
|
|
let edit = ops::checkbox_edit(
|
|
|
|
|
src,
|
|
|
|
|
&doc,
|
|
|
|
|
&dummy_uri(),
|
|
|
|
|
1,
|
|
|
|
|
6,
|
|
|
|
|
true,
|
|
|
|
|
ops::toggle_state,
|
|
|
|
|
false,
|
|
|
|
|
)
|
|
|
|
|
.expect("toggle edit");
|
2026-05-14 15:00:46 +00:00
|
|
|
let pairs = edits_by_line(edit);
|
|
|
|
|
assert_eq!(pairs, vec![(1, "[X]".into())]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn propagate_skips_parent_without_checkbox() {
|
|
|
|
|
// Parent has no `[…]`, so propagation has nothing to update.
|
|
|
|
|
let src = "\
|
|
|
|
|
- root
|
|
|
|
|
- [ ] sub1
|
|
|
|
|
- [ ] sub2
|
|
|
|
|
";
|
|
|
|
|
let doc = parse(src);
|
|
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 6, true, ops::toggle_state, true)
|
|
|
|
|
.expect("toggle edit");
|
|
|
|
|
let pairs = edits_by_line(edit);
|
|
|
|
|
assert_eq!(pairs, vec![(1, "[X]".into())]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn propagate_walks_multiple_levels() {
|
|
|
|
|
// Toggle the deepest leaf: grandparent has 1 child (parent), parent
|
|
|
|
|
// has 2 children. Sub1 done → parent half → grandparent half.
|
|
|
|
|
let src = "\
|
|
|
|
|
- [ ] grand
|
|
|
|
|
- [ ] parent
|
|
|
|
|
- [ ] sub1
|
|
|
|
|
- [ ] sub2
|
|
|
|
|
";
|
|
|
|
|
let doc = parse(src);
|
|
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 8, true, ops::toggle_state, true)
|
|
|
|
|
.expect("toggle edit");
|
|
|
|
|
let pairs = edits_by_line(edit);
|
|
|
|
|
assert_eq!(
|
|
|
|
|
pairs,
|
|
|
|
|
vec![(0, "[o]".into()), (1, "[o]".into()), (2, "[X]".into())]
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn propagate_all_rejected_marks_parent_rejected() {
|
|
|
|
|
// Every counted child is rejected → parent becomes [-].
|
|
|
|
|
let src = "\
|
|
|
|
|
- [ ] root
|
|
|
|
|
- [-] sub1
|
|
|
|
|
- [ ] sub2
|
|
|
|
|
";
|
|
|
|
|
let doc = parse(src);
|
|
|
|
|
// Reject sub2 so both children are rejected.
|
|
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 6, true, ops::reject_state, true)
|
|
|
|
|
.expect("reject edit");
|
|
|
|
|
let pairs = edits_by_line(edit);
|
|
|
|
|
assert_eq!(pairs, vec![(0, "[-]".into()), (2, "[-]".into())]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn propagate_rejected_sibling_counts_as_done_for_average() {
|
|
|
|
|
// sub1 rejected, sub2 toggled to done → both "complete" → parent [X].
|
|
|
|
|
let src = "\
|
|
|
|
|
- [ ] root
|
|
|
|
|
- [-] sub1
|
|
|
|
|
- [ ] sub2
|
|
|
|
|
";
|
|
|
|
|
let doc = parse(src);
|
|
|
|
|
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 2, 6, true, ops::toggle_state, true)
|
|
|
|
|
.expect("toggle edit");
|
|
|
|
|
let pairs = edits_by_line(edit);
|
|
|
|
|
assert_eq!(pairs, vec![(0, "[X]".into()), (2, "[X]".into())]);
|
|
|
|
|
}
|