From 46ae618b5fae3567d6be54b75cf36d64fb8b08e1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Tue, 12 May 2026 21:11:53 +0000 Subject: [PATCH] parity(2): insert-mode list bindings (//) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Match upstream vimwiki's insert-mode list editing: list_change_level(-1) dedent current item list_change_level(+1) indent current item list_cycle_symbol(+1) cycle marker forward list_cycle_symbol(-1) cycle marker backward 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 `` dance) since list_change_level / changeSymbol mutate the buffer via the LSP applyEdit pipeline, which works cleanly in insert mode. VimL keeps the `:call …` 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) --- autoload/nuwiki/commands.vim | 41 +++++++++++++++++++++ ftplugin/vimwiki.vim | 7 ++++ lua/nuwiki/commands.lua | 67 ++++++++++++++++++++++++++++++++++ lua/nuwiki/keymaps.lua | 14 +++++++ scripts/test-keymaps.lua | 71 ++++++++++++++++++++++++++++++++++++ scripts/test-keymaps.sh | 15 ++++---- 6 files changed, 208 insertions(+), 7 deletions(-) diff --git a/autoload/nuwiki/commands.vim b/autoload/nuwiki/commands.vim index a32f56e..275c830 100644 --- a/autoload/nuwiki/commands.vim +++ b/autoload/nuwiki/commands.vim @@ -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 diff --git a/ftplugin/vimwiki.vim b/ftplugin/vimwiki.vim index 48b0f36..d1752b9 100644 --- a/ftplugin/vimwiki.vim +++ b/ftplugin/vimwiki.vim @@ -169,6 +169,13 @@ if !has('nvim') nnoremap o :call nuwiki#commands#open_below_with_bullet() nnoremap O :call nuwiki#commands#open_above_with_bullet() + " Insert-mode list editing (vimwiki parity). + inoremap :call nuwiki#commands#list_change_level(-1, 0) + inoremap :call nuwiki#commands#list_change_level(1, 0) + inoremap :call nuwiki#commands#list_cycle_symbol(1) + inoremap :call nuwiki#commands#list_cycle_symbol(-1) + inoremap :call nuwiki#commands#list_toggle_or_add_checkbox() + " Headers nnoremap = :call nuwiki#commands#heading_add() nnoremap - :call nuwiki#commands#heading_remove() diff --git a/lua/nuwiki/commands.lua b/lua/nuwiki/commands.lua index 4e9c321..725e047 100644 --- a/lua/nuwiki/commands.lua +++ b/lua/nuwiki/commands.lua @@ -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 + +--- `` (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) diff --git a/lua/nuwiki/keymaps.lua b/lua/nuwiki/keymaps.lua index 5b2ee63..76961be 100644 --- a/lua/nuwiki/keymaps.lua +++ b/lua/nuwiki/keymaps.lua @@ -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 `` dance needed. + map('i', '', function() cmd.list_change_level(-1, false) end, + { desc = 'nuwiki: list dedent (insert)' }, bufnr) + map('i', '', function() cmd.list_change_level(1, false) end, + { desc = 'nuwiki: list indent (insert)' }, bufnr) + map('i', '', function() cmd.list_cycle_symbol(1) end, + { desc = 'nuwiki: cycle list symbol (insert)' }, bufnr) + map('i', '', function() cmd.list_cycle_symbol(-1) end, + { desc = 'nuwiki: cycle list symbol backward (insert)' }, bufnr) + map('i', '', cmd.list_toggle_or_add_checkbox, + { desc = 'nuwiki: toggle/add checkbox (insert)' }, bufnr) end -- ===== Header levels + nav ===== diff --git a/scripts/test-keymaps.lua b/scripts/test-keymaps.lua index b42e3fc..cafa0d1 100644 --- a/scripts/test-keymaps.lua +++ b/scripts/test-keymaps.lua @@ -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', + 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', + 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 ``. + 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', + 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', + 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!') diff --git a/scripts/test-keymaps.sh b/scripts/test-keymaps.sh index c38a79c..e3061aa 100755 --- a/scripts/test-keymaps.sh +++ b/scripts/test-keymaps.sh @@ -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"