phase 14: list/heading edit commands + CommandOutcome split
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:
@@ -36,7 +36,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification.
|
|||||||
| 11 | Plumbing prerequisites | ✅ done |
|
| 11 | Plumbing prerequisites | ✅ done |
|
||||||
| 12 | Tags | ✅ done |
|
| 12 | Tags | ✅ done |
|
||||||
| 13 | Workspace edits + executeCommand | ✅ 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 | ⏳ |
|
| 15 | Link health + TOC/index generation | ⏳ |
|
||||||
| 16 | Diary | ⏳ |
|
| 16 | Diary | ⏳ |
|
||||||
| 17 | HTML export commands | ⏳ |
|
| 17 | HTML export commands | ⏳ |
|
||||||
|
|||||||
@@ -1,40 +1,91 @@
|
|||||||
//! `workspace/executeCommand` dispatcher.
|
//! `workspace/executeCommand` dispatcher.
|
||||||
//!
|
//!
|
||||||
//! Phase 13 lands the router + the first command. Phases 14–17 register
|
//! Phase 13 introduced the router + `nuwiki.file.delete`. Phase 14 adds
|
||||||
//! their own commands here. Each handler returns the `WorkspaceEdit` it
|
//! the cheap surgical edits — checkbox toggle/cycle/reject, heading
|
||||||
//! wants applied; the `Backend` calls `client.apply_edit` after the call.
|
//! 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:
|
//! Every command resolves to either a `WorkspaceEdit` (the server then
|
||||||
//! - `nuwiki.file.delete` — args: `{ "uri": <Url> }`
|
//! 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 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::edits::{op_delete, WorkspaceEditBuilder};
|
||||||
|
use crate::nav;
|
||||||
use crate::Backend;
|
use crate::Backend;
|
||||||
|
|
||||||
/// All commands the server advertises in `ExecuteCommandOptions.commands`.
|
/// Result of a single executeCommand dispatch.
|
||||||
/// Keep this in lock-step with the match arms in `execute` below — adding
|
pub enum CommandOutcome {
|
||||||
/// here without a handler will fail at runtime; adding a handler without
|
/// Push to the client via `workspace/applyEdit`.
|
||||||
/// listing here means the client won't see it as available.
|
Edit(WorkspaceEdit),
|
||||||
pub const COMMANDS: &[&str] = &["nuwiki.file.delete"];
|
/// 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(
|
pub(crate) async fn execute(
|
||||||
_backend: &Backend,
|
backend: &Backend,
|
||||||
command: &str,
|
command: &str,
|
||||||
args: Vec<serde_json::Value>,
|
args: Vec<Value>,
|
||||||
) -> Result<Option<WorkspaceEdit>, String> {
|
) -> Result<Option<CommandOutcome>, String> {
|
||||||
match command {
|
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}")),
|
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)]
|
#[derive(Deserialize)]
|
||||||
struct Args {
|
struct Args {
|
||||||
uri: Url,
|
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));
|
builder.file_op(op_delete(parsed.uri));
|
||||||
Ok(Some(builder.build()))
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -639,7 +639,7 @@ impl LanguageServer for Backend {
|
|||||||
params: ExecuteCommandParams,
|
params: ExecuteCommandParams,
|
||||||
) -> LspResult<Option<serde_json::Value>> {
|
) -> LspResult<Option<serde_json::Value>> {
|
||||||
match commands::execute(self, ¶ms.command, params.arguments).await {
|
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 {
|
if let Err(e) = self.client.apply_edit(edit).await {
|
||||||
self.client
|
self.client
|
||||||
.log_message(
|
.log_message(
|
||||||
@@ -650,6 +650,7 @@ impl LanguageServer for Backend {
|
|||||||
}
|
}
|
||||||
Ok(None)
|
Ok(None)
|
||||||
}
|
}
|
||||||
|
Ok(Some(commands::CommandOutcome::Value(v))) => Ok(Some(v)),
|
||||||
Ok(None) => Ok(None),
|
Ok(None) => Ok(None),
|
||||||
Err(msg) => {
|
Err(msg) => {
|
||||||
self.client
|
self.client
|
||||||
|
|||||||
@@ -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<DocumentChanges> {
|
||||||
|
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());
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user