parity(2): insert-mode list bindings (<C-D>/<C-T>/<C-L><C-?>)

Match upstream vimwiki's insert-mode list editing:
  <C-D>      list_change_level(-1)   dedent current item
  <C-T>      list_change_level(+1)   indent current item
  <C-L><C-J> list_cycle_symbol(+1)   cycle marker forward
  <C-L><C-K> list_cycle_symbol(-1)   cycle marker backward
  <C-L><C-M> list_toggle_or_add_chk  toggle if has checkbox, else add `[ ]`

Cycle order matches vimwiki's canonical run:
  - → * → # → 1. → 1) → a) → A) → i) → I) → (wrap)

Both Lua and VimL paths wired. Lua callbacks fire directly (no `<C-o>`
dance) since list_change_level / changeSymbol mutate the buffer via
the LSP applyEdit pipeline, which works cleanly in insert mode. VimL
keeps the `<C-o>:call …<CR>` idiom since that's the standard there.

Harness adds 5 cases covering the new insert-mode bindings plus one
direct LSP roundtrip for changeSymbol — surfaced a long-standing
stale-binary footgun in test-keymaps.sh (rebuilt only when bin was
missing; now rebuilds every run since incremental cargo is fast).
Harness also captures server log_messages and dumps them on failure
so future swallowed-error bugs (Err → log + Ok(None)) are visible.

Gates: 421 Rust / 25 Neovim / 12 Vim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 21:11:53 +00:00
parent ee0309b3c2
commit 46ae618b5f
6 changed files with 208 additions and 7 deletions
+41
View File
@@ -551,6 +551,47 @@ function! nuwiki#commands#list_change_lvl(...) abort
let l:delta = (l:dir ==# 'increase' || l:dir ==# 'indent') ? 1 : -1 let l:delta = (l:dir ==# 'increase' || l:dir ==# 'indent') ? 1 : -1
call nuwiki#commands#list_change_level(l:delta, 0) call nuwiki#commands#list_change_level(l:delta, 0)
endfunction endfunction
" Cycle the list marker through vimwiki's canonical order.
let s:symbol_order = ['-', '*', '#', '1.', '1)', 'a)', 'A)', 'i)', 'I)']
function! s:detect_symbol() abort
let l:line = getline('.')
let l:m = matchlist(l:line, '^\s*\([-*#]\)\s')
if !empty(l:m) | return l:m[1] | endif
let l:m = matchlist(l:line, '^\s*\d\+\([.)]\)\s')
if !empty(l:m) | return '1' . l:m[1] | endif
if l:line =~# '^\s*[ivxlcdm]\+)\s' | return 'i)' | endif
if l:line =~# '^\s*[IVXLCDM]\+)\s' | return 'I)' | endif
if l:line =~# '^\s*[a-z]\+)\s' | return 'a)' | endif
if l:line =~# '^\s*[A-Z]\+)\s' | return 'A)' | endif
return ''
endfunction
function! nuwiki#commands#list_cycle_symbol(direction) abort
let l:cur = s:detect_symbol()
if l:cur ==# '' | return | endif
let l:idx = index(s:symbol_order, l:cur)
if l:idx < 0 | return | endif
let l:n = (l:idx + a:direction) % len(s:symbol_order)
if l:n < 0 | let l:n += len(s:symbol_order) | endif
call nuwiki#commands#list_change_symbol(s:symbol_order[l:n], 0)
endfunction
function! nuwiki#commands#list_toggle_or_add_checkbox() abort
let l:line = getline('.')
if l:line =~# '^\s*[-*#]\s\+\[[ xXoO.\-]\]' || l:line =~# '^\s*\w\+[.)]\s\+\[[ xXoO.\-]\]'
call nuwiki#commands#toggle_list_item()
return
endif
let l:end = matchend(l:line, '^\s*[-*#] ')
if l:end < 0
let l:end = matchend(l:line, '^\s*\w\+[.)] ')
endif
if l:end < 0 | return | endif
let l:new = strpart(l:line, 0, l:end) . '[ ] ' . strpart(l:line, l:end)
call setline('.', l:new)
endfunction
" §13.1 Cluster B — table rewriters. " §13.1 Cluster B — table rewriters.
function! nuwiki#commands#table_insert(...) abort function! nuwiki#commands#table_insert(...) abort
+7
View File
@@ -169,6 +169,13 @@ if !has('nvim')
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>
" Insert-mode list editing (vimwiki parity).
inoremap <silent><buffer> <C-D> <C-o>:call nuwiki#commands#list_change_level(-1, 0)<CR>
inoremap <silent><buffer> <C-T> <C-o>:call nuwiki#commands#list_change_level(1, 0)<CR>
inoremap <silent><buffer> <C-L><C-J> <C-o>:call nuwiki#commands#list_cycle_symbol(1)<CR>
inoremap <silent><buffer> <C-L><C-K> <C-o>:call nuwiki#commands#list_cycle_symbol(-1)<CR>
inoremap <silent><buffer> <C-L><C-M> <C-o>:call nuwiki#commands#list_toggle_or_add_checkbox()<CR>
" Headers " Headers
nnoremap <silent><buffer> = :call nuwiki#commands#heading_add()<CR> nnoremap <silent><buffer> = :call nuwiki#commands#heading_add()<CR>
nnoremap <silent><buffer> - :call nuwiki#commands#heading_remove()<CR> nnoremap <silent><buffer> - :call nuwiki#commands#heading_remove()<CR>
+67
View File
@@ -515,6 +515,73 @@ function M.list_change_lvl(direction)
M.list_change_level(delta, false) M.list_change_level(delta, false)
end end
--- Cycle the list marker forward (`direction = 1`) or backward
--- (`direction = -1`). Pure client-side: detects the current line's
--- marker via regex, picks the next symbol in vimwiki's canonical
--- order, and dispatches `list.changeSymbol`.
local _symbol_order = { '-', '*', '#', '1.', '1)', 'a)', 'A)', 'i)', 'I)' }
local function detect_current_symbol()
local line = vim.api.nvim_get_current_line()
-- Match optional indent, marker, then whitespace.
local indent, marker = line:match('^(%s*)([%-%*%#]) ')
if marker then return marker end
local n
indent, n, marker = line:match('^(%s*)(%d+)([%.%)]) ')
if marker then return '1' .. marker end
-- Roman lower
indent, marker = line:match('^(%s*)([ivxlcdm]+)%) ')
if marker then return 'i)' end
-- Roman upper
indent, marker = line:match('^(%s*)([IVXLCDM]+)%) ')
if marker then return 'I)' end
-- Alpha lower (single letter or short run)
indent, marker = line:match('^(%s*)([a-z]+)%) ')
if marker then return 'a)' end
indent, marker = line:match('^(%s*)([A-Z]+)%) ')
if marker then return 'A)' end
-- Suppress unused warning
local _ = indent
return nil
end
function M.list_cycle_symbol(direction)
local current = detect_current_symbol()
if not current then return end
local idx
for i, s in ipairs(_symbol_order) do
if s == current then idx = i break end
end
if not idx then return end
local next_idx = ((idx - 1 + (direction or 1)) % #_symbol_order) + 1
M.list_change_symbol(_symbol_order[next_idx], false)
end
--- `<C-L><C-M>` (insert mode) — toggle the checkbox on the current
--- item if any, OR insert `[ ]` after the marker if the line is a
--- plain list item without one. Pure client-side.
function M.list_toggle_or_add_checkbox()
local line = vim.api.nvim_get_current_line()
-- Already has a checkbox? Let the server's toggle handle it.
if line:match('%s*[%-%*%#]%s+%[[%sxXoO%.%-]%]') or
line:match('%s*%w+[%.%)]%s+%[[%sxXoO%.%-]%]') then
M.toggle_list_item()
return
end
-- Looks like a list item without a checkbox — insert `[ ] ` after
-- the marker.
local row = vim.api.nvim_win_get_cursor(0)[1]
local insert_at = nil
local function try(pattern)
local start_, finish = line:find(pattern)
if start_ then return finish end
end
insert_at = try('^%s*[%-%*%#] ') or try('^%s*%w+[%.%)] ')
if not insert_at then return end
local new_line = line:sub(1, insert_at) .. '[ ] ' .. line:sub(insert_at + 1)
vim.api.nvim_buf_set_lines(0, row - 1, row, false, { new_line })
end
-- §13.1 Cluster B — table rewriters. -- §13.1 Cluster B — table rewriters.
function M.table_insert(cols, rows) function M.table_insert(cols, rows)
+14
View File
@@ -240,6 +240,20 @@ function M.attach(bufnr, mappings)
{ desc = 'nuwiki: remove done items (whole doc)' }, bufnr) { 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)
-- Insert-mode bindings (matches upstream vimwiki). The list helpers
-- mutate the buffer via the LSP / `nvim_buf_set_lines`, both of
-- which work cleanly from insert mode, so no `<C-o>` dance needed.
map('i', '<C-D>', function() cmd.list_change_level(-1, false) end,
{ desc = 'nuwiki: list dedent (insert)' }, bufnr)
map('i', '<C-T>', function() cmd.list_change_level(1, false) end,
{ desc = 'nuwiki: list indent (insert)' }, bufnr)
map('i', '<C-L><C-J>', function() cmd.list_cycle_symbol(1) end,
{ desc = 'nuwiki: cycle list symbol (insert)' }, bufnr)
map('i', '<C-L><C-K>', function() cmd.list_cycle_symbol(-1) end,
{ desc = 'nuwiki: cycle list symbol backward (insert)' }, bufnr)
map('i', '<C-L><C-M>', cmd.list_toggle_or_add_checkbox,
{ desc = 'nuwiki: toggle/add checkbox (insert)' }, bufnr)
end end
-- ===== Header levels + nav ===== -- ===== Header levels + nav =====
+71
View File
@@ -101,6 +101,16 @@ local function wait_for_lsp(timeout_ms)
return nil return nil
end end
-- ===== Capture server log_message events =====
local server_log = {}
local orig_handler = vim.lsp.handlers['window/logMessage']
vim.lsp.handlers['window/logMessage'] = function(err, result, ctx, cfg)
if result and result.message then
table.insert(server_log, '[' .. tostring(result.type) .. '] ' .. result.message)
end
if orig_handler then return orig_handler(err, result, ctx, cfg) end
end
-- ===== Smoke: LSP attached ===== -- ===== Smoke: LSP attached =====
vim.defer_fn(function() vim.defer_fn(function()
@@ -272,6 +282,58 @@ vim.defer_fn(function()
wait_ms = 100, wait_ms = 100,
}) })
-- ===== Insert-mode list bindings (Cluster 2) =====
run('insert.C-L_C-M_adds_checkbox', {
lines = { '- task' },
cursor = { 1, 0 },
keys = 'A<C-L><C-M><Esc>',
expect_line = '- [ ] task',
expect_at = 1,
wait_ms = 200,
})
run('insert.C-L_C-M_toggles_existing_checkbox', {
lines = { '- [ ] task' },
cursor = { 1, 0 },
keys = 'A<C-L><C-M><Esc>',
expect_line = '- [X] task',
expect_at = 1,
})
-- End-to-end regression for changeSymbol over LSP — direct call
-- (no keymap) so we can spot a stale `bin/nuwiki-ls` or a dispatch
-- error fast. Toggle has separate coverage above via `<C-Space>`.
do
local ok, err = pcall(function()
set_buf({ '- [ ] item', '- [ ] two' })
sleep(200)
vim.api.nvim_win_set_cursor(0, { 1, 0 })
require('nuwiki.commands').list_change_symbol('*', false)
sleep(1500)
local got = get_line(1)
if got ~= '* [ ] item' then
error(string.format('change_symbol failed: got %q', got))
end
end)
record(ok, 'direct.list_change_symbol_via_lsp', ok and '' or tostring(err))
end
run('insert.C-L_C-J_cycles_marker_forward', {
lines = { '- item' },
cursor = { 1, 0 },
keys = 'A<C-L><C-J><Esc>',
expect_line = '* item',
expect_at = 1,
wait_ms = 1500,
})
run('insert.C-L_C-K_cycles_marker_backward', {
-- From `-` going backward wraps to the end of the order (`I)`).
lines = { '- item' },
cursor = { 1, 0 },
keys = 'A<C-L><C-K><Esc>',
expect_line = 'I) item',
expect_at = 1,
wait_ms = 1500,
})
-- ===== Diary nav ===== -- ===== Diary nav =====
-- Tested via the LSP open-* responses; can't fully validate without -- Tested via the LSP open-* responses; can't fully validate without
-- a populated diary, but at least confirm the maps fire without -- a populated diary, but at least confirm the maps fire without
@@ -280,6 +342,15 @@ vim.defer_fn(function()
-- ===== Wrap up ===== -- ===== Wrap up =====
table.insert(out_lines, '') table.insert(out_lines, '')
-- Dump captured server log_messages only on failure — useful for
-- diagnosing dispatch errors that the LSP swallows into log_message.
if fail > 0 and #server_log > 0 then
table.insert(out_lines, '--- server log_messages ---')
for _, l in ipairs(server_log) do
table.insert(out_lines, l)
end
table.insert(out_lines, '')
end
table.insert(out_lines, string.format('SUMMARY: %d passed, %d failed', pass, fail)) table.insert(out_lines, string.format('SUMMARY: %d passed, %d failed', pass, fail))
vim.fn.writefile(out_lines, OUT) vim.fn.writefile(out_lines, OUT)
vim.cmd(fail == 0 and 'qall!' or 'cquit!') vim.cmd(fail == 0 and 'qall!' or 'cquit!')
+5 -4
View File
@@ -20,14 +20,15 @@ trap 'rm -rf "$TMP"' EXIT
log() { printf '\033[1;34m[keymap-test]\033[0m %s\n' "$*"; } log() { printf '\033[1;34m[keymap-test]\033[0m %s\n' "$*"; }
# Build the LSP binary if missing. # Always rebuild the LSP binary so harness runs against current sources.
# A previous version only built when the symlink was missing, which let
# stale binaries pass tests against unrelated code — incremental cargo
# build is fast, so just always run it.
BIN="$REPO_ROOT/bin/nuwiki-ls" BIN="$REPO_ROOT/bin/nuwiki-ls"
if [[ ! -x "$BIN" ]]; then log 'building nuwiki-ls (release, incremental)…'
log 'building nuwiki-ls (release)…'
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null
mkdir -p "$REPO_ROOT/bin" mkdir -p "$REPO_ROOT/bin"
ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN" ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN"
fi
mkdir -p "$TMP/wiki" mkdir -p "$TMP/wiki"
echo "= seed =" > "$TMP/wiki/index.wiki" echo "= seed =" > "$TMP/wiki/index.wiki"