From f1d046fad106277921fe8bc27a102d5d2e5ce44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Mon, 11 May 2026 17:41:11 +0000 Subject: [PATCH] phase 14: list/heading edit commands + CommandOutcome split MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six new executeCommand entries — the cheap surgical edits that account for the bulk of vimwiki users' daily keymap traffic. Heavier commands (table.align/moveColumn/insert, list.changeSymbol/changeLevel/renumber/ removeDone, link.normalize, colorize) are deferred for a follow-up since each needs its own structural rewriter. CommandOutcome split: - `Edit(WorkspaceEdit)` — server applies via apply_edit. - `Value(Value)` — returned verbatim to the client (used by read-only queries like nextTask). - execute_command handler in lib.rs dispatches both cases. New commands: - nuwiki.list.toggleCheckbox — [ ] ↔ [X]; mid-states snap to [X]; rejected reverts to [ ]. - nuwiki.list.cycleCheckbox — [ ] → [.] → [o] → [O] → [X] → [ ]; rejected resets to [ ]. - nuwiki.list.rejectCheckbox — toggle the [-] rejected marker. - nuwiki.list.nextTask — returns Location of the first unfinished task after the cursor; wraps to the start of the doc when none found below. Only items with checkboxes count. - nuwiki.heading.addLevel — = → ==; clamps at 6. - nuwiki.heading.removeLevel — == → =; clamps at 1 (no-op on h1). Pure ops: - ops::toggle_state / cycle_state / reject_state — checkbox state transitions. - ops::checkbox_edit / next_task / heading_change_level — end-to-end edit producers. Tests drive these directly without spinning up a Backend. - ops::rewrite_heading_with_level — text-level rewrite preserving the leading whitespace that marks a centered heading (lives outside the heading span, so the edit doesn't touch it). - ops::find_list_item_at / find_checkbox_span / item_in_list — AST + source-text helpers, pub for downstream tooling. Tests (21 new): - State transitions: round-trip, progression, dash toggle. - checkbox_edit: empty → done, partial → cycle, reject path, no-op on plain item, no-op off list, nested-sublist resolution. - heading_change_level: h2→h3, level-6 cap, h3→h2, h1 no-op, off-heading no-op, span boundary semantics. - next_task: first-unfinished, wrap-around, all-done returns None, plain items ignored. - COMMANDS list ↔ handler match. All 211 prior tests still pass. Co-Authored-By: Claude Opus 4.7 (1M context) --- README.md | 2 +- crates/nuwiki-lsp/src/commands.rs | 414 +++++++++++++++++- crates/nuwiki-lsp/src/lib.rs | 3 +- .../nuwiki-lsp/tests/phase14_edit_commands.rs | 246 +++++++++++ 4 files changed, 643 insertions(+), 22 deletions(-) create mode 100644 crates/nuwiki-lsp/tests/phase14_edit_commands.rs diff --git a/README.md b/README.md index 0a8fdf0..8c2a07a 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification. | 11 | Plumbing prerequisites | ✅ done | | 12 | Tags | ✅ done | | 13 | Workspace edits + executeCommand | ✅ done | -| 14 | List & table edit commands | ⏳ | +| 14 | List & table edit commands | ✅ done (focused subset; table/list-renumber/colorize deferred) | | 15 | Link health + TOC/index generation | ⏳ | | 16 | Diary | ⏳ | | 17 | HTML export commands | ⏳ | diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index b969ea2..70fd358 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -1,40 +1,91 @@ //! `workspace/executeCommand` dispatcher. //! -//! Phase 13 lands the router + the first command. Phases 14–17 register -//! their own commands here. Each handler returns the `WorkspaceEdit` it -//! wants applied; the `Backend` calls `client.apply_edit` after the call. +//! Phase 13 introduced the router + `nuwiki.file.delete`. Phase 14 adds +//! the cheap surgical edits — checkbox toggle/cycle/reject, heading +//! promote/demote, "next task" navigation — that account for the bulk +//! of vimwiki users' daily keymap traffic. Heavier commands (table +//! reflow, list renumber, link normalisation, colorize) are scoped for +//! a follow-up since each needs its own structural rewriter. //! -//! Phase 13 surface: -//! - `nuwiki.file.delete` — args: `{ "uri": }` +//! Every command resolves to either a `WorkspaceEdit` (the server then +//! calls `apply_edit`) or a JSON `Value` (returned to the client) — see +//! `CommandOutcome` below. `ops::*` are the pure functions the dispatcher +//! wraps; they're `pub` so tests and future tooling can drive them +//! without spinning up a Backend. use serde::Deserialize; -use tower_lsp::lsp_types::{Url, WorkspaceEdit}; +use serde_json::Value; +use std::sync::atomic::Ordering; +use tower_lsp::lsp_types::{Position as LspPosition, Url, WorkspaceEdit}; use crate::edits::{op_delete, WorkspaceEditBuilder}; +use crate::nav; use crate::Backend; -/// All commands the server advertises in `ExecuteCommandOptions.commands`. -/// Keep this in lock-step with the match arms in `execute` below — adding -/// here without a handler will fail at runtime; adding a handler without -/// listing here means the client won't see it as available. -pub const COMMANDS: &[&str] = &["nuwiki.file.delete"]; +/// Result of a single executeCommand dispatch. +pub enum CommandOutcome { + /// Push to the client via `workspace/applyEdit`. + Edit(WorkspaceEdit), + /// Return verbatim as the executeCommand response. + Value(Value), +} + +/// All commands the server advertises in `ExecuteCommandOptions.commands`. +/// Keep in lock-step with the match arms in `execute`. +pub const COMMANDS: &[&str] = &[ + "nuwiki.file.delete", + "nuwiki.list.toggleCheckbox", + "nuwiki.list.cycleCheckbox", + "nuwiki.list.rejectCheckbox", + "nuwiki.list.nextTask", + "nuwiki.heading.addLevel", + "nuwiki.heading.removeLevel", +]; -/// Dispatch an `executeCommand` request. Returns `Ok(None)` for commands -/// that don't produce a `WorkspaceEdit` (e.g. read-only queries); returns -/// `Err` with a human-readable message for unknown commands or bad args -/// — the LSP backend logs it and returns `null` to the client. pub(crate) async fn execute( - _backend: &Backend, + backend: &Backend, command: &str, - args: Vec, -) -> Result, String> { + args: Vec, +) -> Result, String> { match command { - "nuwiki.file.delete" => file_delete(args), + "nuwiki.file.delete" => file_delete(args).map(|opt| opt.map(CommandOutcome::Edit)), + "nuwiki.list.toggleCheckbox" => { + Ok(list_checkbox(backend, args, ops::toggle_state)?.map(CommandOutcome::Edit)) + } + "nuwiki.list.cycleCheckbox" => { + Ok(list_checkbox(backend, args, ops::cycle_state)?.map(CommandOutcome::Edit)) + } + "nuwiki.list.rejectCheckbox" => { + Ok(list_checkbox(backend, args, ops::reject_state)?.map(CommandOutcome::Edit)) + } + "nuwiki.list.nextTask" => { + list_next_task(backend, args).map(|opt| opt.map(CommandOutcome::Value)) + } + "nuwiki.heading.addLevel" => { + Ok(heading_change_level(backend, args, 1)?.map(CommandOutcome::Edit)) + } + "nuwiki.heading.removeLevel" => { + Ok(heading_change_level(backend, args, -1)?.map(CommandOutcome::Edit)) + } other => Err(format!("unknown nuwiki command: {other}")), } } -fn file_delete(args: Vec) -> Result, String> { +#[derive(Deserialize)] +struct PosArgs { + uri: Url, + position: LspPosition, +} + +fn parse_pos(args: Vec) -> Result { + let raw = args + .into_iter() + .next() + .ok_or_else(|| "missing { uri, position } argument".to_string())?; + serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}")) +} + +fn file_delete(args: Vec) -> Result, String> { #[derive(Deserialize)] struct Args { uri: Url, @@ -49,3 +100,326 @@ fn file_delete(args: Vec) -> Result, St builder.file_op(op_delete(parsed.uri)); Ok(Some(builder.build())) } + +fn list_checkbox( + backend: &Backend, + args: Vec, + mutate: fn(&str) -> Option<&'static str>, +) -> Result, String> { + let p = parse_pos(args)?; + let doc = match backend.documents.get(&p.uri) { + Some(d) => d, + None => return Ok(None), + }; + let utf8 = backend.use_utf8.load(Ordering::Relaxed); + let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8); + Ok(ops::checkbox_edit( + &doc.text, &doc.ast, &p.uri, line, col, utf8, mutate, + )) +} + +fn list_next_task(backend: &Backend, args: Vec) -> Result, String> { + let p = parse_pos(args)?; + let doc = match backend.documents.get(&p.uri) { + Some(d) => d, + None => return Ok(None), + }; + let utf8 = backend.use_utf8.load(Ordering::Relaxed); + let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8); + let Some(loc) = ops::next_task(&doc.ast, &p.uri, &doc.text, line, col, utf8) else { + return Ok(None); + }; + serde_json::to_value(loc) + .map(Some) + .map_err(|e| format!("nuwiki.list.nextTask: serialization failed — {e}")) +} + +fn heading_change_level( + backend: &Backend, + args: Vec, + delta: i32, +) -> Result, String> { + let p = parse_pos(args)?; + let doc = match backend.documents.get(&p.uri) { + Some(d) => d, + None => return Ok(None), + }; + let utf8 = backend.use_utf8.load(Ordering::Relaxed); + let (line, col) = nav::lsp_to_byte_pos(p.position, &doc.text, utf8); + Ok(ops::heading_change_level( + &doc.text, &doc.ast, &p.uri, line, col, utf8, delta, + )) +} + +// ===== Pure operations ===== + +pub mod ops { + use super::*; + use crate::edits::text_edit_replace; + use nuwiki_core::ast::{ + BlockNode, BlockquoteNode, CheckboxState, DocumentNode, HeadingNode, ListItemNode, + ListNode, Span, + }; + 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, + } + } + + /// `[ ]` → `[.]` → `[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, + } + } + + /// Toggle the `[-]` "rejected" state. + pub fn reject_state(current: &str) -> Option<&'static str> { + match current { + "[-]" => Some("[ ]"), + _ => Some("[-]"), + } + } + + pub fn checkbox_edit( + text: &str, + ast: &DocumentNode, + uri: &Url, + line: u32, + col: u32, + utf8: bool, + mutate: fn(&str) -> Option<&'static str>, + ) -> Option { + let item = find_list_item_at(ast, line, col)?; + let cb_span = find_checkbox_span(text, item.span)?; + let cur = &text[cb_span.start.offset..cb_span.end.offset]; + let new = mutate(cur)?; + let mut b = WorkspaceEditBuilder::new(); + b.edit( + uri.clone(), + text_edit_replace(cb_span, new.to_string(), text, utf8), + ); + Some(b.build()) + } + + pub fn next_task( + ast: &DocumentNode, + uri: &Url, + text: &str, + line: u32, + _col: u32, + utf8: bool, + ) -> Option { + // Walk in source order; first task at line > cursor wins. Wraps to + // the start of the document if nothing found after cursor — matches + // vimwiki's `gnt`. + let mut items: Vec<&ListItemNode> = Vec::new(); + for block in &ast.children { + collect_list_items(block, &mut items); + } + let is_unfinished = |it: &&ListItemNode| match it.checkbox { + Some(CheckboxState::Done) | Some(CheckboxState::Rejected) => false, + Some(_) | None => it.checkbox.is_some(), + }; + let after = items + .iter() + .find(|it| it.span.start.line > line && is_unfinished(it)); + let chosen = after.or_else(|| items.iter().find(|it| is_unfinished(it)))?; + let span = chosen.span; + Some(Location { + uri: uri.clone(), + range: crate::edits::text_edit_replace(span, String::new(), text, utf8).range, + }) + } + + fn collect_list_items<'a>(block: &'a BlockNode, out: &mut Vec<&'a ListItemNode>) { + match block { + BlockNode::List(ListNode { items, .. }) => { + for it in items { + out.push(it); + if let Some(sub) = &it.sublist { + for sub_it in &sub.items { + collect_list_items_from_item(sub_it, out); + } + } + } + } + BlockNode::Blockquote(BlockquoteNode { children, .. }) => { + for c in children { + collect_list_items(c, out); + } + } + _ => {} + } + } + + fn collect_list_items_from_item<'a>(item: &'a ListItemNode, out: &mut Vec<&'a ListItemNode>) { + out.push(item); + if let Some(sub) = &item.sublist { + for sub_it in &sub.items { + collect_list_items_from_item(sub_it, out); + } + } + } + + pub fn heading_change_level( + text: &str, + ast: &DocumentNode, + uri: &Url, + line: u32, + _col: u32, + utf8: bool, + delta: i32, + ) -> Option { + let heading = find_heading_at(ast, line)?; + let current = heading.level as i32; + let new_level = (current + delta).clamp(1, 6) as u8; + if new_level == heading.level { + return None; + } + let new_text = rewrite_heading_with_level(text, heading.span, new_level)?; + let mut b = WorkspaceEditBuilder::new(); + b.edit( + uri.clone(), + text_edit_replace(heading.span, new_text, text, utf8), + ); + Some(b.build()) + } + + fn find_heading_at(ast: &DocumentNode, line: u32) -> Option<&HeadingNode> { + for block in &ast.children { + if let BlockNode::Heading(h) = block { + if h.span.start.line == line { + return Some(h); + } + } + } + None + } + + /// Rewrite a heading's source so it has `new_level` `=`s on each side. + /// Returns `None` if the source doesn't have a balanced `=` ... `=` + /// shape (defensive — the lexer should guarantee this, but we don't + /// crash if the buffer was edited mid-flight). + pub fn rewrite_heading_with_level(text: &str, span: Span, new_level: u8) -> Option { + if !(1..=6).contains(&new_level) { + return None; + } + let segment = &text[span.start.offset..span.end.offset]; + let leading_ws = segment + .bytes() + .take_while(|b| *b == b' ' || *b == b'\t') + .count(); + let after_ws = &segment[leading_ws..]; + let opening = after_ws.bytes().take_while(|b| *b == b'=').count(); + let trimmed_end = after_ws.trim_end_matches([' ', '\t']).len(); + let trailing = after_ws[..trimmed_end] + .bytes() + .rev() + .take_while(|b| *b == b'=') + .count(); + if opening == 0 || trailing == 0 || opening != trailing { + return None; + } + let middle = &after_ws[opening..trimmed_end - trailing]; + let trailing_ws = &after_ws[trimmed_end..]; + + let mut out = String::with_capacity(segment.len() + 2); + out.push_str(&segment[..leading_ws]); + for _ in 0..new_level { + out.push('='); + } + out.push_str(middle); + for _ in 0..new_level { + out.push('='); + } + out.push_str(trailing_ws); + Some(out) + } + + /// Find the `[X]` (or other checkbox) substring within a list item's + /// source span. Returns a span covering exactly the 3-byte run. + 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()); + if start >= end { + return None; + } + let segment = &text[start..end]; + let bytes = segment.as_bytes(); + let mut i = 0; + while i + 2 < bytes.len() { + if bytes[i] == b'[' + && bytes[i + 2] == b']' + && matches!(bytes[i + 1], b' ' | b'.' | b'o' | b'O' | b'X' | b'-') + { + let abs_start = start + i; + let cb_start = nuwiki_core::ast::Position { + line: item_span.start.line, + column: item_span.start.column + i as u32, + offset: abs_start, + }; + let cb_end = nuwiki_core::ast::Position { + line: item_span.start.line, + column: item_span.start.column + (i + 3) as u32, + offset: abs_start + 3, + }; + return Some(Span::new(cb_start, cb_end)); + } + i += 1; + } + None + } + + /// Find the list item whose source line contains `line`. Descends into + /// sublists; returns the deepest match so editing the inner item works + /// when the user's cursor sits there. + pub fn find_list_item_at(ast: &DocumentNode, line: u32, col: u32) -> Option<&ListItemNode> { + for block in &ast.children { + if let Some(it) = item_in_block(block, line, col) { + return Some(it); + } + } + None + } + + fn item_in_block(block: &BlockNode, line: u32, col: u32) -> Option<&ListItemNode> { + match block { + BlockNode::List(l) => l.items.iter().find_map(|it| item_in_list(it, line, col)), + BlockNode::Blockquote(BlockquoteNode { children, .. }) => { + children.iter().find_map(|c| item_in_block(c, line, col)) + } + _ => None, + } + } + + fn item_in_list(item: &ListItemNode, line: u32, col: u32) -> Option<&ListItemNode> { + // Prefer the deepest match: try sublists first. + if let Some(sub) = &item.sublist { + for sub_it in &sub.items { + if let Some(found) = item_in_list(sub_it, line, col) { + return Some(found); + } + } + } + if (item.span.start.line..=item.span.end.line).contains(&line) { + return Some(item); + } + let _ = col; + None + } +} diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index fdc3f99..37951c7 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -639,7 +639,7 @@ impl LanguageServer for Backend { params: ExecuteCommandParams, ) -> LspResult> { match commands::execute(self, ¶ms.command, params.arguments).await { - Ok(Some(edit)) => { + Ok(Some(commands::CommandOutcome::Edit(edit))) => { if let Err(e) = self.client.apply_edit(edit).await { self.client .log_message( @@ -650,6 +650,7 @@ impl LanguageServer for Backend { } Ok(None) } + Ok(Some(commands::CommandOutcome::Value(v))) => Ok(Some(v)), Ok(None) => Ok(None), Err(msg) => { self.client diff --git a/crates/nuwiki-lsp/tests/phase14_edit_commands.rs b/crates/nuwiki-lsp/tests/phase14_edit_commands.rs new file mode 100644 index 0000000..fe562cb --- /dev/null +++ b/crates/nuwiki-lsp/tests/phase14_edit_commands.rs @@ -0,0 +1,246 @@ +//! Phase 14 edit-command tests. Drive `commands::ops::*` directly with +//! parsed ASTs so we don't need a live Backend / Client. + +use nuwiki_core::syntax::vimwiki::VimwikiSyntax; +use nuwiki_core::syntax::SyntaxPlugin; +use nuwiki_lsp::commands::ops; +use tower_lsp::lsp_types::{DocumentChanges, Url}; + +fn parse(src: &str) -> nuwiki_core::ast::DocumentNode { + VimwikiSyntax::new().parse(src) +} + +fn dummy_uri() -> Url { + Url::parse("file:///tmp/note.wiki").unwrap() +} + +fn one_text_edit(edit: tower_lsp::lsp_types::WorkspaceEdit) -> tower_lsp::lsp_types::TextEdit { + let changes = edit.changes.expect("expected changes map"); + let (_, edits) = changes.into_iter().next().expect("at least one uri"); + assert_eq!(edits.len(), 1, "expected exactly one text edit"); + edits.into_iter().next().unwrap() +} + +fn doc_changes_for(edit: tower_lsp::lsp_types::WorkspaceEdit) -> Option { + edit.document_changes +} + +// ===== state transitions ===== + +#[test] +fn toggle_state_round_trip() { + assert_eq!(ops::toggle_state("[ ]"), Some("[X]")); + assert_eq!(ops::toggle_state("[X]"), Some("[ ]")); + assert_eq!(ops::toggle_state("[.]"), Some("[X]")); + assert_eq!(ops::toggle_state("[-]"), Some("[ ]")); + assert_eq!(ops::toggle_state("[?]"), None); +} + +#[test] +fn cycle_state_walks_progression() { + assert_eq!(ops::cycle_state("[ ]"), Some("[.]")); + assert_eq!(ops::cycle_state("[.]"), Some("[o]")); + assert_eq!(ops::cycle_state("[o]"), Some("[O]")); + assert_eq!(ops::cycle_state("[O]"), Some("[X]")); + assert_eq!(ops::cycle_state("[X]"), Some("[ ]")); + assert_eq!(ops::cycle_state("[-]"), Some("[ ]")); +} + +#[test] +fn reject_state_toggles_dash_marker() { + assert_eq!(ops::reject_state("[ ]"), Some("[-]")); + assert_eq!(ops::reject_state("[X]"), Some("[-]")); + assert_eq!(ops::reject_state("[-]"), Some("[ ]")); +} + +// ===== checkbox_edit ===== + +#[test] +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) + .expect("toggle edit"); + let te = one_text_edit(edit); + assert_eq!(te.new_text, "[X]"); + assert_eq!(te.range.start.character, 2); // bytes 2..5 = "[ ]" + assert_eq!(te.range.end.character, 5); +} + +#[test] +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) + .expect("cycle edit"); + let te = one_text_edit(edit); + assert_eq!(te.new_text, "[o]"); +} + +#[test] +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) + .expect("reject edit"); + let te = one_text_edit(edit); + assert_eq!(te.new_text, "[-]"); +} + +#[test] +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); + assert!(edit.is_none()); +} + +#[test] +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); + assert!(edit.is_none()); +} + +#[test] +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) + .expect("edit on nested item"); + let te = one_text_edit(edit); + assert_eq!(te.new_text, "[X]"); + assert_eq!(te.range.start.line, 1); +} + +// ===== heading_change_level ===== + +#[test] +fn add_heading_level_promotes_h2_to_h3() { + let src = "== Sub ==\n"; + let doc = parse(src); + let edit = + ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1).expect("add_level edit"); + let te = one_text_edit(edit); + assert_eq!(te.new_text, "=== Sub ==="); +} + +#[test] +fn add_heading_level_caps_at_6() { + let src = "====== Deep ======\n"; + let doc = parse(src); + let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1); + // No-op because level 6 is the cap; clamp matched current level. + assert!(edit.is_none()); +} + +#[test] +fn remove_heading_level_demotes() { + let src = "=== Mid ===\n"; + let doc = parse(src); + let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1) + .expect("remove_level edit"); + let te = one_text_edit(edit); + assert_eq!(te.new_text, "== Mid =="); +} + +#[test] +fn remove_heading_level_at_h1_is_noop() { + let src = "= Top =\n"; + let doc = parse(src); + let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, -1); + assert!(edit.is_none()); +} + +#[test] +fn heading_change_returns_none_when_cursor_not_on_heading() { + let src = "paragraph\n"; + let doc = parse(src); + let edit = ops::heading_change_level(src, &doc, &dummy_uri(), 0, 0, true, 1); + assert!(edit.is_none()); +} + +#[test] +fn rewrite_heading_with_level_keeps_leading_whitespace_outside_span() { + // The lexer's heading span starts at the first `=`, not the line's + // start, so the leading whitespace that marks a "centered" heading + // lives outside the edit range — preserved automatically. The + // rewriter's output is just the `=...=` portion. + let src = " = Title =\n"; + let doc = parse(src); + let h = match &doc.children[0] { + nuwiki_core::ast::BlockNode::Heading(h) => h, + _ => panic!("expected heading"), + }; + let new = ops::rewrite_heading_with_level(src, h.span, 2).unwrap(); + assert_eq!(new, "== Title =="); +} + +// ===== next_task ===== + +#[test] +fn next_task_returns_first_unfinished() { + let src = "- [X] done\n- [ ] todo\n- [ ] later\n"; + let doc = parse(src); + let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found a task"); + // Should be line 1 (the first [ ]). + assert_eq!(loc.range.start.line, 1); +} + +#[test] +fn next_task_wraps_around_when_no_task_after_cursor() { + // Only task is on line 0; cursor on line 2 → wraps back. + let src = "- [ ] todo\n- [X] done\n- [X] also done\n"; + let doc = parse(src); + let loc = ops::next_task(&doc, &dummy_uri(), src, 2, 0, true).expect("found"); + assert_eq!(loc.range.start.line, 0); +} + +#[test] +fn next_task_returns_none_when_no_open_tasks() { + let src = "- [X] a\n- [X] b\n"; + let doc = parse(src); + let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true); + assert!(loc.is_none()); +} + +#[test] +fn next_task_only_counts_items_with_checkboxes() { + // Plain items without a `[…]` are ignored — they're not tasks. + let src = "- plain\n- [ ] real task\n"; + let doc = parse(src); + let loc = ops::next_task(&doc, &dummy_uri(), src, 0, 0, true).expect("found"); + assert_eq!(loc.range.start.line, 1); +} + +// ===== smoke: COMMANDS list matches registered handlers ===== + +#[test] +fn commands_list_is_complete() { + let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec(); + for name in [ + "nuwiki.file.delete", + "nuwiki.list.toggleCheckbox", + "nuwiki.list.cycleCheckbox", + "nuwiki.list.rejectCheckbox", + "nuwiki.list.nextTask", + "nuwiki.heading.addLevel", + "nuwiki.heading.removeLevel", + ] { + assert!(names.contains(&name), "missing: {name}"); + } +} + +// ===== sanity: workspace edit shape ===== + +#[test] +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(); + assert!(doc_changes_for(edit.clone()).is_none()); + assert!(edit.changes.is_some()); +}