Files
nuwiki/crates/nuwiki-lsp/tests/commands_checkboxes.rs
gffranco 3a33d53c22 feat(lsp): drive checkbox commands from the wiki listsyms palette
Parse editor and export documents with each wiki's configured listsyms,
and make toggle/cycle/reject plus parent-propagation walk the real
palette glyphs instead of the hardcoded ' .oOX'. Falls back to the
default palette when no wiki matches the URI.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 23:32:15 -03:00

525 lines
13 KiB
Rust

//! Checkbox state transitions and the `checkbox_edit` rewriter that
//! emits `WorkspaceEdit`s for `nuwiki.list.toggleCheckbox`,
//! `nuwiki.list.cycleCheckbox`, and `nuwiki.list.rejectCheckbox`.
use nuwiki_core::listsyms::ListSyms;
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 syms() -> ListSyms {
ListSyms::default()
}
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() {
let s = syms();
assert_eq!(ops::toggle_state(&s, "[ ]").as_deref(), Some("[X]"));
assert_eq!(ops::toggle_state(&s, "[X]").as_deref(), Some("[ ]"));
assert_eq!(ops::toggle_state(&s, "[.]").as_deref(), Some("[X]"));
assert_eq!(ops::toggle_state(&s, "[-]").as_deref(), Some("[ ]"));
assert_eq!(ops::toggle_state(&s, "[?]").as_deref(), None);
}
#[test]
fn cycle_state_walks_progression() {
let s = syms();
assert_eq!(ops::cycle_state(&s, "[ ]").as_deref(), Some("[.]"));
assert_eq!(ops::cycle_state(&s, "[.]").as_deref(), Some("[o]"));
assert_eq!(ops::cycle_state(&s, "[o]").as_deref(), Some("[O]"));
assert_eq!(ops::cycle_state(&s, "[O]").as_deref(), Some("[X]"));
assert_eq!(ops::cycle_state(&s, "[X]").as_deref(), Some("[ ]"));
assert_eq!(ops::cycle_state(&s, "[-]").as_deref(), Some("[ ]"));
}
#[test]
fn cycle_state_back_is_exact_inverse() {
// `glp` walks the progression in reverse — every step must undo the
// matching `cycle_state` step.
let s = syms();
assert_eq!(ops::cycle_state_back(&s, "[ ]").as_deref(), Some("[X]"));
assert_eq!(ops::cycle_state_back(&s, "[X]").as_deref(), Some("[O]"));
assert_eq!(ops::cycle_state_back(&s, "[O]").as_deref(), Some("[o]"));
assert_eq!(ops::cycle_state_back(&s, "[o]").as_deref(), Some("[.]"));
assert_eq!(ops::cycle_state_back(&s, "[.]").as_deref(), Some("[ ]"));
assert_eq!(ops::cycle_state_back(&s, "[-]").as_deref(), Some("[ ]"));
// Composing forward then backward returns to the start for every
// in-cycle marker.
for m in ["[ ]", "[.]", "[o]", "[O]", "[X]"] {
let fwd = ops::cycle_state(&s, m).unwrap();
assert_eq!(ops::cycle_state_back(&s, &fwd).as_deref(), Some(m));
}
}
#[test]
fn reject_state_toggles_dash_marker() {
let s = syms();
assert_eq!(ops::reject_state(&s, "[ ]").as_deref(), Some("[-]"));
assert_eq!(ops::reject_state(&s, "[X]").as_deref(), Some("[-]"));
assert_eq!(ops::reject_state(&s, "[-]").as_deref(), Some("[ ]"));
}
#[test]
fn custom_palette_three_symbols() {
// A 3-glyph palette `" x✓"`: empty/half/done. `[x]` is the only
// intermediate; cycling walks ` `→`x`→`✓`→` `.
let s = ListSyms::new(" x✓");
assert_eq!(ops::cycle_state(&s, "[ ]").as_deref(), Some("[x]"));
assert_eq!(ops::cycle_state(&s, "[x]").as_deref(), Some("[✓]"));
assert_eq!(ops::cycle_state(&s, "[✓]").as_deref(), Some("[ ]"));
// Default-palette glyphs are not in this palette → no transition.
assert_eq!(ops::cycle_state(&s, "[o]").as_deref(), None);
// Toggle still maps empty→done, done→empty.
assert_eq!(ops::toggle_state(&s, "[ ]").as_deref(), Some("[✓]"));
assert_eq!(ops::toggle_state(&s, "[✓]").as_deref(), Some("[ ]"));
}
// ===== checkbox_edit =====
#[test]
fn toggle_empty_checkbox_to_done() {
let src = "- [ ] task\n";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
4,
true,
&syms(),
ops::toggle_state,
true,
)
.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);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
0,
true,
&syms(),
ops::cycle_state,
true,
)
.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);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
0,
true,
&syms(),
ops::reject_state,
true,
)
.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);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
0,
true,
&syms(),
ops::toggle_state,
true,
);
assert!(edit.is_none());
}
#[test]
fn checkbox_edit_returns_none_off_list() {
let src = "just a paragraph\n";
let doc = parse(src);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
0,
true,
&syms(),
ops::toggle_state,
true,
);
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.
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
1,
5,
true,
&syms(),
ops::toggle_state,
true,
)
.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);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
0,
4,
true,
&syms(),
ops::toggle_state,
true,
)
.unwrap();
assert!(doc_changes_for(edit.clone()).is_none());
assert!(edit.changes.is_some());
}
// ===== 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,
&syms(),
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,
&syms(),
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,
&syms(),
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,
&syms(),
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,
&syms(),
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);
let edit = ops::checkbox_edit(
src,
&doc,
&dummy_uri(),
1,
6,
true,
&syms(),
ops::toggle_state,
false,
)
.expect("toggle edit");
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,
&syms(),
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,
&syms(),
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,
&syms(),
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,
&syms(),
ops::toggle_state,
true,
)
.expect("toggle edit");
let pairs = edits_by_line(edit);
assert_eq!(pairs, vec![(0, "[X]".into()), (2, "[X]".into())]);
}