diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index 3bc74eb..2df2290 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -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) -> Result, +) -> Result, 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://` for the current file. +fn link_cat_url(backend: &Backend, args: Vec) -> Result, 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) -> Result, 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 { + let mut spans: Vec = 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) { + 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 { + 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 diff --git a/crates/nuwiki-lsp/tests/commands_links.rs b/crates/nuwiki-lsp/tests/commands_links.rs index e879141..68e2c91 100644 --- a/crates/nuwiki-lsp/tests/commands_links.rs +++ b/crates/nuwiki-lsp/tests/commands_links.rs @@ -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}"); } } diff --git a/crates/nuwiki-lsp/tests/commands_lists.rs b/crates/nuwiki-lsp/tests/commands_lists.rs index e77c205..fcb176e 100644 --- a/crates/nuwiki-lsp/tests/commands_lists.rs +++ b/crates/nuwiki-lsp/tests/commands_lists.rs @@ -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",