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:
@@ -551,6 +551,47 @@ function! nuwiki#commands#list_change_lvl(...) abort
|
||||
let l:delta = (l:dir ==# 'increase' || l:dir ==# 'indent') ? 1 : -1
|
||||
call nuwiki#commands#list_change_level(l:delta, 0)
|
||||
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.
|
||||
|
||||
function! nuwiki#commands#table_insert(...) abort
|
||||
|
||||
@@ -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_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
|
||||
nnoremap <silent><buffer> = :call nuwiki#commands#heading_add()<CR>
|
||||
nnoremap <silent><buffer> - :call nuwiki#commands#heading_remove()<CR>
|
||||
|
||||
@@ -515,6 +515,73 @@ function M.list_change_lvl(direction)
|
||||
M.list_change_level(delta, false)
|
||||
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.
|
||||
|
||||
function M.table_insert(cols, rows)
|
||||
|
||||
@@ -240,6 +240,20 @@ function M.attach(bufnr, mappings)
|
||||
{ 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_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
|
||||
|
||||
-- ===== Header levels + nav =====
|
||||
|
||||
@@ -101,6 +101,16 @@ local function wait_for_lsp(timeout_ms)
|
||||
return nil
|
||||
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 =====
|
||||
|
||||
vim.defer_fn(function()
|
||||
@@ -272,6 +282,58 @@ vim.defer_fn(function()
|
||||
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 =====
|
||||
-- Tested via the LSP open-* responses; can't fully validate without
|
||||
-- a populated diary, but at least confirm the maps fire without
|
||||
@@ -280,6 +342,15 @@ vim.defer_fn(function()
|
||||
-- ===== Wrap up =====
|
||||
|
||||
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))
|
||||
vim.fn.writefile(out_lines, OUT)
|
||||
vim.cmd(fail == 0 and 'qall!' or 'cquit!')
|
||||
|
||||
@@ -20,14 +20,15 @@ trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
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"
|
||||
if [[ ! -x "$BIN" ]]; then
|
||||
log 'building nuwiki-ls (release)…'
|
||||
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null
|
||||
mkdir -p "$REPO_ROOT/bin"
|
||||
ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN"
|
||||
fi
|
||||
log 'building nuwiki-ls (release, incremental)…'
|
||||
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml" >/dev/null
|
||||
mkdir -p "$REPO_ROOT/bin"
|
||||
ln -sf "$REPO_ROOT/target/release/nuwiki-ls" "$BIN"
|
||||
|
||||
mkdir -p "$TMP/wiki"
|
||||
echo "= seed =" > "$TMP/wiki/index.wiki"
|
||||
|
||||
Reference in New Issue
Block a user