feat(lsp): add list.removeCheckbox and link.catUrl commands

removeCheckbox strips the `[ ]` marker (plus its trailing space) from
the current item or every item in the list. catUrl returns the
`file://` URL of the current page's HTML export, mirroring vimwiki's
:VimwikiCatUrl.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 17:32:41 -03:00
parent b1155dee6a
commit 8d8d85daf9
3 changed files with 160 additions and 1 deletions
+119
View File
@@ -68,9 +68,11 @@ pub const COMMANDS: &[&str] = &[
"nuwiki.link.pasteWikilink",
"nuwiki.link.pasteUrl",
"nuwiki.list.removeDone",
"nuwiki.list.removeCheckbox",
"nuwiki.list.renumber",
"nuwiki.list.changeSymbol",
"nuwiki.list.changeLevel",
"nuwiki.link.catUrl",
"nuwiki.table.insert",
"nuwiki.table.align",
"nuwiki.table.moveColumn",
@@ -183,6 +185,10 @@ pub(crate) async fn execute(
"nuwiki.list.removeDone" => {
list_remove_done(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.list.removeCheckbox" => {
list_remove_checkbox(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.link.catUrl" => link_cat_url(backend, args).map(|o| o.map(CommandOutcome::Value)),
"nuwiki.list.renumber" => list_renumber(backend, args).map(|o| o.map(CommandOutcome::Edit)),
"nuwiki.list.changeSymbol" => {
list_change_symbol(backend, args).map(|o| o.map(CommandOutcome::Edit))
@@ -854,6 +860,55 @@ fn list_remove_done(backend: &Backend, args: Vec<Value>) -> Result<Option<Worksp
))
}
fn list_remove_checkbox(
backend: &Backend,
args: Vec<Value>,
) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
position: LspPosition,
#[serde(default)]
whole_list: bool,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri, position } argument".to_string())?;
let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?;
let doc = match backend.documents.get(&parsed.uri) {
Some(d) => d,
None => return Ok(None),
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let (line, _) = nav::lsp_to_byte_pos(parsed.position, &doc.text, utf8);
Ok(ops::remove_checkbox_edit(
&doc.text,
&doc.ast,
&parsed.uri,
line,
parsed.whole_list,
utf8,
))
}
/// Compute the `file://` URL of the current page's HTML export, returned
/// to the client. Mirrors vimwiki's `VimwikiCatUrl`, which echoes
/// `file://<html-export-path>` for the current file.
fn link_cat_url(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
let p = parse_uri_arg(args)?;
let Some(wiki) = backend.wiki_for_uri(&p.uri) else {
return Ok(None);
};
let cfg = &wiki.config;
let name = crate::index::page_name_from_uri(&p.uri, Some(&cfg.root));
let out_path = crate::export::output_path_for(&cfg.html, &name);
let url = Url::from_file_path(&out_path)
.map(|u| u.to_string())
.unwrap_or_else(|_| format!("file://{}", out_path.display()));
Ok(Some(Value::from(url)))
}
fn list_renumber(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
@@ -2161,6 +2216,70 @@ pub mod ops {
Some(b.build())
}
/// Strip the `[ ]` checkbox (plus a single trailing space) from the
/// item at `line`, or from every item in the list when `whole_list`
/// is set. Leaves the marker and content intact — only the checkbox
/// disappears.
pub fn remove_checkbox_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
whole_list: bool,
utf8: bool,
) -> Option<WorkspaceEdit> {
let mut spans: Vec<Span> = Vec::new();
if whole_list {
let list = find_list_at_line(ast, line)?;
collect_checkbox_spans(list, text, &mut spans);
} else {
let item = find_list_item_at(ast, line, 0)?;
if let Some(s) = remove_checkbox_span(text, item.span) {
spans.push(s);
}
}
if spans.is_empty() {
return None;
}
let mut b = WorkspaceEditBuilder::new();
for span in spans {
b.edit(uri.clone(), text_edit_delete(span, text, utf8));
}
Some(b.build())
}
fn collect_checkbox_spans(list: &ListNode, text: &str, spans: &mut Vec<Span>) {
for item in &list.items {
if let Some(s) = remove_checkbox_span(text, item.span) {
spans.push(s);
}
if let Some(sub) = &item.sublist {
collect_checkbox_spans(sub, text, spans);
}
}
}
/// Span of the `[ ]` checkbox plus one trailing space (so removal
/// leaves `* foo`, not `* foo`). `None` when the item has no checkbox.
fn remove_checkbox_span(text: &str, item_span: Span) -> Option<Span> {
let cb = find_checkbox_span(text, item_span)?;
let bytes = text.as_bytes();
let mut end_off = cb.end.offset;
let mut end_col = cb.end.column;
if end_off < bytes.len() && bytes[end_off] == b' ' {
end_off += 1;
end_col += 1;
}
Some(Span::new(
cb.start,
AstPosition {
line: cb.end.line,
column: end_col,
offset: end_off,
},
))
}
/// Find the top-level (contiguous) list block whose span contains
/// `line`. Unlike [`find_list_at_line`], this does not descend into
/// sublists — it returns the outermost list so callers operate on the