phase 14: list/heading edit commands + CommandOutcome split
CI / cargo fmt --check (push) Successful in 20s
CI / cargo clippy (push) Successful in 1m11s
CI / cargo test (push) Successful in 1m11s

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) <noreply@anthropic.com>
This commit is contained in:
2026-05-11 17:41:11 +00:00
parent cb4bfae5e7
commit f1d046fad1
4 changed files with 643 additions and 22 deletions
+394 -20
View File
@@ -1,40 +1,91 @@
//! `workspace/executeCommand` dispatcher.
//!
//! Phase 13 lands the router + the first command. Phases 1417 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": <Url> }`
//! 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<serde_json::Value>,
) -> Result<Option<WorkspaceEdit>, String> {
args: Vec<Value>,
) -> Result<Option<CommandOutcome>, 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<serde_json::Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct PosArgs {
uri: Url,
position: LspPosition,
}
fn parse_pos(args: Vec<Value>) -> Result<PosArgs, String> {
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<Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
@@ -49,3 +100,326 @@ fn file_delete(args: Vec<serde_json::Value>) -> Result<Option<WorkspaceEdit>, St
builder.file_op(op_delete(parsed.uri));
Ok(Some(builder.build()))
}
fn list_checkbox(
backend: &Backend,
args: Vec<Value>,
mutate: fn(&str) -> Option<&'static str>,
) -> Result<Option<WorkspaceEdit>, 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<Value>) -> Result<Option<Value>, 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<Value>,
delta: i32,
) -> Result<Option<WorkspaceEdit>, 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<WorkspaceEdit> {
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<Location> {
// 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<WorkspaceEdit> {
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<String> {
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<Span> {
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
}
}
+2 -1
View File
@@ -639,7 +639,7 @@ impl LanguageServer for Backend {
params: ExecuteCommandParams,
) -> LspResult<Option<serde_json::Value>> {
match commands::execute(self, &params.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