feat(13.1-A): list rewriters — removeDone, renumber, changeSymbol/Level

Closes the four-command list-rewriter cluster from SPEC §13.1.

Server-side (commands.rs ops module):
- `parse_symbol` / `render_marker` — round-trip between
  `:VimwikiListChangeSymbol` arg shapes (`-`, `1.`, `a)`, `i)`,
  `Dash`, `Numeric`, …) and the rendered marker text. `render_marker`
  knows how to spell `a/b/…/z/aa` and `i/ii/iv/v/…/MMM` for the
  alphabetic + roman variants.
- `remove_done_edit` — walks every list (recursively through
  blockquotes and sublists), emits delete TextEdits for items whose
  checkbox is `Done` or `Rejected`. Item span gets extended through
  the trailing newline so we don't leave behind empty lines. Accepts
  an optional `position` to scope the operation to the item under
  cursor + its descendants.
- `renumber_edit` — finds the list containing the cursor line (or
  every list when `whole_file: true`), re-sequences numeric
  markers in-place. Unordered lists are left alone.
- `change_symbol_edit` — rewrites the leading marker on a single
  item, or every item in a list when `whole_list: true`. Uses
  `render_marker` so ordered variants get the right index.
- `change_level_edit` — re-indents the marker line (or every line of
  the item subtree when `whole_subtree: true`) by ±2 spaces per
  level. Dedent clamps at column 0.
- `find_list_at_line` + `find_marker_span` — pure helpers reused by
  the three above.

Editor glue:
- `lua/nuwiki/commands.lua` / `autoload/nuwiki/commands.vim` —
  removed all four "not yet implemented" stubs and wired them to the
  real LSP commands. Lua also exposes `list_change_lvl(direction)`
  for the `:VimwikiListChangeLvl decrease|increase` compat entry.
- Keymaps for `glh` / `gll` / `gLh` / `gLl` (list level single +
  subtree), `glr` / `gLr` (renumber list + whole-file),
  `gl<Space>` / `gL<Space>` (remove done items) now hit the real
  commands instead of printing a "deferred" notification.

Tests: 16 new in `cluster_a_list_rewriters.rs` covering
symbol parsing, marker rendering (alpha + roman + numeric),
removeDone shape, scoped removeDone, no-match cases, renumber
sequencing, whole-file walk, changeSymbol single + whole-list,
changeLevel single + subtree + clamp, and COMMANDS list completeness.
Total 402 Rust tests pass; clippy clean; keymap harness still at
20/20.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:37:11 +00:00
parent cebc806ce3
commit 9d34c8baa2
6 changed files with 983 additions and 23 deletions
+654
View File
@@ -67,6 +67,10 @@ pub const COMMANDS: &[&str] = &[
"nuwiki.wiki.gotoPage",
"nuwiki.link.pasteWikilink",
"nuwiki.link.pasteUrl",
"nuwiki.list.removeDone",
"nuwiki.list.renumber",
"nuwiki.list.changeSymbol",
"nuwiki.list.changeLevel",
];
pub(crate) async fn execute(
@@ -163,6 +167,16 @@ pub(crate) async fn execute(
"nuwiki.link.pasteUrl" => {
link_paste(backend, args, PasteKind::Url).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.list.removeDone" => {
list_remove_done(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"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))
}
"nuwiki.list.changeLevel" => {
list_change_level(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
other => Err(format!("unknown nuwiki command: {other}")),
}
}
@@ -782,6 +796,139 @@ fn wiki_goto_page(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>,
})))
}
// ===== §13.1 Cluster A: list rewriters =====
fn list_remove_done(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_optional_uri_arg(args.clone())?;
// Accept both `{ uri }` (whole doc) and `{ uri, position }` (item under
// cursor + its descendants). Position is optional — when absent, we
// strip every done/rejected item in the doc.
let raw = args.into_iter().next().unwrap_or(serde_json::json!({}));
#[derive(Deserialize, Default)]
struct Args {
uri: Option<Url>,
#[serde(default)]
position: Option<LspPosition>,
}
let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?;
let uri = parsed
.uri
.or(p.uri)
.ok_or_else(|| "missing uri".to_string())?;
let doc = match backend.documents.get(&uri) {
Some(d) => d,
None => return Ok(None),
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
Ok(ops::remove_done_edit(
&doc.text,
&doc.ast,
&uri,
parsed.position,
utf8,
))
}
fn list_renumber(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
#[serde(default)]
position: Option<LspPosition>,
#[serde(default)]
whole_file: bool,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri } 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, _) = match parsed.position {
Some(pos) => nav::lsp_to_byte_pos(pos, &doc.text, utf8),
None => (0, 0),
};
Ok(ops::renumber_edit(
&doc.text,
&doc.ast,
&parsed.uri,
line,
parsed.whole_file,
utf8,
))
}
fn list_change_symbol(
backend: &Backend,
args: Vec<Value>,
) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
position: LspPosition,
symbol: String,
#[serde(default)]
whole_list: bool,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri, position, symbol } argument".to_string())?;
let parsed: Args = serde_json::from_value(raw).map_err(|e| format!("invalid args: {e}"))?;
let target = ops::parse_symbol(&parsed.symbol)
.ok_or_else(|| format!("unknown list symbol: {}", parsed.symbol))?;
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::change_symbol_edit(
&doc.text,
&doc.ast,
&parsed.uri,
line,
target,
parsed.whole_list,
utf8,
))
}
fn list_change_level(backend: &Backend, args: Vec<Value>) -> Result<Option<WorkspaceEdit>, String> {
#[derive(Deserialize)]
struct Args {
uri: Url,
position: LspPosition,
delta: i32,
#[serde(default)]
whole_subtree: bool,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "missing { uri, position, delta } 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::change_level_edit(
&doc.text,
&doc.ast,
&parsed.uri,
line,
parsed.delta,
parsed.whole_subtree,
utf8,
))
}
// ===== Phase 17: HTML export commands =====
fn export_current(
@@ -1495,6 +1642,513 @@ pub mod ops {
/// on the diagnostics module's internals.
pub use crate::diagnostics::collect_wiki_links;
// ===== §13.1 Cluster A: list rewriters =====
use crate::edits::text_edit_delete;
use nuwiki_core::ast::ListSymbol;
/// Parse a `:VimwikiListChangeSymbol` argument into a `ListSymbol`.
/// Accepts both the short markers (`-`, `*`, `#`, `1.`, …) and the
/// long variant names so wrappers can pass either shape.
pub fn parse_symbol(name: &str) -> Option<ListSymbol> {
match name {
"-" | "Dash" | "dash" => Some(ListSymbol::Dash),
"*" | "Star" | "star" => Some(ListSymbol::Star),
"#" | "Hash" | "hash" => Some(ListSymbol::Hash),
"1." | "Numeric" | "numeric" => Some(ListSymbol::Numeric),
"1)" | "NumericParen" | "numeric_paren" => Some(ListSymbol::NumericParen),
"a)" | "AlphaParen" | "alpha_paren" => Some(ListSymbol::AlphaParen),
"A)" | "AlphaUpperParen" | "alpha_upper_paren" => Some(ListSymbol::AlphaUpperParen),
"i)" | "RomanParen" | "roman_paren" => Some(ListSymbol::RomanParen),
"I)" | "RomanUpperParen" | "roman_upper_paren" => Some(ListSymbol::RomanUpperParen),
_ => None,
}
}
/// Render a list marker for the given symbol + 1-based sequence index.
/// Indices only matter for ordered variants (`Numeric`, `AlphaParen`, …).
pub fn render_marker(symbol: ListSymbol, idx: usize) -> String {
match symbol {
ListSymbol::Dash => "-".to_string(),
ListSymbol::Star => "*".to_string(),
ListSymbol::Hash => "#".to_string(),
ListSymbol::Numeric => format!("{idx}."),
ListSymbol::NumericParen => format!("{idx})"),
ListSymbol::AlphaParen => alpha_marker(idx, false),
ListSymbol::AlphaUpperParen => alpha_marker(idx, true),
ListSymbol::RomanParen => format!("{})", roman_marker(idx, false)),
ListSymbol::RomanUpperParen => format!("{})", roman_marker(idx, true)),
}
}
fn alpha_marker(idx: usize, upper: bool) -> String {
// Spreadsheet-style: 1 → a, 2 → b, …, 26 → z, 27 → aa.
let mut n = idx;
let mut buf = Vec::new();
let base = if upper { b'A' } else { b'a' };
while n > 0 {
let rem = (n - 1) % 26;
buf.push(base + rem as u8);
n = (n - 1) / 26;
}
let mut s: String = buf.iter().rev().map(|b| *b as char).collect();
s.push(')');
s
}
fn roman_marker(idx: usize, upper: bool) -> String {
let map: [(usize, &str); 13] = [
(1000, "m"),
(900, "cm"),
(500, "d"),
(400, "cd"),
(100, "c"),
(90, "xc"),
(50, "l"),
(40, "xl"),
(10, "x"),
(9, "ix"),
(5, "v"),
(4, "iv"),
(1, "i"),
];
let mut n = idx;
let mut out = String::new();
for (val, sym) in map {
while n >= val {
out.push_str(sym);
n -= val;
}
}
if upper {
out.to_ascii_uppercase()
} else {
out
}
}
/// Walk every list block at top level (recursing into blockquotes
/// and sublists) and call `f` with each item.
fn walk_list_items<'a, F: FnMut(&'a ListItemNode)>(blocks: &'a [BlockNode], f: &mut F) {
for block in blocks {
walk_list_items_block(block, f);
}
}
fn walk_list_items_block<'a, F: FnMut(&'a ListItemNode)>(block: &'a BlockNode, f: &mut F) {
match block {
BlockNode::List(ListNode { items, .. }) => {
for item in items {
walk_list_items_in_item(item, f);
}
}
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
for c in children {
walk_list_items_block(c, f);
}
}
_ => {}
}
}
fn walk_list_items_in_item<'a, F: FnMut(&'a ListItemNode)>(item: &'a ListItemNode, f: &mut F) {
f(item);
if let Some(sub) = &item.sublist {
for sub_it in &sub.items {
walk_list_items_in_item(sub_it, f);
}
}
}
// ----- removeDone -----
/// Delete every checkbox item whose state is `Done` or `Rejected`,
/// cascading into their sublists. When `pos` is `Some`, restrict to
/// the list containing `pos`'s line; otherwise sweep the whole doc.
pub fn remove_done_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
pos: Option<LspPosition>,
utf8: bool,
) -> Option<WorkspaceEdit> {
let restrict_line = pos.map(|p| p.line);
let mut victims: Vec<Span> = Vec::new();
walk_list_items(&ast.children, &mut |item| {
let matches_scope = match restrict_line {
None => true,
Some(line) => (item.span.start.line..=item.span.end.line).contains(&line),
};
if !matches_scope {
return;
}
if matches!(
item.checkbox,
Some(nuwiki_core::ast::CheckboxState::Done)
| Some(nuwiki_core::ast::CheckboxState::Rejected),
) {
victims.push(extend_to_line_end(text, item.span));
}
});
if victims.is_empty() {
return None;
}
let mut b = WorkspaceEditBuilder::new();
for span in victims {
b.edit(uri.clone(), text_edit_delete(span, text, utf8));
}
Some(b.build())
}
/// Extend a span to include the trailing newline (so deletion removes
/// the empty line that would otherwise be left behind).
fn extend_to_line_end(text: &str, span: Span) -> Span {
let bytes = text.as_bytes();
let mut end_off = span.end.offset.min(bytes.len());
let mut end_line = span.end.line;
let mut end_col = span.end.column;
if end_off < bytes.len() && bytes[end_off] == b'\n' {
end_off += 1;
end_line += 1;
end_col = 0;
}
Span::new(
span.start,
AstPosition {
line: end_line,
column: end_col,
offset: end_off,
},
)
}
// ----- renumber -----
/// Re-sequence numeric markers in the list containing `line`, or in
/// every list when `whole_file` is set.
pub fn renumber_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
whole_file: bool,
utf8: bool,
) -> Option<WorkspaceEdit> {
let mut edits: Vec<(Span, String)> = Vec::new();
if whole_file {
for block in &ast.children {
renumber_in_block(block, text, &mut edits);
}
} else {
let list = find_list_at_line(ast, line)?;
renumber_in_list(list, text, &mut edits);
}
if edits.is_empty() {
return None;
}
let mut b = WorkspaceEditBuilder::new();
for (span, new) in edits {
b.edit(uri.clone(), text_edit_replace(span, new, text, utf8));
}
Some(b.build())
}
fn renumber_in_block(block: &BlockNode, text: &str, edits: &mut Vec<(Span, String)>) {
match block {
BlockNode::List(list) => renumber_in_list(list, text, edits),
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
for c in children {
renumber_in_block(c, text, edits);
}
}
_ => {}
}
}
fn renumber_in_list(list: &ListNode, text: &str, edits: &mut Vec<(Span, String)>) {
for (idx, item) in list.items.iter().enumerate() {
if is_ordered_symbol(item.symbol) {
if let Some((span, _, _)) = find_marker_span(text, item.span) {
let new = render_marker(item.symbol, idx + 1);
edits.push((span, new));
}
}
if let Some(sub) = &item.sublist {
renumber_in_list(sub, text, edits);
}
}
}
fn is_ordered_symbol(s: ListSymbol) -> bool {
matches!(
s,
ListSymbol::Numeric
| ListSymbol::NumericParen
| ListSymbol::AlphaParen
| ListSymbol::AlphaUpperParen
| ListSymbol::RomanParen
| ListSymbol::RomanUpperParen
)
}
// ----- changeSymbol -----
/// Rewrite the leading marker on each list item. When `whole_list`
/// is true, every item in the same list (recursively through
/// sublists) gets the new symbol; otherwise only the item at `line`
/// is touched.
pub fn change_symbol_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
target: ListSymbol,
whole_list: bool,
utf8: bool,
) -> Option<WorkspaceEdit> {
let mut edits: Vec<(Span, String)> = Vec::new();
if whole_list {
let list = find_list_at_line(ast, line)?;
rewrite_list_symbols(list, target, text, &mut edits);
} else {
let item = find_list_item_at(ast, line, 0)?;
if let Some((span, _, _)) = find_marker_span(text, item.span) {
edits.push((span, render_marker(target, 1)));
}
}
if edits.is_empty() {
return None;
}
let mut b = WorkspaceEditBuilder::new();
for (span, new) in edits {
b.edit(uri.clone(), text_edit_replace(span, new, text, utf8));
}
Some(b.build())
}
fn rewrite_list_symbols(
list: &ListNode,
target: ListSymbol,
text: &str,
edits: &mut Vec<(Span, String)>,
) {
for (idx, item) in list.items.iter().enumerate() {
if let Some((span, _, _)) = find_marker_span(text, item.span) {
edits.push((span, render_marker(target, idx + 1)));
}
if let Some(sub) = &item.sublist {
rewrite_list_symbols(sub, target, text, edits);
}
}
}
// ----- changeLevel -----
/// Indent (`delta > 0`) or dedent (`delta < 0`) the item at `line`.
/// When `whole_subtree` is set, all descendants get the same shift
/// so the visual tree stays consistent.
pub fn change_level_edit(
text: &str,
ast: &DocumentNode,
uri: &Url,
line: u32,
delta: i32,
whole_subtree: bool,
utf8: bool,
) -> Option<WorkspaceEdit> {
let item = find_list_item_at(ast, line, 0)?;
let mut edits: Vec<(Span, String)> = Vec::new();
if whole_subtree {
collect_level_edits(item, text, delta, &mut edits);
} else {
// Only the first line of the item — the marker line —
// changes indentation. Sublists keep their own indentation
// (a partial shift would break the tree).
push_level_edit_for_line(item.span.start.line, text, delta, &mut edits);
}
if edits.is_empty() {
return None;
}
let mut b = WorkspaceEditBuilder::new();
for (span, new) in edits {
b.edit(uri.clone(), text_edit_replace(span, new, text, utf8));
}
Some(b.build())
}
fn collect_level_edits(
item: &ListItemNode,
text: &str,
delta: i32,
edits: &mut Vec<(Span, String)>,
) {
for line in item.span.start.line..=item.span.end.line {
push_level_edit_for_line(line, text, delta, edits);
}
if let Some(sub) = &item.sublist {
for sub_it in &sub.items {
// Already covered by the line range, but recurse so we
// don't miss items past the parent's span.end (resilient
// to off-by-one quirks in the lexer).
collect_level_edits(sub_it, text, delta, edits);
}
}
}
fn push_level_edit_for_line(
line_num: u32,
text: &str,
delta: i32,
edits: &mut Vec<(Span, String)>,
) {
let bytes = text.as_bytes();
// Locate the start offset of `line_num`.
let mut off = 0usize;
let mut cur_line: u32 = 0;
while cur_line < line_num && off < bytes.len() {
if bytes[off] == b'\n' {
cur_line += 1;
}
off += 1;
}
if cur_line != line_num {
return;
}
let mut ws_end = off;
while ws_end < bytes.len() && (bytes[ws_end] == b' ' || bytes[ws_end] == b'\t') {
ws_end += 1;
}
let current_ws = ws_end - off;
let new_ws = if delta < 0 {
current_ws.saturating_sub((-delta * 2) as usize)
} else {
current_ws + (delta * 2) as usize
};
if new_ws == current_ws {
return;
}
let span = Span::new(
AstPosition {
line: line_num,
column: 0,
offset: off,
},
AstPosition {
line: line_num,
column: current_ws as u32,
offset: ws_end,
},
);
edits.push((span, " ".repeat(new_ws)));
}
// ----- shared helpers -----
/// Find the deepest list containing `line` (an item's line range).
/// Top-level lists win over nested ones unless the cursor is on a
/// sublist item.
pub fn find_list_at_line(ast: &DocumentNode, line: u32) -> Option<&ListNode> {
for block in &ast.children {
if let Some(list) = find_list_in_block(block, line) {
return Some(list);
}
}
None
}
fn find_list_in_block(block: &BlockNode, line: u32) -> Option<&ListNode> {
match block {
BlockNode::List(list) => {
// First check sublists for a tighter match.
for item in &list.items {
if let Some(sub) = &item.sublist {
if (sub.span.start.line..=sub.span.end.line).contains(&line) {
if let Some(nested) = find_list_in_sublist(sub, line) {
return Some(nested);
}
return Some(sub);
}
}
}
if (list.span.start.line..=list.span.end.line).contains(&line) {
Some(list)
} else {
None
}
}
BlockNode::Blockquote(BlockquoteNode { children, .. }) => {
for c in children {
if let Some(l) = find_list_in_block(c, line) {
return Some(l);
}
}
None
}
_ => None,
}
}
fn find_list_in_sublist(list: &ListNode, line: u32) -> Option<&ListNode> {
for item in &list.items {
if let Some(sub) = &item.sublist {
if (sub.span.start.line..=sub.span.end.line).contains(&line) {
return find_list_in_sublist(sub, line).or(Some(sub));
}
}
}
None
}
/// Find the leading marker substring (e.g. `"-"`, `"1."`, `"a)"`)
/// within the first line of an item's source span. Returns the
/// span covering only the marker characters, the marker text, and
/// the byte offset right after the marker (caller uses this to
/// extend edits if needed).
pub fn find_marker_span(text: &str, item_span: Span) -> Option<(Span, &str, usize)> {
let start = item_span.start.offset;
let end = item_span.end.offset.min(text.len());
if start >= end {
return None;
}
let bytes = text.as_bytes();
// Skip leading whitespace.
let mut i = start;
while i < end && (bytes[i] == b' ' || bytes[i] == b'\t') {
i += 1;
}
let marker_start = i;
// Marker = run of non-space chars before the first whitespace.
// For `-`, `*`, `#`: single char. For `1.`, `1)`, `a)`, …: 2+.
while i < end && !(bytes[i] as char).is_whitespace() {
i += 1;
}
if marker_start == i {
return None;
}
let marker_str = std::str::from_utf8(&bytes[marker_start..i]).ok()?;
// Sanity: only accept characters that could form a list marker.
if !marker_str
.chars()
.all(|c| c.is_alphanumeric() || matches!(c, '-' | '*' | '#' | '.' | ')'))
{
return None;
}
let col_offset = marker_start - start;
let span = Span::new(
AstPosition {
line: item_span.start.line,
column: item_span.start.column + col_offset as u32,
offset: marker_start,
},
AstPosition {
line: item_span.start.line,
column: item_span.start.column + (i - start) as u32,
offset: i,
},
);
Some((span, marker_str, i))
}
// ===== Phase 16: diary helpers =====
use crate::edits::op_create;