diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index d74287a..7c3f849 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -231,8 +231,12 @@ fn list_checkbox( }; let utf8 = backend.use_utf8.load(Ordering::Relaxed); 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( - &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( text: &str, ast: &DocumentNode, @@ -1224,9 +1229,11 @@ pub mod ops { col: u32, utf8: bool, mutate: fn(&str) -> Option<&'static str>, + propagate: bool, ) -> Option { - let item = find_list_item_at(ast, line, col)?; - let cb_span = find_checkbox_span(text, item.span)?; + let path = find_path_to_item(ast, line, col)?; + 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 new = mutate(cur)?; let mut b = WorkspaceEditBuilder::new(); @@ -1234,9 +1241,132 @@ pub mod ops { uri.clone(), 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()) } + /// 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 { + 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( ast: &DocumentNode, uri: &Url, @@ -1374,6 +1504,9 @@ pub mod ops { /// Find the `[X]` (or other checkbox) substring within a list item's /// 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 { let start = item_span.start.offset; let end = item_span.end.offset.min(text.len()); @@ -1382,6 +1515,8 @@ pub mod ops { } let segment = &text[start..end]; 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; while i + 2 < bytes.len() { if bytes[i] == b'[' @@ -1444,6 +1579,50 @@ pub mod ops { 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> { + 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> { + 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> { + 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 ===== use serde::Serialize; diff --git a/crates/nuwiki-lsp/tests/commands_checkboxes.rs b/crates/nuwiki-lsp/tests/commands_checkboxes.rs index 0c9bff2..c04d46f 100644 --- a/crates/nuwiki-lsp/tests/commands_checkboxes.rs +++ b/crates/nuwiki-lsp/tests/commands_checkboxes.rs @@ -60,7 +60,7 @@ 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) + let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 4, true, ops::toggle_state, true) .expect("toggle edit"); let te = one_text_edit(edit); assert_eq!(te.new_text, "[X]"); @@ -72,7 +72,7 @@ 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) + let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::cycle_state, true) .expect("cycle edit"); let te = one_text_edit(edit); assert_eq!(te.new_text, "[o]"); @@ -82,7 +82,7 @@ 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) + let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::reject_state, true) .expect("reject edit"); let te = one_text_edit(edit); assert_eq!(te.new_text, "[-]"); @@ -93,7 +93,7 @@ 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); + let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state, true); assert!(edit.is_none()); } @@ -101,7 +101,7 @@ 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); + let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 0, 0, true, ops::toggle_state, true); assert!(edit.is_none()); } @@ -110,7 +110,7 @@ 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) + let edit = ops::checkbox_edit(src, &doc, &dummy_uri(), 1, 5, true, ops::toggle_state, true) .expect("edit on nested item"); let te = one_text_edit(edit); 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() { let src = "- [ ] task\n"; 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!(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())]); +}