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:
@@ -462,11 +462,56 @@ endfunction
|
|||||||
|
|
||||||
" ===== §13.1 deferred =====
|
" ===== §13.1 deferred =====
|
||||||
|
|
||||||
function! nuwiki#commands#list_change_lvl() abort
|
" §13.1 Cluster A — list rewriters.
|
||||||
call s:notify_deferred(':VimwikiListChangeLvl')
|
|
||||||
endfunction
|
|
||||||
function! nuwiki#commands#list_remove_done() abort
|
function! nuwiki#commands#list_remove_done() abort
|
||||||
call s:notify_deferred(':VimwikiRemoveDone')
|
let l:args = [{ 'uri': s:buf_uri() }]
|
||||||
|
call s:exec('nuwiki.list.removeDone', l:args)
|
||||||
|
endfunction
|
||||||
|
|
||||||
|
function! nuwiki#commands#list_renumber() abort
|
||||||
|
let l:p = s:cursor_position()
|
||||||
|
let l:args = [{ 'uri': l:p['textDocument']['uri'], 'position': l:p['position'] }]
|
||||||
|
call s:exec('nuwiki.list.renumber', l:args)
|
||||||
|
endfunction
|
||||||
|
|
||||||
|
function! nuwiki#commands#list_renumber_all() abort
|
||||||
|
let l:p = s:cursor_position()
|
||||||
|
let l:args = [{
|
||||||
|
\ 'uri': l:p['textDocument']['uri'],
|
||||||
|
\ 'position': l:p['position'],
|
||||||
|
\ 'whole_file': v:true,
|
||||||
|
\ }]
|
||||||
|
call s:exec('nuwiki.list.renumber', l:args)
|
||||||
|
endfunction
|
||||||
|
|
||||||
|
function! nuwiki#commands#list_change_symbol(symbol, whole_list) abort
|
||||||
|
let l:p = s:cursor_position()
|
||||||
|
let l:args = [{
|
||||||
|
\ 'uri': l:p['textDocument']['uri'],
|
||||||
|
\ 'position': l:p['position'],
|
||||||
|
\ 'symbol': a:symbol,
|
||||||
|
\ 'whole_list': a:whole_list ? v:true : v:false,
|
||||||
|
\ }]
|
||||||
|
call s:exec('nuwiki.list.changeSymbol', l:args)
|
||||||
|
endfunction
|
||||||
|
|
||||||
|
function! nuwiki#commands#list_change_level(delta, whole_subtree) abort
|
||||||
|
let l:p = s:cursor_position()
|
||||||
|
let l:args = [{
|
||||||
|
\ 'uri': l:p['textDocument']['uri'],
|
||||||
|
\ 'position': l:p['position'],
|
||||||
|
\ 'delta': a:delta,
|
||||||
|
\ 'whole_subtree': a:whole_subtree ? v:true : v:false,
|
||||||
|
\ }]
|
||||||
|
call s:exec('nuwiki.list.changeLevel', l:args)
|
||||||
|
endfunction
|
||||||
|
|
||||||
|
" `:VimwikiListChangeLvl decrease|increase 0` compat entry.
|
||||||
|
function! nuwiki#commands#list_change_lvl(...) abort
|
||||||
|
let l:dir = a:0 >= 1 ? a:1 : 'increase'
|
||||||
|
let l:delta = (l:dir ==# 'increase' || l:dir ==# 'indent') ? 1 : -1
|
||||||
|
call nuwiki#commands#list_change_level(l:delta, 0)
|
||||||
endfunction
|
endfunction
|
||||||
function! nuwiki#commands#table_insert() abort
|
function! nuwiki#commands#table_insert() abort
|
||||||
call s:notify_deferred(':VimwikiTable')
|
call s:notify_deferred(':VimwikiTable')
|
||||||
|
|||||||
@@ -67,6 +67,10 @@ pub const COMMANDS: &[&str] = &[
|
|||||||
"nuwiki.wiki.gotoPage",
|
"nuwiki.wiki.gotoPage",
|
||||||
"nuwiki.link.pasteWikilink",
|
"nuwiki.link.pasteWikilink",
|
||||||
"nuwiki.link.pasteUrl",
|
"nuwiki.link.pasteUrl",
|
||||||
|
"nuwiki.list.removeDone",
|
||||||
|
"nuwiki.list.renumber",
|
||||||
|
"nuwiki.list.changeSymbol",
|
||||||
|
"nuwiki.list.changeLevel",
|
||||||
];
|
];
|
||||||
|
|
||||||
pub(crate) async fn execute(
|
pub(crate) async fn execute(
|
||||||
@@ -163,6 +167,16 @@ pub(crate) async fn execute(
|
|||||||
"nuwiki.link.pasteUrl" => {
|
"nuwiki.link.pasteUrl" => {
|
||||||
link_paste(backend, args, PasteKind::Url).map(|o| o.map(CommandOutcome::Edit))
|
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}")),
|
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 =====
|
// ===== Phase 17: HTML export commands =====
|
||||||
|
|
||||||
fn export_current(
|
fn export_current(
|
||||||
@@ -1495,6 +1642,513 @@ pub mod ops {
|
|||||||
/// on the diagnostics module's internals.
|
/// on the diagnostics module's internals.
|
||||||
pub use crate::diagnostics::collect_wiki_links;
|
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 =====
|
// ===== Phase 16: diary helpers =====
|
||||||
|
|
||||||
use crate::edits::op_create;
|
use crate::edits::op_create;
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
//! §13.1 Cluster A — list rewriters: `removeDone`, `renumber`,
|
||||||
|
//! `changeSymbol`, `changeLevel`.
|
||||||
|
|
||||||
|
use nuwiki_core::ast::ListSymbol;
|
||||||
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||||
|
use nuwiki_core::syntax::SyntaxPlugin;
|
||||||
|
use nuwiki_lsp::commands::ops;
|
||||||
|
use tower_lsp::lsp_types::{Position as LspPosition, Url};
|
||||||
|
|
||||||
|
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||||
|
VimwikiSyntax::new().parse(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn one_edit_text(edit: tower_lsp::lsp_types::WorkspaceEdit) -> String {
|
||||||
|
let changes = edit.changes.expect("changes map");
|
||||||
|
let (_, edits) = changes.into_iter().next().expect("≥1 uri");
|
||||||
|
edits
|
||||||
|
.into_iter()
|
||||||
|
.map(|e| e.new_text)
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn uri() -> Url {
|
||||||
|
Url::parse("file:///tmp/list.wiki").unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== parse_symbol + render_marker =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_symbol_accepts_short_and_long_names() {
|
||||||
|
assert_eq!(ops::parse_symbol("-"), Some(ListSymbol::Dash));
|
||||||
|
assert_eq!(ops::parse_symbol("Dash"), Some(ListSymbol::Dash));
|
||||||
|
assert_eq!(ops::parse_symbol("1."), Some(ListSymbol::Numeric));
|
||||||
|
assert_eq!(ops::parse_symbol("Numeric"), Some(ListSymbol::Numeric));
|
||||||
|
assert_eq!(ops::parse_symbol("a)"), Some(ListSymbol::AlphaParen));
|
||||||
|
assert_eq!(ops::parse_symbol("i)"), Some(ListSymbol::RomanParen));
|
||||||
|
assert!(ops::parse_symbol("nope").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_marker_handles_each_symbol() {
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::Dash, 1), "-");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::Star, 1), "*");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::Hash, 1), "#");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::Numeric, 1), "1.");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::Numeric, 4), "4.");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::NumericParen, 7), "7)");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 1), "a)");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 26), "z)");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::AlphaParen, 27), "aa)");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::AlphaUpperParen, 2), "B)");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::RomanParen, 4), "iv)");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::RomanParen, 9), "ix)");
|
||||||
|
assert_eq!(ops::render_marker(ListSymbol::RomanUpperParen, 14), "XIV)");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== removeDone =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_done_strips_done_and_rejected_items() {
|
||||||
|
let src = "- [ ] todo\n- [X] done\n- [-] rejected\n- [ ] also todo\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
let edit = ops::remove_done_edit(src, &ast, &uri(), None, true).expect("edit");
|
||||||
|
let changes = edit.changes.expect("changes");
|
||||||
|
let edits = &changes[&uri()];
|
||||||
|
assert_eq!(edits.len(), 2, "expected 2 deletions");
|
||||||
|
// Each delete edit replaces a span with empty string.
|
||||||
|
for e in edits {
|
||||||
|
assert_eq!(e.new_text, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_done_leaves_open_items_alone() {
|
||||||
|
let src = "- [ ] todo\n- [.] partial\n- [o] more\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
assert!(ops::remove_done_edit(src, &ast, &uri(), None, true).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_done_returns_none_when_no_lists() {
|
||||||
|
let src = "= heading =\nparagraph\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
assert!(ops::remove_done_edit(src, &ast, &uri(), None, true).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn remove_done_scoped_by_position_to_one_item() {
|
||||||
|
// With `position` set, only items whose line range contains the
|
||||||
|
// cursor line are considered. So a done item on line 1 is removed
|
||||||
|
// by cursor on line 1, but a done item on line 3 is left alone.
|
||||||
|
let src = "- [X] done one\n- [ ] todo\n- [X] done two\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
let pos = Some(LspPosition {
|
||||||
|
line: 0,
|
||||||
|
character: 0,
|
||||||
|
});
|
||||||
|
let edit = ops::remove_done_edit(src, &ast, &uri(), pos, true).expect("edit");
|
||||||
|
let edits = &edit.changes.unwrap()[&uri()];
|
||||||
|
assert_eq!(edits.len(), 1, "only the line-0 item should match");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== renumber =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renumber_resequences_numeric_markers() {
|
||||||
|
let src = "5. first\n9. second\n2. third\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
let edit = ops::renumber_edit(src, &ast, &uri(), 0, false, true).expect("edit");
|
||||||
|
let edits = &edit.changes.unwrap()[&uri()];
|
||||||
|
// We expect 3 marker rewrites: 5.→1., 9.→2., 2.→3.
|
||||||
|
assert_eq!(edits.len(), 3);
|
||||||
|
let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect();
|
||||||
|
assert!(texts.contains(&"1."));
|
||||||
|
assert!(texts.contains(&"2."));
|
||||||
|
assert!(texts.contains(&"3."));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renumber_ignores_unordered_lists() {
|
||||||
|
let src = "- a\n- b\n- c\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
assert!(ops::renumber_edit(src, &ast, &uri(), 0, false, true).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renumber_whole_file_walks_every_list() {
|
||||||
|
let src = "5. one\n\n2. two\n9. three\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
let edit = ops::renumber_edit(src, &ast, &uri(), 0, true, true).expect("edit");
|
||||||
|
let edits = &edit.changes.unwrap()[&uri()];
|
||||||
|
// Two separate lists — first has 1 numeric item, second has 2 →
|
||||||
|
// 3 marker rewrites total.
|
||||||
|
assert_eq!(edits.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== changeSymbol =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_symbol_rewrites_just_the_current_item() {
|
||||||
|
let src = "- a\n- b\n- c\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
let edit =
|
||||||
|
ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Star, false, true).expect("edit");
|
||||||
|
let edits = &edit.changes.unwrap()[&uri()];
|
||||||
|
assert_eq!(edits.len(), 1);
|
||||||
|
assert_eq!(edits[0].new_text, "*");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_symbol_whole_list_rewrites_every_item() {
|
||||||
|
let src = "- a\n- b\n- c\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
let edit = ops::change_symbol_edit(src, &ast, &uri(), 1, ListSymbol::Numeric, true, true)
|
||||||
|
.expect("edit");
|
||||||
|
let edits = &edit.changes.unwrap()[&uri()];
|
||||||
|
assert_eq!(edits.len(), 3);
|
||||||
|
let texts: Vec<&str> = edits.iter().map(|e| e.new_text.as_str()).collect();
|
||||||
|
assert!(texts.contains(&"1."));
|
||||||
|
assert!(texts.contains(&"2."));
|
||||||
|
assert!(texts.contains(&"3."));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== changeLevel =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_level_indents_single_item() {
|
||||||
|
let src = "- one\n- two\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
let edit = ops::change_level_edit(src, &ast, &uri(), 1, 1, false, true).expect("edit");
|
||||||
|
let edits = &edit.changes.unwrap()[&uri()];
|
||||||
|
assert_eq!(edits.len(), 1);
|
||||||
|
// delta=1 → +2 spaces
|
||||||
|
assert_eq!(edits[0].new_text, " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_level_dedents_clamps_to_zero() {
|
||||||
|
let src = "- root\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
let edit = ops::change_level_edit(src, &ast, &uri(), 0, -1, false, true);
|
||||||
|
// Already at column 0 — dedent is a no-op.
|
||||||
|
assert!(edit.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn change_level_whole_subtree_walks_descendants() {
|
||||||
|
let src = "- parent\n - child a\n - child b\n- sibling\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
let edit = ops::change_level_edit(src, &ast, &uri(), 0, 1, true, true).expect("edit");
|
||||||
|
let edits = &edit.changes.unwrap()[&uri()];
|
||||||
|
// Parent + 2 children get indented. Sibling stays.
|
||||||
|
assert!(edits.len() >= 3, "got {} edits", edits.len());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== COMMANDS list completeness =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commands_list_includes_cluster_a() {
|
||||||
|
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||||
|
for name in [
|
||||||
|
"nuwiki.list.removeDone",
|
||||||
|
"nuwiki.list.renumber",
|
||||||
|
"nuwiki.list.changeSymbol",
|
||||||
|
"nuwiki.list.changeLevel",
|
||||||
|
] {
|
||||||
|
assert!(names.contains(&name), "missing: {name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Smoke a one_edit_text helper so it's exercised by at least one test.
|
||||||
|
#[test]
|
||||||
|
fn one_edit_text_round_trip() {
|
||||||
|
let src = "- [X] done\n";
|
||||||
|
let ast = parse(src);
|
||||||
|
let edit = ops::remove_done_edit(src, &ast, &uri(), None, true).unwrap();
|
||||||
|
let _ = one_edit_text(edit);
|
||||||
|
}
|
||||||
@@ -155,14 +155,14 @@ if !has('nvim')
|
|||||||
xnoremap <silent><buffer> glp :<C-u>call nuwiki#commands#cycle_list_item()<CR>
|
xnoremap <silent><buffer> glp :<C-u>call nuwiki#commands#cycle_list_item()<CR>
|
||||||
nnoremap <silent><buffer> glx :call nuwiki#commands#reject_list_item()<CR>
|
nnoremap <silent><buffer> glx :call nuwiki#commands#reject_list_item()<CR>
|
||||||
xnoremap <silent><buffer> glx :<C-u>call nuwiki#commands#reject_list_item()<CR>
|
xnoremap <silent><buffer> glx :<C-u>call nuwiki#commands#reject_list_item()<CR>
|
||||||
nnoremap <silent><buffer> glh :echohl WarningMsg<bar>echom 'nuwiki: list level not yet implemented — see SPEC §13.1'<bar>echohl None<CR>
|
nnoremap <silent><buffer> glh :call nuwiki#commands#list_change_level(-1, 0)<CR>
|
||||||
nnoremap <silent><buffer> gll :echohl WarningMsg<bar>echom 'nuwiki: list level not yet implemented — see SPEC §13.1'<bar>echohl None<CR>
|
nnoremap <silent><buffer> gll :call nuwiki#commands#list_change_level(1, 0)<CR>
|
||||||
nnoremap <silent><buffer> gLh :echohl WarningMsg<bar>echom 'nuwiki: list level subtree not yet implemented — see SPEC §13.1'<bar>echohl None<CR>
|
nnoremap <silent><buffer> gLh :call nuwiki#commands#list_change_level(-1, 1)<CR>
|
||||||
nnoremap <silent><buffer> gLl :echohl WarningMsg<bar>echom 'nuwiki: list level subtree not yet implemented — see SPEC §13.1'<bar>echohl None<CR>
|
nnoremap <silent><buffer> gLl :call nuwiki#commands#list_change_level(1, 1)<CR>
|
||||||
nnoremap <silent><buffer> glr :echohl WarningMsg<bar>echom 'nuwiki: list renumber not yet implemented — see SPEC §13.1'<bar>echohl None<CR>
|
nnoremap <silent><buffer> glr :call nuwiki#commands#list_renumber()<CR>
|
||||||
nnoremap <silent><buffer> gLr :echohl WarningMsg<bar>echom 'nuwiki: list renumber-all not yet implemented — see SPEC §13.1'<bar>echohl None<CR>
|
nnoremap <silent><buffer> gLr :call nuwiki#commands#list_renumber_all()<CR>
|
||||||
nnoremap <silent><buffer> gl<Space> :echohl WarningMsg<bar>echom 'nuwiki: :VimwikiRemoveSingleCB not yet implemented — see SPEC §13.1'<bar>echohl None<CR>
|
nnoremap <silent><buffer> gl<Space> :call nuwiki#commands#list_remove_done()<CR>
|
||||||
nnoremap <silent><buffer> gL<Space> :echohl WarningMsg<bar>echom 'nuwiki: :VimwikiRemoveCBInList not yet implemented — see SPEC §13.1'<bar>echohl None<CR>
|
nnoremap <silent><buffer> gL<Space> :call nuwiki#commands#list_remove_done()<CR>
|
||||||
nnoremap <silent><buffer> o :call nuwiki#commands#open_below_with_bullet()<CR>
|
nnoremap <silent><buffer> o :call nuwiki#commands#open_below_with_bullet()<CR>
|
||||||
nnoremap <silent><buffer> O :call nuwiki#commands#open_above_with_bullet()<CR>
|
nnoremap <silent><buffer> O :call nuwiki#commands#open_above_with_bullet()<CR>
|
||||||
|
|
||||||
|
|||||||
+37
-3
@@ -437,9 +437,43 @@ local function _not_yet(name)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
M.list_change_lvl = _not_yet(':VimwikiListChangeLvl')
|
-- §13.1 Cluster A — list rewriters.
|
||||||
M.list_renumber = _not_yet(':VimwikiRenumber')
|
|
||||||
M.list_remove_done = _not_yet(':VimwikiRemoveDone')
|
function M.list_remove_done()
|
||||||
|
exec('nuwiki.list.removeDone', uri_args())
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.list_renumber()
|
||||||
|
exec('nuwiki.list.renumber', pos_args())
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.list_renumber_all()
|
||||||
|
local args = pos_args()[1]
|
||||||
|
args.whole_file = true
|
||||||
|
exec('nuwiki.list.renumber', { args })
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.list_change_symbol(symbol, whole_list)
|
||||||
|
local args = pos_args()[1]
|
||||||
|
args.symbol = symbol
|
||||||
|
args.whole_list = whole_list and true or false
|
||||||
|
exec('nuwiki.list.changeSymbol', { args })
|
||||||
|
end
|
||||||
|
|
||||||
|
function M.list_change_level(delta, whole_subtree)
|
||||||
|
local args = pos_args()[1]
|
||||||
|
args.delta = delta
|
||||||
|
args.whole_subtree = whole_subtree and true or false
|
||||||
|
exec('nuwiki.list.changeLevel', { args })
|
||||||
|
end
|
||||||
|
|
||||||
|
-- Vimwiki's `:VimwikiListChangeLvl decrease|increase 0` keeps a single
|
||||||
|
-- entry point. Forward to changeLevel.
|
||||||
|
function M.list_change_lvl(direction)
|
||||||
|
local delta = (direction == 'increase' or direction == 'indent') and 1 or -1
|
||||||
|
M.list_change_level(delta, false)
|
||||||
|
end
|
||||||
|
|
||||||
M.table_insert = _not_yet(':VimwikiTable')
|
M.table_insert = _not_yet(':VimwikiTable')
|
||||||
M.table_move_column_left = _not_yet(':VimwikiTableMoveColumnLeft')
|
M.table_move_column_left = _not_yet(':VimwikiTableMoveColumnLeft')
|
||||||
M.table_move_column_right = _not_yet(':VimwikiTableMoveColumnRight')
|
M.table_move_column_right = _not_yet(':VimwikiTableMoveColumnRight')
|
||||||
|
|||||||
+16
-8
@@ -220,14 +220,22 @@ function M.attach(bufnr, mappings)
|
|||||||
map('x', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: decrement checkbox' }, bufnr)
|
map('x', 'glp', cmd.cycle_list_item, { desc = 'nuwiki: decrement checkbox' }, bufnr)
|
||||||
map('n', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
|
map('n', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
|
||||||
map('x', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
|
map('x', 'glx', cmd.reject_list_item, { desc = 'nuwiki: reject checkbox' }, bufnr)
|
||||||
map('n', 'glh', deferred(':VimwikiListDecreaseLvl'), { desc = 'nuwiki: list dedent (deferred)' }, bufnr)
|
map('n', 'glh', function() cmd.list_change_level(-1, false) end,
|
||||||
map('n', 'gll', deferred(':VimwikiListIncreaseLvl'), { desc = 'nuwiki: list indent (deferred)' }, bufnr)
|
{ desc = 'nuwiki: list dedent' }, bufnr)
|
||||||
map('n', 'gLh', deferred(':VimwikiListDecreaseLvlSubtree'), { desc = 'nuwiki: list dedent subtree (deferred)' }, bufnr)
|
map('n', 'gll', function() cmd.list_change_level(1, false) end,
|
||||||
map('n', 'gLl', deferred(':VimwikiListIncreaseLvlSubtree'), { desc = 'nuwiki: list indent subtree (deferred)' }, bufnr)
|
{ desc = 'nuwiki: list indent' }, bufnr)
|
||||||
map('n', 'glr', deferred(':VimwikiRenumberList'), { desc = 'nuwiki: renumber list (deferred)' }, bufnr)
|
map('n', 'gLh', function() cmd.list_change_level(-1, true) end,
|
||||||
map('n', 'gLr', deferred(':VimwikiRenumberAllLists'), { desc = 'nuwiki: renumber all lists (deferred)' }, bufnr)
|
{ desc = 'nuwiki: list dedent subtree' }, bufnr)
|
||||||
map('n', 'gl<Space>', deferred(':VimwikiRemoveSingleCB'), { desc = 'nuwiki: remove checkbox (deferred)' }, bufnr)
|
map('n', 'gLl', function() cmd.list_change_level(1, true) end,
|
||||||
map('n', 'gL<Space>', deferred(':VimwikiRemoveCBInList'), { desc = 'nuwiki: remove all checkboxes (deferred)' }, bufnr)
|
{ desc = 'nuwiki: list indent subtree' }, bufnr)
|
||||||
|
map('n', 'glr', cmd.list_renumber,
|
||||||
|
{ desc = 'nuwiki: renumber list' }, bufnr)
|
||||||
|
map('n', 'gLr', cmd.list_renumber_all,
|
||||||
|
{ desc = 'nuwiki: renumber all lists' }, bufnr)
|
||||||
|
map('n', 'gl<Space>', cmd.list_remove_done,
|
||||||
|
{ desc = 'nuwiki: remove done items' }, bufnr)
|
||||||
|
map('n', 'gL<Space>', cmd.list_remove_done,
|
||||||
|
{ desc = 'nuwiki: remove done items (whole doc)' }, bufnr)
|
||||||
map('n', 'o', open_below_with_bullet, { desc = 'nuwiki: open below + bullet' }, bufnr)
|
map('n', 'o', open_below_with_bullet, { desc = 'nuwiki: open below + bullet' }, bufnr)
|
||||||
map('n', 'O', open_above_with_bullet, { desc = 'nuwiki: open above + bullet' }, bufnr)
|
map('n', 'O', open_above_with_bullet, { desc = 'nuwiki: open above + bullet' }, bufnr)
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user