fix(lists): propagate checkbox state to ancestor list items on toggle

Toggling/cycling/rejecting a nested checkbox now recomputes each
ancestor's progress marker (vimwiki's listsyms_propagate). Mirrors
vimwiki's averaging — rejected items count as 100% unless every child
is rejected, in which case the parent itself becomes rejected. Walks
up while each ancestor has its own checkbox.

Also tightens find_checkbox_span to scan only the marker line, since a
parent item's span covers its nested sublist and was matching child
markers as if they belonged to the parent.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-14 15:00:46 +00:00
parent 695702555f
commit 801896926d
2 changed files with 371 additions and 10 deletions
+182 -3
View File
@@ -231,8 +231,12 @@ fn list_checkbox(
}; };
let utf8 = backend.use_utf8.load(Ordering::Relaxed); let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8); let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8);
let propagate = backend
.wiki_for_uri(&p.uri)
.map(|w| w.config.listsyms_propagate)
.unwrap_or(true);
Ok(ops::checkbox_edit( Ok(ops::checkbox_edit(
&doc.text, &doc.ast, &p.uri, line, col, utf8, mutate, &doc.text, &doc.ast, &p.uri, line, col, utf8, mutate, propagate,
)) ))
} }
@@ -1216,6 +1220,7 @@ pub mod ops {
} }
} }
#[allow(clippy::too_many_arguments)]
pub fn checkbox_edit( pub fn checkbox_edit(
text: &str, text: &str,
ast: &DocumentNode, ast: &DocumentNode,
@@ -1224,9 +1229,11 @@ pub mod ops {
col: u32, col: u32,
utf8: bool, utf8: bool,
mutate: fn(&str) -> Option<&'static str>, mutate: fn(&str) -> Option<&'static str>,
propagate: bool,
) -> Option<WorkspaceEdit> { ) -> Option<WorkspaceEdit> {
let item = find_list_item_at(ast, line, col)?; let path = find_path_to_item(ast, line, col)?;
let cb_span = find_checkbox_span(text, item.span)?; let leaf = *path.last()?;
let cb_span = find_checkbox_span(text, leaf.span)?;
let cur = &text[cb_span.start.offset..cb_span.end.offset]; let cur = &text[cb_span.start.offset..cb_span.end.offset];
let new = mutate(cur)?; let new = mutate(cur)?;
let mut b = WorkspaceEditBuilder::new(); let mut b = WorkspaceEditBuilder::new();
@@ -1234,9 +1241,132 @@ pub mod ops {
uri.clone(), uri.clone(),
text_edit_replace(cb_span, new.to_string(), text, utf8), text_edit_replace(cb_span, new.to_string(), text, utf8),
); );
if propagate && path.len() > 1 {
// Vimwiki's listsyms_propagate: walk up the chain, recomputing
// each ancestor's marker from the average rate of its immediate
// children (using the modified child's *new* state). Stops at
// the first ancestor that has no checkbox.
let mut child_node: &ListItemNode = leaf;
let mut child_marker: &str = new;
for parent in path.iter().rev().skip(1) {
let parent_cb_span = match find_checkbox_span(text, parent.span) {
Some(s) => s,
None => break,
};
let parent_cur = &text[parent_cb_span.start.offset..parent_cb_span.end.offset];
let new_parent = match compute_parent_marker(parent, child_node, child_marker) {
Some(m) => m,
None => break,
};
if new_parent == parent_cur {
break;
}
b.edit(
uri.clone(),
text_edit_replace(parent_cb_span, new_parent.to_string(), text, utf8),
);
child_node = parent;
child_marker = new_parent;
}
}
Some(b.build()) Some(b.build())
} }
/// Convert a marker string like `"[X]"` to its progress rate. `-1`
/// signals rejected (handled specially when averaging). Returns
/// `None` for unrecognised markers.
fn marker_to_rate(marker: &str) -> Option<i32> {
match marker {
"[ ]" => Some(0),
"[.]" => Some(25),
"[o]" => Some(50),
"[O]" => Some(75),
"[X]" => Some(100),
"[-]" => Some(-1),
_ => None,
}
}
fn checkbox_state_to_rate(state: CheckboxState) -> i32 {
match state {
CheckboxState::Empty => 0,
CheckboxState::Quarter => 25,
CheckboxState::Half => 50,
CheckboxState::ThreeQuarters => 75,
CheckboxState::Done => 100,
CheckboxState::Rejected => -1,
}
}
/// Inverse of `marker_to_rate`. `rate` is 0..=100 or `-1` (rejected).
/// Mirrors vimwiki's `s:rate_to_state` for the standard 5-symbol
/// `' .oOX'` palette.
fn rate_to_marker(rate: i32) -> &'static str {
if rate == -1 {
return "[-]";
}
if rate <= 0 {
return "[ ]";
}
if rate >= 100 {
return "[X]";
}
// n=5 symbols, so n-2=3. ceil(rate/100 * 3) ∈ {1,2,3}
let idx = ((rate as f64) / 100.0 * 3.0).ceil() as i32;
match idx {
1 => "[.]",
2 => "[o]",
_ => "[O]",
}
}
/// Compute the new marker for `parent` given that `modified_child`
/// (one of its immediate sublist items) just became
/// `modified_marker`. Returns `None` if the parent has no children
/// with checkboxes — there's nothing to average from.
///
/// Mirrors vimwiki's `s:update_state` averaging: rejected children
/// count as 100% for the average, but if *every* child is rejected
/// the parent itself becomes rejected.
fn compute_parent_marker(
parent: &ListItemNode,
modified_child: &ListItemNode,
modified_marker: &str,
) -> Option<&'static str> {
let sub = parent.sublist.as_ref()?;
let mut sum: i32 = 0;
let mut count_with_cb: i32 = 0;
let mut count_rejected: i32 = 0;
for item in &sub.items {
let rate = if std::ptr::eq(item, modified_child) {
marker_to_rate(modified_marker)?
} else {
match item.checkbox {
Some(state) => checkbox_state_to_rate(state),
None => continue,
}
};
count_with_cb += 1;
if rate == -1 {
count_rejected += 1;
sum += 100;
} else {
sum += rate;
}
}
if count_with_cb == 0 {
return None;
}
let new_rate = if count_rejected == count_with_cb {
-1
} else {
sum / count_with_cb
};
Some(rate_to_marker(new_rate))
}
pub fn next_task( pub fn next_task(
ast: &DocumentNode, ast: &DocumentNode,
uri: &Url, uri: &Url,
@@ -1374,6 +1504,9 @@ pub mod ops {
/// Find the `[X]` (or other checkbox) substring within a list item's /// Find the `[X]` (or other checkbox) substring within a list item's
/// source span. Returns a span covering exactly the 3-byte run. /// source span. Returns a span covering exactly the 3-byte run.
/// Searches only the marker line — a parent item's span covers its
/// nested sublist too, and we don't want a child's checkbox to look
/// like the parent's.
pub fn find_checkbox_span(text: &str, item_span: Span) -> Option<Span> { pub fn find_checkbox_span(text: &str, item_span: Span) -> Option<Span> {
let start = item_span.start.offset; let start = item_span.start.offset;
let end = item_span.end.offset.min(text.len()); let end = item_span.end.offset.min(text.len());
@@ -1382,6 +1515,8 @@ pub mod ops {
} }
let segment = &text[start..end]; let segment = &text[start..end];
let bytes = segment.as_bytes(); let bytes = segment.as_bytes();
let line_end = bytes.iter().position(|&b| b == b'\n').unwrap_or(bytes.len());
let bytes = &bytes[..line_end];
let mut i = 0; let mut i = 0;
while i + 2 < bytes.len() { while i + 2 < bytes.len() {
if bytes[i] == b'[' if bytes[i] == b'['
@@ -1444,6 +1579,50 @@ pub mod ops {
None None
} }
/// Return the chain of list items from outermost ancestor to the
/// deepest item containing `line`. Used by checkbox propagation so
/// it can update each ancestor in turn.
pub fn find_path_to_item(
ast: &DocumentNode,
line: u32,
col: u32,
) -> Option<Vec<&ListItemNode>> {
for block in &ast.children {
if let Some(path) = path_in_block(block, line, col) {
return Some(path);
}
}
None
}
fn path_in_block(block: &BlockNode, line: u32, col: u32) -> Option<Vec<&ListItemNode>> {
match block {
BlockNode::List(l) => l.items.iter().find_map(|it| path_in_item(it, line, col)),
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
children.iter().find_map(|c| path_in_block(c, line, col))
}
_ => None,
}
}
fn path_in_item(item: &ListItemNode, line: u32, col: u32) -> Option<Vec<&ListItemNode>> {
if let Some(sub) = &item.sublist {
for sub_it in &sub.items {
if let Some(mut deeper) = path_in_item(sub_it, line, col) {
let mut path = Vec::with_capacity(deeper.len() + 1);
path.push(item);
path.append(&mut deeper);
return Some(path);
}
}
}
if (item.span.start.line..=item.span.end.line).contains(&line) {
return Some(vec![item]);
}
let _ = col;
None
}
// ===== Phase 15: TOC + Links generation, workspace queries ===== // ===== Phase 15: TOC + Links generation, workspace queries =====
use serde::Serialize; use serde::Serialize;
+189 -7
View File
@@ -60,7 +60,7 @@ fn reject_state_toggles_dash_marker() {
fn toggle_empty_checkbox_to_done() { fn toggle_empty_checkbox_to_done() {
let src = "- [ ] task\n"; let src = "- [ ] task\n";
let doc = parse(src); let doc = parse(src);
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state) let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state, true)
.expect("toggle edit"); .expect("toggle edit");
let te = one_text_edit(edit); let te = one_text_edit(edit);
assert_eq!(te.new_text, "[X]"); assert_eq!(te.new_text, "[X]");
@@ -72,7 +72,7 @@ fn toggle_empty_checkbox_to_done() {
fn cycle_advances_partial_states() { fn cycle_advances_partial_states() {
let src = "- [.] half\n"; let src = "- [.] half\n";
let doc = parse(src); let doc = parse(src);
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::cycle_state) let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::cycle_state, true)
.expect("cycle edit"); .expect("cycle edit");
let te = one_text_edit(edit); let te = one_text_edit(edit);
assert_eq!(te.new_text, "[o]"); assert_eq!(te.new_text, "[o]");
@@ -82,7 +82,7 @@ fn cycle_advances_partial_states() {
fn reject_replaces_empty_with_dash() { fn reject_replaces_empty_with_dash() {
let src = "* [ ] thing\n"; let src = "* [ ] thing\n";
let doc = parse(src); let doc = parse(src);
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::reject_state) let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::reject_state, true)
.expect("reject edit"); .expect("reject edit");
let te = one_text_edit(edit); let te = one_text_edit(edit);
assert_eq!(te.new_text, "[-]"); assert_eq!(te.new_text, "[-]");
@@ -93,7 +93,7 @@ fn checkbox_edit_returns_none_on_plain_list_item() {
// No `[…]` at all → no edit. // No `[…]` at all → no edit.
let src = "- plain item\n"; let src = "- plain item\n";
let doc = parse(src); let doc = parse(src);
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state); let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state, true);
assert!(edit.is_none()); assert!(edit.is_none());
} }
@@ -101,7 +101,7 @@ fn checkbox_edit_returns_none_on_plain_list_item() {
fn checkbox_edit_returns_none_off_list() { fn checkbox_edit_returns_none_off_list() {
let src = "just a paragraph\n"; let src = "just a paragraph\n";
let doc = parse(src); let doc = parse(src);
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state); let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state, true);
assert!(edit.is_none()); assert!(edit.is_none());
} }
@@ -110,7 +110,7 @@ fn checkbox_edit_finds_item_in_sublist() {
let src = "- top\n - [ ] nested\n"; let src = "- top\n - [ ] nested\n";
let doc = parse(src); let doc = parse(src);
// Cursor on line 1, the nested item. // Cursor on line 1, the nested item.
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 5, true, ops::toggle_state) let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 5, true, ops::toggle_state, true)
.expect("edit on nested item"); .expect("edit on nested item");
let te = one_text_edit(edit); let te = one_text_edit(edit);
assert_eq!(te.new_text, "[X]"); assert_eq!(te.new_text, "[X]");
@@ -123,7 +123,189 @@ fn checkbox_edit_finds_item_in_sublist() {
fn checkbox_edit_uses_changes_map_not_document_changes() { fn checkbox_edit_uses_changes_map_not_document_changes() {
let src = "- [ ] task\n"; let src = "- [ ] task\n";
let doc = parse(src); let doc = parse(src);
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state).unwrap(); let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state, true).unwrap();
assert!(doc_changes_for(edit.clone()).is_none()); assert!(doc_changes_for(edit.clone()).is_none());
assert!(edit.changes.is_some()); 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, 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);
let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 6, true, 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, 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())]);
}