Fix glp backward cycle and gl<Space> current-list scope
CI / cargo fmt --check (push) Successful in 16s
CI / cargo clippy (push) Successful in 21s
CI / cargo test (push) Successful in 37s
CI / cargo fmt --check (pull_request) Successful in 34s
CI / cargo clippy (pull_request) Successful in 36s
CI / cargo test (pull_request) Successful in 38s
CI / editor keymaps (push) Successful in 1m23s
CI / editor keymaps (pull_request) Successful in 1m31s

Two documented checkbox-list mappings did not match their spec:

- `glp` dispatched the same forward-only cycleCheckbox as `gln`, so it
  could never "cycle the checkbox state backward". Add `ops::cycle_state_back`
  and thread a `reverse` flag through the dispatcher and both clients.

- `gl<Space>` and `gL<Space>` both swept the whole buffer, making them
  identical. `gl<Space>` now passes the cursor position and the server
  scopes deletion to the contiguous list block under the cursor (current
  list, cascading into sublists); `gL<Space>` stays whole-buffer.

Adds Rust unit tests, plus behavioral and full-registration coverage for
the documented mapping surface in the Neovim and Vim keymap harnesses, and
a command-coverage suite mirroring the doc's command groups.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 13:56:28 -03:00
parent 93725501dc
commit 21ac9deb23
12 changed files with 1016 additions and 25 deletions
+3
View File
@@ -33,6 +33,9 @@ VIMRC="$TMP/vimrc"
cat > "$VIMRC" <<EOF
set nocompatible
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath
" Enable the opt-in mouse mappings before the ftplugin loads so the
" harness can assert the full documented mapping surface (doc §6 mouse).
let g:nuwiki_mouse_mappings = 1
filetype plugin indent on
syntax enable
" ftdetect/nuwiki.vim already maps *.wiki → vimwiki via \`set filetype\`.
+107
View File
@@ -95,6 +95,107 @@ else
call s:record(0, 'cmd.NuwikiIndex_defined', ':NuwikiIndex not registered')
endif
" ===== Documented command surface (doc/nuwiki.txt §5) =====
" Every command must be reachable in BOTH the canonical :Nuwiki* form and
" the vimwiki-compatible :Vimwiki* form. The suffixes below are the §5 list
" minus the prefix. :NuwikiInstall is the one exception — it's a nuwiki-only
" maintenance command with no :Vimwiki* alias, so it's checked separately.
let s:both_forms = [
\ 'Index', 'TabIndex', 'UISelect', 'Goto', 'FollowLink', 'Backlinks',
\ 'NextLink', 'PrevLink', 'BaddLink', 'RenameFile', 'DeleteFile',
\ 'MakeDiaryNote', 'MakeYesterdayDiaryNote', 'MakeTomorrowDiaryNote',
\ 'DiaryIndex', 'DiaryGenerateLinks', 'DiaryNextDay', 'DiaryPrevDay',
\ 'TOC', 'GenerateLinks', 'CheckLinks', 'SearchTags', 'GenerateTagLinks',
\ 'RebuildTags', '2HTML', '2HTMLBrowse', 'All2HTML', 'Rss',
\ ]
for s:suffix in s:both_forms
for s:prefix in ['Nuwiki', 'Vimwiki']
let s:cmd = s:prefix . s:suffix
let s:ok = exists(':' . s:cmd) == 2
call s:record(s:ok ? 1 : 0, 'surface.' . s:cmd,
\ s:ok ? '' : ':' . s:cmd . ' not registered')
endfor
endfor
let s:ok = exists(':NuwikiInstall') == 2
call s:record(s:ok ? 1 : 0, 'surface.NuwikiInstall',
\ s:ok ? '' : ':NuwikiInstall not registered')
" ===== Pure-VimL command invocation (cursor movement) =====
" :NuwikiNextLink / :NuwikiPrevLink wrap search('\[\[', …) so they run
" entirely in VimL and move the cursor without touching the LSP.
call s:set_buf(['intro [[A]] more [[B]] end'])
call cursor(1, 1)
silent NuwikiNextLink
call s:record(
\ col('.') == 7 ? 1 : 0,
\ 'invoke.NuwikiNextLink_jumps_to_first_link',
\ 'col=' . col('.'))
silent NuwikiNextLink
call s:record(
\ col('.') == 18 ? 1 : 0,
\ 'invoke.NuwikiNextLink_jumps_to_second_link',
\ 'col=' . col('.'))
silent NuwikiPrevLink
call s:record(
\ col('.') == 7 ? 1 : 0,
\ 'invoke.NuwikiPrevLink_jumps_back',
\ 'col=' . col('.'))
" The :Vimwiki* alias must drive the identical motion.
call cursor(1, 1)
silent VimwikiNextLink
call s:record(
\ col('.') == 7 ? 1 : 0,
\ 'invoke.VimwikiNextLink_jumps_to_first_link',
\ 'col=' . col('.'))
" ===== Documented mapping surface (doc §6/§7/§8) =====
" Every documented mapping must resolve to a buffer-local mapping in the
" mode(s) the doc lists it under. The mouse group is opt-in — the shell
" wrapper sets g:nuwiki_mouse_mappings before the ftplugin loads.
function! s:has_map(lhs, mode) abort
let l:d = maparg(a:lhs, a:mode, 0, 1)
return !empty(l:d) && get(l:d, 'buffer', 0)
endfunction
let s:mapping_surface = [
\ ['<CR>', 'n'], ['<S-CR>', 'n'], ['<C-CR>', 'n'], ['<C-S-CR>', 'n'],
\ ['<BS>', 'n'], ['<Tab>', 'n'], ['<S-Tab>', 'n'], ['+', 'nx'],
\ ['<C-Space>', 'nx'], ['<C-@>', 'nx'], ['<Nul>', 'nx'],
\ ['gnt', 'n'], ['gln', 'nx'], ['glp', 'nx'], ['glx', 'nx'],
\ ['glh', 'n'], ['gll', 'n'], ['gLh', 'n'], ['gLl', 'n'],
\ ['glr', 'n'], ['gLr', 'n'], ['gl<Space>', 'n'], ['gL<Space>', 'n'],
\ ['o', 'n'], ['O', 'n'],
\ ['=', 'n'], ['-', 'n'], [']]', 'n'], ['[[', 'n'],
\ [']=', 'n'], ['[=', 'n'], [']u', 'n'], ['[u', 'n'],
\ ['gqq', 'n'], ['gq1', 'n'], ['gww', 'n'], ['gw1', 'n'],
\ ['<A-Left>', 'n'], ['<A-Right>', 'n'],
\ ['<Leader>ww', 'n'], ['<Leader>ws', 'n'], ['<Leader>wi', 'n'],
\ ['<Leader>w<Leader>w', 'n'], ['<Leader>w<Leader>y', 'n'],
\ ['<Leader>w<Leader>t', 'n'], ['<Leader>w<Leader>i', 'n'],
\ ['<Leader>wn', 'n'], ['<Leader>wd', 'n'], ['<Leader>wr', 'n'],
\ ['<Leader>wh', 'n'], ['<Leader>whh', 'n'], ['<Leader>wha', 'n'],
\ ['<Leader>wc', 'nx'], ['<C-Down>', 'n'], ['<C-Up>', 'n'],
\ ['<2-LeftMouse>', 'n'], ['<S-2-LeftMouse>', 'n'], ['<C-2-LeftMouse>', 'n'],
\ ['<MiddleMouse>', 'n'], ['<RightMouse>', 'n'],
\ ['ah', 'ox'], ['ih', 'ox'], ['aH', 'ox'], ['iH', 'ox'],
\ ['al', 'ox'], ['il', 'ox'], ['a\', 'ox'], ['i\', 'ox'],
\ ['ac', 'ox'], ['ic', 'ox'],
\ ['<CR>', 'i'], ['<Tab>', 'i'], ['<S-Tab>', 'i'],
\ ['<C-D>', 'i'], ['<C-T>', 'i'],
\ ['<C-L><C-J>', 'i'], ['<C-L><C-K>', 'i'], ['<C-L><C-M>', 'i'],
\ ]
for s:entry in s:mapping_surface
for s:m in split(s:entry[1], '\zs')
let s:ok = s:has_map(s:entry[0], s:m)
call s:record(s:ok ? 1 : 0, 'map[' . s:m . '].' . s:entry[0],
\ s:ok ? '' : s:entry[0] . ' (' . s:m . ') not mapped buffer-local')
endfor
endfor
" ===== Header nav (pure VimL) =====
call s:run('headers.]]_jumps_to_next', {
@@ -121,6 +222,12 @@ call s:run('headers.]=_jumps_to_next_sibling', {
\ 'keys': ']=',
\ 'expect_cursor_line': 4,
\ })
call s:run('headers.[=_jumps_to_prev_sibling', {
\ 'lines': ['= A =', '== child ==', 'body', '= B ='],
\ 'cursor': [4, 1],
\ 'keys': '[=',
\ 'expect_cursor_line': 1,
\ })
" ===== Link nav (pure VimL) =====
+177
View File
@@ -123,6 +123,105 @@ vim.defer_fn(function()
end
record(true, 'lsp.attached', 'client=' .. client.name)
-- ===== Documented command surface (doc/nuwiki.txt §5) =====
-- Every command must be reachable in BOTH the canonical :Nuwiki* form
-- and the vimwiki-compatible :Vimwiki* form. The suffixes below are the
-- §5 list minus the prefix. :NuwikiInstall is the one exception — a
-- nuwiki-only maintenance command with no :Vimwiki* alias.
local both_forms = {
'Index', 'TabIndex', 'UISelect', 'Goto', 'FollowLink', 'Backlinks',
'NextLink', 'PrevLink', 'BaddLink', 'RenameFile', 'DeleteFile',
'MakeDiaryNote', 'MakeYesterdayDiaryNote', 'MakeTomorrowDiaryNote',
'DiaryIndex', 'DiaryGenerateLinks', 'DiaryNextDay', 'DiaryPrevDay',
'TOC', 'GenerateLinks', 'CheckLinks', 'SearchTags', 'GenerateTagLinks',
'RebuildTags', '2HTML', '2HTMLBrowse', 'All2HTML', 'Rss',
}
for _, suffix in ipairs(both_forms) do
for _, prefix in ipairs({ 'Nuwiki', 'Vimwiki' }) do
local cmd = prefix .. suffix
local exists = vim.fn.exists(':' .. cmd) == 2
record(exists, 'surface.' .. cmd, exists and '' or (':' .. cmd .. ' not registered'))
end
end
do
local exists = vim.fn.exists(':NuwikiInstall') == 2
record(exists, 'surface.NuwikiInstall',
exists and '' or ':NuwikiInstall not registered')
end
-- ===== Pure-Lua command invocation (cursor movement) =====
-- :NuwikiNextLink / :NuwikiPrevLink wrap the link-search motion and run
-- without the LSP, so we can assert exact cursor movement here.
do
local ok, err = pcall(function()
set_buf({ 'intro [[A]] more [[B]] end' })
vim.api.nvim_win_set_cursor(0, { 1, 0 })
vim.cmd('NuwikiNextLink')
local c1 = vim.api.nvim_win_get_cursor(0)[2]
if c1 ~= 6 then error('NextLink #1 col=' .. c1 .. ' want 6') end
vim.cmd('NuwikiNextLink')
local c2 = vim.api.nvim_win_get_cursor(0)[2]
if c2 ~= 17 then error('NextLink #2 col=' .. c2 .. ' want 17') end
vim.cmd('VimwikiPrevLink')
local c3 = vim.api.nvim_win_get_cursor(0)[2]
if c3 ~= 6 then error('PrevLink col=' .. c3 .. ' want 6') end
end)
record(ok, 'invoke.next_prev_link_cursor_movement', ok and '' or tostring(err))
end
-- ===== Documented mapping surface (doc §6/§7/§8) =====
-- Every documented mapping must resolve to a buffer-local mapping in
-- the mode(s) the doc lists it under. `modes` is a string of mode
-- letters: n/x/o/i. The mouse group is opt-in — the harness enables it
-- via setup({ mappings = { mouse = true } }).
local function has_map(lhs, mode)
local d = vim.fn.maparg(lhs, mode, false, true)
return next(d) ~= nil and d.buffer == 1
end
local mapping_surface = {
-- §6 Links
{ '<CR>', 'n' }, { '<S-CR>', 'n' }, { '<C-CR>', 'n' }, { '<C-S-CR>', 'n' },
{ '<BS>', 'n' }, { '<Tab>', 'n' }, { '<S-Tab>', 'n' }, { '+', 'nx' },
-- §6 Lists
{ '<C-Space>', 'nx' }, { '<C-@>', 'nx' }, { '<Nul>', 'nx' },
{ 'gnt', 'n' }, { 'gln', 'nx' }, { 'glp', 'nx' }, { 'glx', 'nx' },
{ 'glh', 'n' }, { 'gll', 'n' }, { 'gLh', 'n' }, { 'gLl', 'n' },
{ 'glr', 'n' }, { 'gLr', 'n' }, { 'gl<Space>', 'n' }, { 'gL<Space>', 'n' },
{ 'o', 'n' }, { 'O', 'n' },
-- §6 Headers
{ '=', 'n' }, { '-', 'n' }, { ']]', 'n' }, { '[[', 'n' },
{ ']=', 'n' }, { '[=', 'n' }, { ']u', 'n' }, { '[u', 'n' },
-- §6 Tables
{ 'gqq', 'n' }, { 'gq1', 'n' }, { 'gww', 'n' }, { 'gw1', 'n' },
{ '<A-Left>', 'n' }, { '<A-Right>', 'n' },
-- §6 Wiki / diary / export
{ '<Leader>ww', 'n' }, { '<Leader>ws', 'n' }, { '<Leader>wi', 'n' },
{ '<Leader>w<Leader>w', 'n' }, { '<Leader>w<Leader>y', 'n' },
{ '<Leader>w<Leader>t', 'n' }, { '<Leader>w<Leader>i', 'n' },
{ '<Leader>wn', 'n' }, { '<Leader>wd', 'n' }, { '<Leader>wr', 'n' },
{ '<Leader>wh', 'n' }, { '<Leader>whh', 'n' }, { '<Leader>wha', 'n' },
{ '<Leader>wc', 'nx' }, { '<C-Down>', 'n' }, { '<C-Up>', 'n' },
-- §6 Mouse (opt-in)
{ '<2-LeftMouse>', 'n' }, { '<S-2-LeftMouse>', 'n' }, { '<C-2-LeftMouse>', 'n' },
{ '<MiddleMouse>', 'n' }, { '<RightMouse>', 'n' },
-- §7 Text objects (operator-pending + visual)
{ 'ah', 'ox' }, { 'ih', 'ox' }, { 'aH', 'ox' }, { 'iH', 'ox' },
{ 'al', 'ox' }, { 'il', 'ox' }, { 'a\\', 'ox' }, { 'i\\', 'ox' },
{ 'ac', 'ox' }, { 'ic', 'ox' },
-- §8 Insert-mode
{ '<CR>', 'i' }, { '<Tab>', 'i' }, { '<S-Tab>', 'i' },
{ '<C-D>', 'i' }, { '<C-T>', 'i' },
{ '<C-L><C-J>', 'i' }, { '<C-L><C-K>', 'i' }, { '<C-L><C-M>', 'i' },
}
for _, entry in ipairs(mapping_surface) do
local lhs, modes = entry[1], entry[2]
for m in modes:gmatch('.') do
local ok = has_map(lhs, m)
record(ok, 'map[' .. m .. '].' .. lhs,
ok and '' or (lhs .. ' (' .. m .. ') not mapped buffer-local'))
end
end
-- ===== Lists =====
run('list.toggle_via_C-Space', {
@@ -167,6 +266,84 @@ vim.defer_fn(function()
expect_line = '- [ ] done',
expect_at = 1,
})
-- `glp` must cycle BACKWARD (regression for the glp==gln bug). From
-- `[.]` the backward step lands on `[ ]`, whereas `gln` would advance
-- to `[o]`.
run('list.cycle_back_via_glp', {
lines = { '- [.] task' },
cursor = { 1, 0 },
keys = 'glp',
expect_line = '- [ ] task',
expect_at = 1,
})
run('list.cycle_back_via_glp_wraps', {
-- `[ ]` backward wraps to the top of the progression (`[X]`).
lines = { '- [ ] task' },
cursor = { 1, 0 },
keys = 'glp',
expect_line = '- [X] task',
expect_at = 1,
})
-- ===== Change level (glh/gll) =====
run('list.indent_via_gll', {
lines = { '- item' },
cursor = { 1, 0 },
keys = 'gll',
expect_line = ' - item',
expect_at = 1,
})
run('list.dedent_via_glh', {
lines = { ' - item' },
cursor = { 1, 2 },
keys = 'glh',
expect_line = '- item',
expect_at = 1,
})
-- ===== Renumber (glr / gLr) =====
run('list.renumber_via_glr', {
lines = { '5. one', '9. two', '2. three' },
cursor = { 1, 0 },
keys = 'glr',
expect_lines = { '1. one', '2. two', '3. three' },
})
-- ===== Remove done (gl<Space> current list / gL<Space> whole doc) =====
-- `gl<Space>` removes done items from the cursor's list only; the
-- second list's done item survives.
run('list.remove_done_current_list_via_gl_space', {
lines = {
'- [X] done a', '- [ ] todo a', '',
'paragraph', '',
'- [ ] todo b', '- [X] done b',
},
cursor = { 1, 0 },
keys = 'gl<Space>',
expect_lines = {
'- [ ] todo a', '',
'paragraph', '',
'- [ ] todo b', '- [X] done b',
},
})
-- `gL<Space>` sweeps the whole buffer regardless of cursor position.
run('list.remove_done_whole_buffer_via_gL_space', {
lines = {
'- [X] done a', '- [ ] todo a', '',
'paragraph', '',
'- [ ] todo b', '- [X] done b',
},
cursor = { 1, 0 },
keys = 'gL<Space>',
expect_lines = {
'- [ ] todo a', '',
'paragraph', '',
'- [ ] todo b',
},
})
-- ===== Next task (gnt) =====
+3 -1
View File
@@ -38,7 +38,9 @@ cat > "$INIT" <<EOF
-- Keep the harness self-contained; no plugin manager required.
vim.opt.runtimepath:prepend('$REPO_ROOT')
vim.g.nuwiki_binary_path = '$BIN'
require('nuwiki').setup({ wiki_root = '$TMP/wiki' })
-- Enable the opt-in mouse mappings so the harness can assert the full
-- documented mapping surface (doc §6 mouse group).
require('nuwiki').setup({ wiki_root = '$TMP/wiki', mappings = { mouse = true } })
EOF
RESULTS="$TMP/results.txt"