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:
2026-05-30 23:32:15 -03:00
parent 85094d6e3b
commit 3a33d53c22
4 changed files with 363 additions and 155 deletions
+99 -84
View File
@@ -238,7 +238,7 @@ fn file_delete(args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
fn list_checkbox(
backend: &Backend,
args: Vec<Value>,
mutate: fn(&str) -> Option<&'static str>,
mutate: fn(&nuwiki_core::listsyms::ListSyms, &str) -> Option<String>,
) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_pos(args)?;
let doc = match backend.documents.get(&p.uri) {
@@ -247,12 +247,14 @@ 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);
let wiki = backend.wiki_for_uri(&p.uri);
let syms = wiki
.as_ref()
.map(|w| nuwiki_core::listsyms::ListSyms::new(&w.config.listsyms))
.unwrap_or_default();
let propagate = wiki.map(|w| w.config.listsyms_propagate).unwrap_or(true);
Ok(ops::checkbox_edit(
&doc.text, &doc.ast, &p.uri, line, col, utf8, mutate, propagate,
&doc.text, &doc.ast, &p.uri, line, col, utf8, &syms, mutate, propagate,
))
}
@@ -1139,9 +1141,9 @@ fn export_all(backend: &Backend, args: Vec<Value>, force: bool) -> Result<Option
})));
}
let registry = std::sync::Arc::clone(&backend.registry);
let Some(plugin) = registry.get("vimwiki") else {
if registry.get("vimwiki").is_none() {
return Err("vimwiki syntax plugin missing".to_string());
};
}
let files = crate::index::walk_wiki_files(&cfg.root);
let mut exported: Vec<serde_json::Value> = Vec::new();
let mut skipped: Vec<serde_json::Value> = Vec::new();
@@ -1160,7 +1162,10 @@ fn export_all(backend: &Backend, args: Vec<Value>, force: bool) -> Result<Option
let Ok(text) = std::fs::read_to_string(path) else {
continue;
};
let ast = plugin.parse(&text);
let ast = nuwiki_core::syntax::vimwiki::VimwikiSyntax::new().parse_with_listsyms(
&text,
&nuwiki_core::listsyms::ListSyms::new(&cfg.listsyms),
);
(text, ast)
}
};
@@ -1271,52 +1276,79 @@ pub mod ops {
BlockNode, BlockquoteNode, CheckboxState, DocumentNode, HeadingNode, ListItemNode,
ListNode, Span,
};
use nuwiki_core::listsyms::ListSyms;
use tower_lsp::lsp_types::Location;
/// Toggle ` ↔ X. Mid-states (`.`, `o`, `O`) snap forward to `X`.
/// Rejected (`-`) reverts to empty.
pub fn toggle_state(current: &str) -> Option<&'static str> {
match current {
"[ ]" => Some("[X]"),
"[X]" => Some("[ ]"),
"[.]" | "[o]" | "[O]" => Some("[X]"),
"[-]" => Some("[ ]"),
_ => None,
/// The single glyph inside a `[g]` checkbox marker, or `None` when
/// `marker` isn't a well-formed one-glyph marker.
fn marker_glyph(marker: &str) -> Option<char> {
let inner = marker.strip_prefix('[')?.strip_suffix(']')?;
let mut chars = inner.chars();
let glyph = chars.next()?;
if chars.next().is_some() {
return None;
}
Some(glyph)
}
fn marker_for(glyph: char) -> String {
format!("[{glyph}]")
}
/// Toggle empty ↔ done. Mid-states snap forward to done; rejected reverts
/// to empty.
pub fn toggle_state(syms: &ListSyms, current: &str) -> Option<String> {
let glyph = marker_glyph(current)?;
if glyph == syms.rejected_glyph() {
return Some(marker_for(syms.empty_glyph()));
}
let index = syms.index_of(glyph)?;
if index == syms.len() - 1 {
Some(marker_for(syms.empty_glyph()))
} else {
Some(marker_for(syms.done_glyph()))
}
}
/// `[ ]` → `[.]` → `[o]` → `[O]` → `[X]` → `[ ]`. `[-]` → `[ ]`.
pub fn cycle_state(current: &str) -> Option<&'static str> {
match current {
"[ ]" => Some("[.]"),
"[.]" => Some("[o]"),
"[o]" => Some("[O]"),
"[O]" => Some("[X]"),
"[X]" => Some("[ ]"),
"[-]" => Some("[ ]"),
_ => None,
/// Advance one step through the palette, wrapping done → empty.
/// Rejected reverts to empty.
pub fn cycle_state(syms: &ListSyms, current: &str) -> Option<String> {
let glyph = marker_glyph(current)?;
if glyph == syms.rejected_glyph() {
return Some(marker_for(syms.empty_glyph()));
}
let index = syms.index_of(glyph)?;
let next = if index + 1 >= syms.len() {
0
} else {
index + 1
};
Some(marker_for(syms.glyph_at(next)))
}
/// Exact inverse of [`cycle_state`] — used by the `glp` mapping
/// ("cycle the checkbox state backward").
pub fn cycle_state_back(current: &str) -> Option<&'static str> {
match current {
"[ ]" => Some("[X]"),
"[X]" => Some("[O]"),
"[O]" => Some("[o]"),
"[o]" => Some("[.]"),
"[.]" => Some("[ ]"),
"[-]" => Some("[ ]"),
_ => None,
/// ("cycle the checkbox state backward"). Rejected reverts to empty.
pub fn cycle_state_back(syms: &ListSyms, current: &str) -> Option<String> {
let glyph = marker_glyph(current)?;
if glyph == syms.rejected_glyph() {
return Some(marker_for(syms.empty_glyph()));
}
let index = syms.index_of(glyph)?;
let prev = if index == 0 {
syms.len() - 1
} else {
index - 1
};
Some(marker_for(syms.glyph_at(prev)))
}
/// Toggle the `[-]` "rejected" state.
pub fn reject_state(current: &str) -> Option<&'static str> {
match current {
"[-]" => Some("[ ]"),
_ => Some("[-]"),
/// Toggle the rejected state.
pub fn reject_state(syms: &ListSyms, current: &str) -> Option<String> {
let glyph = marker_glyph(current)?;
if glyph == syms.rejected_glyph() {
Some(marker_for(syms.empty_glyph()))
} else {
Some(marker_for(syms.rejected_glyph()))
}
}
@@ -1328,18 +1360,19 @@ pub mod ops {
line: u32,
col: u32,
utf8: bool,
mutate: fn(&str) -> Option<&'static str>,
syms: &ListSyms,
mutate: fn(&ListSyms, &str) -> Option<String>,
propagate: bool,
) -> Option<WorkspaceEdit> {
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 new = mutate(syms, cur)?;
let mut b = WorkspaceEditBuilder::new();
b.edit(
uri.clone(),
text_edit_replace(cb_span, new.to_string(), text, utf8),
text_edit_replace(cb_span, new.clone(), text, utf8),
);
if propagate && path.len() > 1 {
@@ -1348,23 +1381,24 @@ pub mod ops {
// 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;
let mut child_marker: String = 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,
};
let new_parent =
match compute_parent_marker(syms, 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),
text_edit_replace(parent_cb_span, new_parent.clone(), text, utf8),
);
child_node = parent;
child_marker = new_parent;
@@ -1376,17 +1410,13 @@ pub mod ops {
/// 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,
/// `None` for markers whose glyph isn't in the palette.
fn marker_to_rate(syms: &ListSyms, marker: &str) -> Option<i32> {
let glyph = marker_glyph(marker)?;
if glyph == syms.rejected_glyph() {
return Some(-1);
}
Some(syms.rate(syms.index_of(glyph)?))
}
fn checkbox_state_to_rate(state: CheckboxState) -> i32 {
@@ -1401,25 +1431,9 @@ pub mod ops {
}
/// 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]",
}
/// Mirrors vimwiki's `s:rate_to_state`, generalised over the palette.
fn rate_to_marker(syms: &ListSyms, rate: i32) -> String {
marker_for(syms.glyph_for_rate(rate))
}
/// Compute the new marker for `parent` given that `modified_child`
@@ -1431,17 +1445,18 @@ pub mod ops {
/// count as 100% for the average, but if *every* child is rejected
/// the parent itself becomes rejected.
fn compute_parent_marker(
syms: &ListSyms,
parent: &ListItemNode,
modified_child: &ListItemNode,
modified_marker: &str,
) -> Option<&'static str> {
) -> Option<String> {
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)?
marker_to_rate(syms, modified_marker)?
} else {
match item.checkbox {
Some(state) => checkbox_state_to_rate(state),
@@ -1464,7 +1479,7 @@ pub mod ops {
} else {
sum / count_with_cb
};
Some(rate_to_marker(new_rate))
Some(rate_to_marker(syms, new_rate))
}
pub fn next_task(
+4 -3
View File
@@ -48,9 +48,10 @@ pub struct WikiConfig {
pub diary_sort: String,
/// H1 text for the diary index page.
pub diary_header: String,
/// Vimwiki's `g:vimwiki_listsyms`. The 5-character string maps
/// fractional progress states for checkboxes; the i-th char (0..=4)
/// is the rendered marker for state `i / 4`. Default: `" .oOX"`.
/// Vimwiki's `g:vimwiki_listsyms`. A progression of checkbox glyphs
/// from "empty" (first) to "done" (last); the lexer recognises these
/// glyphs and the toggle/cycle commands walk them. Any length ≥ 2 is
/// honoured. Default: `" .oOX"`.
pub listsyms: String,
/// When toggling a parent list item, also cascade to descendants.
pub listsyms_propagate: bool,
+22 -15
View File
@@ -187,6 +187,17 @@ impl Backend {
id_or_default.and_then(|id| self.wiki(id))
}
/// Parse `text` as vimwiki, honouring the checkbox palette
/// (`listsyms`) of the wiki owning `uri`. Falls back to the default
/// palette when no wiki matches.
fn parse_for_uri(&self, uri: &Url, text: &str) -> nuwiki_core::ast::DocumentNode {
let syms = self
.wiki_for_uri(uri)
.map(|w| nuwiki_core::listsyms::ListSyms::new(&w.config.listsyms))
.unwrap_or_default();
nuwiki_core::syntax::vimwiki::VimwikiSyntax::new().parse_with_listsyms(text, &syms)
}
/// Snapshot of every registered wiki — held by value so callers can
/// drop the lock before awaiting or building responses.
pub(crate) fn wikis_snapshot(&self) -> Vec<Wiki> {
@@ -304,11 +315,10 @@ impl Backend {
.to_file_path()
.map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "non-file URI"))?;
let text = tokio::fs::read_to_string(&path).await?;
let plugin = self
.registry
.get("vimwiki")
.ok_or_else(|| io::Error::new(io::ErrorKind::Other, "vimwiki syntax plugin missing"))?;
let ast = plugin.parse(&text);
if self.registry.get("vimwiki").is_none() {
return Err(io::Error::other("vimwiki syntax plugin missing"));
}
let ast = self.parse_for_uri(uri, &text);
Ok(DocSnapshot {
text,
ast,
@@ -319,16 +329,13 @@ impl Backend {
async fn update_document(&self, uri: Url, text: String, version: i32) {
// We always treat unknown buffers as vimwiki — if/when
// multi-syntax dispatch lands the pick should follow the URI's ext.
let plugin = match self.registry.get("vimwiki") {
Some(p) => p,
None => {
self.client
.log_message(MessageType::ERROR, "vimwiki plugin missing")
.await;
return;
}
};
let ast = plugin.parse(&text);
if self.registry.get("vimwiki").is_none() {
self.client
.log_message(MessageType::ERROR, "vimwiki plugin missing")
.await;
return;
}
let ast = self.parse_for_uri(&uri, &text);
let utf8 = self.use_utf8.load(Ordering::Relaxed);
// Compute diagnostics + update the matching wiki's index. All locks