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:
@@ -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
|
||||
|
||||
@@ -28,7 +28,11 @@ fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||
#[test]
|
||||
fn commands_list_advertises_link_helpers() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in ["nuwiki.link.pasteWikilink", "nuwiki.link.pasteUrl"] {
|
||||
for name in [
|
||||
"nuwiki.link.pasteWikilink",
|
||||
"nuwiki.link.pasteUrl",
|
||||
"nuwiki.link.catUrl",
|
||||
] {
|
||||
assert!(names.contains(&name), "missing: {name}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -192,6 +192,41 @@ fn change_symbol_whole_list_rewrites_every_item() {
|
||||
assert!(texts.contains(&"3."));
|
||||
}
|
||||
|
||||
// ===== removeCheckbox =====
|
||||
|
||||
#[test]
|
||||
fn remove_checkbox_strips_single_item() {
|
||||
let src = "- [ ] task\n- [X] done\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 1);
|
||||
assert_eq!(edits[0].new_text, "");
|
||||
// Removes `[ ] ` — columns 2..6 on the first line.
|
||||
assert_eq!(edits[0].range.start.line, 0);
|
||||
assert_eq!(edits[0].range.start.character, 2);
|
||||
assert_eq!(edits[0].range.end.character, 6);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_checkbox_whole_list_strips_every_item() {
|
||||
let src = "- [ ] a\n- [X] b\n- [-] c\n";
|
||||
let ast = parse(src);
|
||||
let edit = ops::remove_checkbox_edit(src, &ast, &uri(), 0, true, true).expect("edit");
|
||||
let edits = &edit.changes.unwrap()[&uri()];
|
||||
assert_eq!(edits.len(), 3);
|
||||
for e in edits {
|
||||
assert_eq!(e.new_text, "");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remove_checkbox_none_without_checkbox() {
|
||||
let src = "- plain item\n";
|
||||
let ast = parse(src);
|
||||
assert!(ops::remove_checkbox_edit(src, &ast, &uri(), 0, false, true).is_none());
|
||||
}
|
||||
|
||||
// ===== changeLevel =====
|
||||
|
||||
#[test]
|
||||
@@ -231,6 +266,7 @@ fn commands_list_includes_cluster_a() {
|
||||
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||
for name in [
|
||||
"nuwiki.list.removeDone",
|
||||
"nuwiki.list.removeCheckbox",
|
||||
"nuwiki.list.renumber",
|
||||
"nuwiki.list.changeSymbol",
|
||||
"nuwiki.list.changeLevel",
|
||||
|
||||
Reference in New Issue
Block a user