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>
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
//! 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;
|
||||
@@ -11,6 +12,10 @@ 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()
|
||||
}
|
||||
@@ -30,46 +35,65 @@ fn doc_changes_for(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Option<Document
|
||||
|
||||
#[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);
|
||||
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() {
|
||||
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("[ ]"));
|
||||
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.
|
||||
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("[ ]"));
|
||||
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 s in ["[ ]", "[.]", "[o]", "[O]", "[X]"] {
|
||||
let fwd = ops::cycle_state(s).unwrap();
|
||||
assert_eq!(ops::cycle_state_back(fwd), Some(s));
|
||||
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() {
|
||||
assert_eq!(ops::reject_state("[ ]"), Some("[-]"));
|
||||
assert_eq!(ops::reject_state("[X]"), Some("[-]"));
|
||||
assert_eq!(ops::reject_state("[-]"), Some("[ ]"));
|
||||
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 =====
|
||||
@@ -78,8 +102,18 @@ fn reject_state_toggles_dash_marker() {
|
||||
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, ops::toggle_state, true)
|
||||
.expect("toggle edit");
|
||||
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 = "[ ]"
|
||||
@@ -90,8 +124,18 @@ fn toggle_empty_checkbox_to_done() {
|
||||
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, ops::cycle_state, true)
|
||||
.expect("cycle edit");
|
||||
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]");
|
||||
}
|
||||
@@ -100,8 +144,18 @@ fn cycle_advances_partial_states() {
|
||||
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, ops::reject_state, true)
|
||||
.expect("reject edit");
|
||||
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, "[-]");
|
||||
}
|
||||
@@ -111,7 +165,17 @@ 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, ops::toggle_state, true);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
@@ -119,7 +183,17 @@ fn checkbox_edit_returns_none_on_plain_list_item() {
|
||||
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, ops::toggle_state, true);
|
||||
let edit = ops::checkbox_edit(
|
||||
src,
|
||||
&doc,
|
||||
&dummy_uri(),
|
||||
0,
|
||||
0,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
true,
|
||||
);
|
||||
assert!(edit.is_none());
|
||||
}
|
||||
|
||||
@@ -128,8 +202,18 @@ 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, ops::toggle_state, true)
|
||||
.expect("edit on 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);
|
||||
@@ -141,8 +225,18 @@ fn checkbox_edit_finds_item_in_sublist() {
|
||||
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, ops::toggle_state, true).unwrap();
|
||||
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());
|
||||
}
|
||||
@@ -175,8 +269,18 @@ fn propagate_one_of_four_done_marks_parent_quarter() {
|
||||
- [ ] 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 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())]);
|
||||
}
|
||||
@@ -192,8 +296,18 @@ fn propagate_two_of_four_done_marks_parent_half() {
|
||||
- [ ] 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 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())]);
|
||||
}
|
||||
@@ -208,8 +322,18 @@ fn propagate_three_of_four_done_marks_parent_three_quarters() {
|
||||
- [ ] 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 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())]);
|
||||
}
|
||||
@@ -224,8 +348,18 @@ fn propagate_all_done_marks_parent_done() {
|
||||
- [ ] 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 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())]);
|
||||
}
|
||||
@@ -241,8 +375,18 @@ fn propagate_untoggle_drops_parent_back() {
|
||||
- [ ] 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 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())]);
|
||||
}
|
||||
@@ -264,6 +408,7 @@ fn propagate_disabled_only_emits_leaf_edit() {
|
||||
1,
|
||||
6,
|
||||
true,
|
||||
&syms(),
|
||||
ops::toggle_state,
|
||||
false,
|
||||
)
|
||||
@@ -281,8 +426,18 @@ fn propagate_skips_parent_without_checkbox() {
|
||||
- [ ] 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 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())]);
|
||||
}
|
||||
@@ -298,8 +453,18 @@ fn propagate_walks_multiple_levels() {
|
||||
- [ ] 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 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,
|
||||
@@ -317,8 +482,18 @@ fn propagate_all_rejected_marks_parent_rejected() {
|
||||
";
|
||||
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 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())]);
|
||||
}
|
||||
@@ -332,8 +507,18 @@ fn propagate_rejected_sibling_counts_as_done_for_average() {
|
||||
- [ ] 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 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())]);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user