Major clean up and improvements to vimwiki compatibility and documentation.
Reviewed-on: #1
This commit was merged in pull request #1.
This commit is contained in:
Executable
+65
@@ -0,0 +1,65 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# development/tests/test-keymaps-vim.sh — Vim counterpart of test-keymaps.sh.
|
||||
#
|
||||
# Pure-VimL portion only. The LSP-roundtrip keymaps (`<C-Space>`,
|
||||
# `=`, `-`, …) talk to the server via vim-lsp's timer-driven async
|
||||
# layer, which doesn't fire reliably inside `vim -e -s`. Those have
|
||||
# Neovim coverage in development/tests/test-keymaps.lua, exercising the same
|
||||
# Lua command layer + server binary.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TMP="$(mktemp -d -t nuwiki-keymap-vim-test-XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
log() { printf '\033[1;34m[keymap-vim]\033[0m %s\n' "$*"; }
|
||||
|
||||
if ! command -v vim >/dev/null 2>&1; then
|
||||
log 'vim not installed — skipping'
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Don't need the LSP binary for the pure-VimL cases, but make sure
|
||||
# the autoload file `expand('<sfile>')` resolves correctly when the
|
||||
# script-load-time plugin_root capture runs.
|
||||
|
||||
mkdir -p "$TMP/wiki"
|
||||
SEED="$TMP/wiki/index.wiki"
|
||||
echo "= seed =" > "$SEED"
|
||||
|
||||
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\`.
|
||||
EOF
|
||||
|
||||
RESULTS="$TMP/results.txt"
|
||||
|
||||
log "running harness…"
|
||||
# `</dev/null` so Vim's ex-mode doesn't hang waiting on stdin;
|
||||
# `timeout` so a misbehaving script can't stall CI for 30 minutes.
|
||||
NUWIKI_KEYMAP_RESULTS="$RESULTS" \
|
||||
timeout 30 vim -e -s -u "$VIMRC" -c "edit $SEED" -c "source $REPO_ROOT/development/tests/test-keymaps-vim.vim" \
|
||||
</dev/null >"$TMP/vim.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$RESULTS" ]]; then
|
||||
echo 'keymap test harness produced no output' >&2
|
||||
echo '--- vim log ---' >&2
|
||||
cat "$TMP/vim.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "$RESULTS"
|
||||
|
||||
if grep -qE '^SUMMARY: [0-9]+ passed, 0 failed' "$RESULTS"; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
@@ -0,0 +1,460 @@
|
||||
" development/tests/test-keymaps-vim.vim — driven by development/tests/test-keymaps-vim.sh.
|
||||
"
|
||||
" Pure-VimL keymap regression suite. Plain Vim's LSP path
|
||||
" (vim-lsp + async.vim) talks to the server via timers, which won't
|
||||
" reliably fire inside `vim -e -s` headless mode — so the cases here
|
||||
" are restricted to the bindings whose RHS is implemented entirely in
|
||||
" VimL (header/link nav, `o`/`O` bullet continuation, the wrap step
|
||||
" of `<CR>`). LSP-roundtrip bindings (`<C-Space>`, `=`, `-`, …) get
|
||||
" their coverage from the Neovim harness in development/tests/test-keymaps.lua
|
||||
" — same Lua codepath, same server.
|
||||
|
||||
let s:results = []
|
||||
let s:pass = 0
|
||||
let s:fail = 0
|
||||
let s:out = $NUWIKI_KEYMAP_RESULTS
|
||||
|
||||
function! s:record(ok, name, detail) abort
|
||||
let l:line = (a:ok ? 'PASS' : 'FAIL') . ' ' . a:name
|
||||
if a:detail !=# ''
|
||||
let l:line .= ' — ' . a:detail
|
||||
endif
|
||||
call add(s:results, l:line)
|
||||
if a:ok
|
||||
let s:pass += 1
|
||||
else
|
||||
let s:fail += 1
|
||||
endif
|
||||
endfunction
|
||||
|
||||
function! s:set_buf(lines) abort
|
||||
silent %delete _
|
||||
call setline(1, a:lines)
|
||||
endfunction
|
||||
|
||||
function! s:lines() abort
|
||||
return getline(1, '$')
|
||||
endfunction
|
||||
|
||||
function! s:run(name, opts) abort
|
||||
try
|
||||
call s:set_buf(a:opts.lines)
|
||||
let l:cursor = get(a:opts, 'cursor', [1, 1])
|
||||
call cursor(l:cursor[0], l:cursor[1])
|
||||
" feedkeys with 'tx' — t: typed (respects maps), x: execute now.
|
||||
let l:keys = a:opts.keys
|
||||
call feedkeys(l:keys, 'tx')
|
||||
let l:detail = ''
|
||||
if has_key(a:opts, 'expect_lines')
|
||||
if s:lines() !=# a:opts.expect_lines
|
||||
let l:detail = 'lines mismatch — expected ' . string(a:opts.expect_lines)
|
||||
\ . ', actual ' . string(s:lines())
|
||||
call s:record(0, a:name, l:detail)
|
||||
return
|
||||
endif
|
||||
endif
|
||||
if has_key(a:opts, 'expect_line') && has_key(a:opts, 'expect_at')
|
||||
let l:got = getline(a:opts.expect_at)
|
||||
if l:got !=# a:opts.expect_line
|
||||
let l:detail = 'line ' . a:opts.expect_at . ' — expected ' . string(a:opts.expect_line)
|
||||
\ . ', got ' . string(l:got)
|
||||
call s:record(0, a:name, l:detail)
|
||||
return
|
||||
endif
|
||||
endif
|
||||
if has_key(a:opts, 'expect_cursor_line')
|
||||
let l:lnum = line('.')
|
||||
if l:lnum != a:opts.expect_cursor_line
|
||||
let l:detail = 'cursor line — expected ' . a:opts.expect_cursor_line
|
||||
\ . ', got ' . l:lnum
|
||||
call s:record(0, a:name, l:detail)
|
||||
return
|
||||
endif
|
||||
endif
|
||||
call s:record(1, a:name, '')
|
||||
catch
|
||||
call s:record(0, a:name, v:exception)
|
||||
endtry
|
||||
endfunction
|
||||
|
||||
" ===== Smoke: ftplugin loaded + commands defined =====
|
||||
|
||||
if &filetype !=# 'vimwiki'
|
||||
call s:record(0, 'ftplugin.attached', 'filetype is ' . &filetype . ' (expected vimwiki)')
|
||||
else
|
||||
call s:record(1, 'ftplugin.attached', '')
|
||||
endif
|
||||
if exists(':VimwikiTOC') == 2
|
||||
call s:record(1, 'cmd.VimwikiTOC_defined', '')
|
||||
else
|
||||
call s:record(0, 'cmd.VimwikiTOC_defined', ':VimwikiTOC not registered')
|
||||
endif
|
||||
if exists(':NuwikiIndex') == 2
|
||||
call s:record(1, 'cmd.NuwikiIndex_defined', '')
|
||||
else
|
||||
call s:record(0, 'cmd.NuwikiIndex_defined', ':NuwikiIndex not registered')
|
||||
endif
|
||||
|
||||
" ===== Documented command surface =====
|
||||
" Every command must be reachable in BOTH the canonical :Nuwiki* form and
|
||||
" the vimwiki-compatible :Vimwiki* form. The suffixes below are the command
|
||||
" 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 =====
|
||||
" 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', {
|
||||
\ 'lines': ['= One =', 'body', '== Two ==', 'body', '= Three ='],
|
||||
\ 'cursor': [1, 1],
|
||||
\ 'keys': ']]',
|
||||
\ 'expect_cursor_line': 3,
|
||||
\ })
|
||||
call s:run('headers.[[_jumps_to_prev', {
|
||||
\ 'lines': ['= One =', 'body', '== Two ==', 'body', '= Three ='],
|
||||
\ 'cursor': [5, 1],
|
||||
\ 'keys': '[[',
|
||||
\ 'expect_cursor_line': 3,
|
||||
\ })
|
||||
call s:run('headers.]u_jumps_to_parent', {
|
||||
\ 'lines': ['= Parent =', '== Child ==', 'body'],
|
||||
\ 'cursor': [3, 1],
|
||||
\ 'keys': ']u',
|
||||
\ 'expect_cursor_line': 1,
|
||||
\ })
|
||||
call s:run('headers.]=_jumps_to_next_sibling', {
|
||||
\ 'lines': ['= A =', '== child ==', 'body', '= B ='],
|
||||
\ 'cursor': [1, 1],
|
||||
\ '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) =====
|
||||
|
||||
call s:run('links.<Tab>_finds_next_wikilink', {
|
||||
\ 'lines': ['intro [[A]] more [[B]] end'],
|
||||
\ 'cursor': [1, 1],
|
||||
\ 'keys': "\<Tab>",
|
||||
\ 'expect_cursor_line': 1,
|
||||
\ })
|
||||
|
||||
" ===== <CR> first press wraps bare word =====
|
||||
|
||||
call s:run('cr.wraps_bare_word_first_press', {
|
||||
\ 'lines': ['MyPage rest'],
|
||||
\ 'cursor': [1, 3],
|
||||
\ 'keys': "\<CR>",
|
||||
\ 'expect_line': '[[MyPage]] rest',
|
||||
\ 'expect_at': 1,
|
||||
\ })
|
||||
|
||||
" ===== Bullet continuation =====
|
||||
|
||||
call s:run('lists.o_continues_list_marker', {
|
||||
\ 'lines': ['- first'],
|
||||
\ 'cursor': [1, 1],
|
||||
\ 'keys': "o\<Esc>",
|
||||
\ 'expect_lines': ['- first', '- '],
|
||||
\ })
|
||||
call s:run('lists.o_continues_checkbox', {
|
||||
\ 'lines': ['- [ ] first'],
|
||||
\ 'cursor': [1, 1],
|
||||
\ 'keys': "o\<Esc>",
|
||||
\ 'expect_lines': ['- [ ] first', '- [ ] '],
|
||||
\ })
|
||||
call s:run('lists.O_continues_above', {
|
||||
\ 'lines': ['- second'],
|
||||
\ 'cursor': [1, 1],
|
||||
\ 'keys': "O\<Esc>",
|
||||
\ 'expect_lines': ['- ', '- second'],
|
||||
\ })
|
||||
|
||||
" ===== Insert-mode smart_return (Cluster 5) =====
|
||||
|
||||
call s:run('cr.continues_list_marker', {
|
||||
\ 'lines': ['- first'],
|
||||
\ 'cursor': [1, 7],
|
||||
\ 'keys': "A\<CR>second\<Esc>",
|
||||
\ 'expect_lines': ['- first', '- second'],
|
||||
\ })
|
||||
call s:run('cr.continues_checkbox', {
|
||||
\ 'lines': ['- [ ] first'],
|
||||
\ 'cursor': [1, 11],
|
||||
\ 'keys': "A\<CR>second\<Esc>",
|
||||
\ 'expect_lines': ['- [ ] first', '- [ ] second'],
|
||||
\ })
|
||||
call s:run('cr.breaks_on_empty_list_line', {
|
||||
\ 'lines': ['- '],
|
||||
\ 'cursor': [1, 2],
|
||||
\ 'keys': "A\<CR>plain\<Esc>",
|
||||
\ 'expect_lines': ['', 'plain'],
|
||||
\ })
|
||||
call s:run('cr.adds_new_table_row', {
|
||||
\ 'lines': ['| a | b |'],
|
||||
\ 'cursor': [1, 4],
|
||||
\ 'keys': "A\<CR>x\<Esc>",
|
||||
\ 'expect_lines': ['| a | b |', '|x | |'],
|
||||
\ })
|
||||
|
||||
" ===== Insert-mode smart_tab / smart_shift_tab (Cluster 6) =====
|
||||
|
||||
call s:run('tab.next_cell_in_table', {
|
||||
\ 'lines': ['| a | b |'],
|
||||
\ 'cursor': [1, 3],
|
||||
\ 'keys': "A\<Tab>x\<Esc>",
|
||||
\ 'expect_lines': ['| a | b |', '|x | |'],
|
||||
\ })
|
||||
call s:run('tab.cycles_to_next_row_when_not_last', {
|
||||
\ 'lines': ['| a | b |', '|---|---|', '| c | d |'],
|
||||
\ 'cursor': [1, 7],
|
||||
\ 'keys': "i\<Tab>x\<Esc>",
|
||||
\ 'expect_lines': ['| a | b |', '|---|---|', '|x c | d |'],
|
||||
\ })
|
||||
call s:run('tab.inserts_row_only_when_on_last_row', {
|
||||
\ 'lines': ['| a | b |', '|---|---|', '| c | d |'],
|
||||
\ 'cursor': [3, 7],
|
||||
\ 'keys': "i\<Tab>x\<Esc>",
|
||||
\ 'expect_lines': ['| a | b |', '|---|---|', '| c | d |', '|x | |'],
|
||||
\ })
|
||||
call s:run('tab.from_first_cell_moves_to_second', {
|
||||
\ 'lines': ['| a | b |'],
|
||||
\ 'cursor': [1, 3],
|
||||
\ 'keys': "i\<Tab>x\<Esc>",
|
||||
\ 'expect_lines': ['| a |x b |'],
|
||||
\ })
|
||||
call s:run('shift_tab.prev_cell_in_table', {
|
||||
\ 'lines': ['| a | b |'],
|
||||
\ 'cursor': [1, 7],
|
||||
\ 'keys': "i\<S-Tab>x\<Esc>",
|
||||
\ 'expect_lines': ['|x a | b |'],
|
||||
\ })
|
||||
|
||||
" ===== Named link commands (Cluster 1) =====
|
||||
|
||||
if exists(':VimwikiNextLink') == 2
|
||||
call s:record(1, 'cmd.VimwikiNextLink_defined', '')
|
||||
else
|
||||
call s:record(0, 'cmd.VimwikiNextLink_defined', ':VimwikiNextLink not registered')
|
||||
endif
|
||||
if exists(':VimwikiPrevLink') == 2
|
||||
call s:record(1, 'cmd.VimwikiPrevLink_defined', '')
|
||||
else
|
||||
call s:record(0, 'cmd.VimwikiPrevLink_defined', ':VimwikiPrevLink not registered')
|
||||
endif
|
||||
if exists(':VimwikiBaddLink') == 2
|
||||
call s:record(1, 'cmd.VimwikiBaddLink_defined', '')
|
||||
else
|
||||
call s:record(0, 'cmd.VimwikiBaddLink_defined', ':VimwikiBaddLink not registered')
|
||||
endif
|
||||
|
||||
" ===== Text objects (Cluster 3) =====
|
||||
" Exercise the pure helpers directly — visual-mode mark inspection in
|
||||
" `vim -e -s` is unreliable for the same reasons the Lua harness uses
|
||||
" the helper-level tests.
|
||||
|
||||
call s:set_buf(['= h1 =', 'a', '== h2 ==', 'b', '== h3 ==', 'c'])
|
||||
call s:record(
|
||||
\ nuwiki#textobjects#_heading_block(1, 0) ==# [1, 2]
|
||||
\ ? 1 : 0,
|
||||
\ 'textobj.heading_block_section_only',
|
||||
\ 'got ' . string(nuwiki#textobjects#_heading_block(1, 0)))
|
||||
call s:record(
|
||||
\ nuwiki#textobjects#_heading_block(1, 1) ==# [1, 6]
|
||||
\ ? 1 : 0,
|
||||
\ 'textobj.heading_block_subtree',
|
||||
\ 'got ' . string(nuwiki#textobjects#_heading_block(1, 1)))
|
||||
|
||||
let s:cells = nuwiki#textobjects#_cell_ranges('| a | b | c |')
|
||||
call s:record(
|
||||
\ len(s:cells) == 3 ? 1 : 0,
|
||||
\ 'textobj.cell_ranges_three_cells',
|
||||
\ len(s:cells) == 3 ? '' : 'got ' . string(s:cells))
|
||||
|
||||
let s:cell_idx = nuwiki#textobjects#_current_cell_for_line('| a | b | c |', 8)
|
||||
call s:record(
|
||||
\ s:cell_idx == 2 ? 1 : 0,
|
||||
\ 'textobj.current_cell_picks_middle',
|
||||
\ s:cell_idx == 2 ? '' : 'got idx=' . s:cell_idx)
|
||||
|
||||
call setline(1, ['before', '| a | b |', '| c | d |', '', 'after'])
|
||||
let s:bnd = nuwiki#textobjects#_table_bounds(2)
|
||||
call s:record(
|
||||
\ s:bnd ==# [2, 3] ? 1 : 0,
|
||||
\ 'textobj.table_bounds_walks_contig_block',
|
||||
\ s:bnd ==# [2, 3] ? '' : 'got ' . string(s:bnd))
|
||||
|
||||
" Operator-pending: `dah` should delete the heading section.
|
||||
call s:set_buf(['= h1 =', 'body', '= h2 =', 'rest'])
|
||||
call cursor(1, 1)
|
||||
call feedkeys('dah', 'tx')
|
||||
let s:dah = getline(1, '$')
|
||||
call s:record(
|
||||
\ s:dah ==# ['= h2 =', 'rest'] || s:dah ==# ['', '= h2 =', 'rest']
|
||||
\ ? 1 : 0,
|
||||
\ 'textobj.dah_deletes_heading_section',
|
||||
\ 'got ' . string(s:dah))
|
||||
|
||||
" ===== Folding =====
|
||||
|
||||
call setline(1, ['= h1 =', 'a', '== h2 ==', 'b', 'c'])
|
||||
let &l:foldmethod = 'expr'
|
||||
let &l:foldexpr = 'nuwiki#folding#expr()'
|
||||
call s:record(
|
||||
\ foldlevel(1) > 0 ? 1 : 0,
|
||||
\ 'folding.heading_starts_fold',
|
||||
\ 'foldlevel(1)=' . foldlevel(1))
|
||||
call s:record(
|
||||
\ foldlevel(2) > 0 ? 1 : 0,
|
||||
\ 'folding.body_inherits_fold',
|
||||
\ 'foldlevel(2)=' . foldlevel(2))
|
||||
" ftplugin sets `foldlevel=99` so every heading-block fold starts open
|
||||
" rather than collapsed on file open.
|
||||
call s:record(
|
||||
\ &l:foldlevel == 99 ? 1 : 0,
|
||||
\ 'folding.starts_with_all_folds_open',
|
||||
\ '&l:foldlevel=' . &l:foldlevel)
|
||||
|
||||
" ===== Conceal =====
|
||||
|
||||
" The ftplugin reveals the cursor line in normal mode (concealcursor has no
|
||||
" `n`) so left/right motions move over visible characters instead of stalling
|
||||
" on hidden conceal regions like a wikilink's `[[`/`]]`.
|
||||
call s:record(
|
||||
\ &l:conceallevel == 2 ? 1 : 0,
|
||||
\ 'conceal.level_is_two',
|
||||
\ '&l:conceallevel=' . &l:conceallevel)
|
||||
call s:record(
|
||||
\ &l:concealcursor !~# 'n' ? 1 : 0,
|
||||
\ 'conceal.cursor_line_revealed_in_normal_mode',
|
||||
\ '&l:concealcursor=' . &l:concealcursor)
|
||||
|
||||
" ===== Wiki picker (config-driven, no LSP) =====
|
||||
|
||||
" The picker builds its list straight from config so it works from any buffer
|
||||
" before the LSP (which only starts on a wiki buffer) is running.
|
||||
let g:nuwiki_wikis = [
|
||||
\ {'name': 'Personal', 'root': '/tmp/nuwiki-a'},
|
||||
\ {'name': 'Work', 'root': '/tmp/nuwiki-b', 'file_extension': 'md'},
|
||||
\ ]
|
||||
let s:wl = nuwiki#commands#wiki_list()
|
||||
call s:record(
|
||||
\ len(s:wl) == 2 ? 1 : 0,
|
||||
\ 'wiki_select.lists_all_configured_wikis',
|
||||
\ 'count=' . len(s:wl))
|
||||
call s:record(
|
||||
\ get(get(s:wl, 0, {}), 'name', '') ==# 'Personal'
|
||||
\ && get(get(s:wl, 1, {}), 'name', '') ==# 'Work' ? 1 : 0,
|
||||
\ 'wiki_select.preserves_names_and_order',
|
||||
\ 'names=' . string(map(copy(s:wl), 'get(v:val, "name", "")')))
|
||||
call s:record(
|
||||
\ get(get(s:wl, 1, {}), 'ext', '') ==# '.md' ? 1 : 0,
|
||||
\ 'wiki_select.honours_per_wiki_extension',
|
||||
\ 'ext=' . get(get(s:wl, 1, {}), 'ext', ''))
|
||||
unlet g:nuwiki_wikis
|
||||
|
||||
" ===== Wrap up =====
|
||||
|
||||
call add(s:results, '')
|
||||
call add(s:results, printf('SUMMARY: %d passed, %d failed', s:pass, s:fail))
|
||||
call writefile(s:results, s:out)
|
||||
execute (s:fail == 0 ? 'qall!' : 'cquit!')
|
||||
@@ -0,0 +1,706 @@
|
||||
-- development/tests/test-keymaps.lua — driven by development/tests/test-keymaps.sh.
|
||||
--
|
||||
-- Headless Neovim harness for the buffer-local keymaps registered by
|
||||
-- `ftplugin/vimwiki.vim` + `lua/nuwiki/keymaps.lua`. Each case sets up
|
||||
-- the buffer, places the cursor, fires the key sequence via
|
||||
-- `nvim_feedkeys`, waits for any LSP round-trip, and asserts the
|
||||
-- expected post-state.
|
||||
--
|
||||
-- Pass/fail summary is written to the path passed as argv[1] so the
|
||||
-- shell wrapper can read + render it without trying to capture nvim's
|
||||
-- TUI output.
|
||||
|
||||
local OUT = vim.env.NUWIKI_KEYMAP_RESULTS or '/tmp/nuwiki-keymap-results.txt'
|
||||
local out_lines = {}
|
||||
local pass = 0
|
||||
local fail = 0
|
||||
|
||||
local function record(ok, name, detail)
|
||||
local suffix = (detail and detail ~= '') and (' — ' .. detail) or ''
|
||||
table.insert(out_lines, (ok and 'PASS' or 'FAIL') .. ' ' .. name .. suffix)
|
||||
if ok then pass = pass + 1 else fail = fail + 1 end
|
||||
end
|
||||
|
||||
local function set_buf(lines)
|
||||
vim.api.nvim_buf_set_lines(0, 0, -1, false, lines)
|
||||
end
|
||||
|
||||
local function get_lines()
|
||||
return vim.api.nvim_buf_get_lines(0, 0, -1, false)
|
||||
end
|
||||
|
||||
local function get_line(n)
|
||||
return vim.api.nvim_buf_get_lines(0, n - 1, n, false)[1] or ''
|
||||
end
|
||||
|
||||
local function send(keys)
|
||||
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), 'x', false)
|
||||
end
|
||||
|
||||
local function sleep(ms)
|
||||
vim.cmd('sleep ' .. ms .. 'm')
|
||||
end
|
||||
|
||||
--- Run one case: lines + cursor (1-based) + key sequence + expected
|
||||
--- post-state. `wait_ms` defaults to 800 (enough for an LSP
|
||||
--- executeCommand round-trip on a hot server).
|
||||
local function run(name, opts)
|
||||
local ok, err = pcall(function()
|
||||
set_buf(opts.lines)
|
||||
vim.api.nvim_win_set_cursor(0, opts.cursor or { 1, 0 })
|
||||
send(opts.keys)
|
||||
sleep(opts.wait_ms or 800)
|
||||
local actual_lines = get_lines()
|
||||
if opts.expect_lines then
|
||||
if not vim.deep_equal(actual_lines, opts.expect_lines) then
|
||||
error(string.format('lines mismatch\n expected: %s\n actual: %s',
|
||||
vim.inspect(opts.expect_lines), vim.inspect(actual_lines)))
|
||||
end
|
||||
end
|
||||
if opts.expect_line and opts.expect_at then
|
||||
local got = get_line(opts.expect_at)
|
||||
if got ~= opts.expect_line then
|
||||
error(string.format('line %d mismatch\n expected: %q\n actual: %q',
|
||||
opts.expect_at, opts.expect_line, got))
|
||||
end
|
||||
end
|
||||
if opts.expect_cursor_line then
|
||||
local lnum = vim.api.nvim_win_get_cursor(0)[1]
|
||||
if lnum ~= opts.expect_cursor_line then
|
||||
error(string.format('cursor line mismatch — expected %d, got %d',
|
||||
opts.expect_cursor_line, lnum))
|
||||
end
|
||||
end
|
||||
if opts.expect_buf_path then
|
||||
local got = vim.api.nvim_buf_get_name(0)
|
||||
if not got:find(opts.expect_buf_path, 1, true) then
|
||||
error(string.format('buffer path mismatch\n expected substring: %q\n actual: %q',
|
||||
opts.expect_buf_path, got))
|
||||
end
|
||||
end
|
||||
end)
|
||||
record(ok, name, ok and '' or tostring(err))
|
||||
end
|
||||
|
||||
local function get_lsp_clients(opts)
|
||||
local fn = vim.lsp.get_clients or vim.lsp.get_active_clients
|
||||
if not fn then return {} end
|
||||
return fn(opts)
|
||||
end
|
||||
|
||||
local function wait_for_lsp(timeout_ms)
|
||||
local deadline = vim.loop.now() + (timeout_ms or 5000)
|
||||
while vim.loop.now() < deadline do
|
||||
local clients = get_lsp_clients({ name = 'nuwiki', bufnr = 0 })
|
||||
if #clients > 0 and clients[1].server_capabilities
|
||||
and clients[1].server_capabilities.executeCommandProvider then
|
||||
return clients[1]
|
||||
end
|
||||
sleep(100)
|
||||
end
|
||||
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()
|
||||
local client = wait_for_lsp()
|
||||
if not client then
|
||||
record(false, 'lsp.attached', 'no nuwiki client after 5s')
|
||||
vim.fn.writefile(out_lines, OUT)
|
||||
vim.cmd('qall!')
|
||||
return
|
||||
end
|
||||
record(true, 'lsp.attached', 'client=' .. client.name)
|
||||
|
||||
-- ===== Documented command surface =====
|
||||
-- Every command must be reachable in BOTH the canonical :Nuwiki* form
|
||||
-- and the vimwiki-compatible :Vimwiki* form. The suffixes below are the
|
||||
-- command 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 =====
|
||||
-- 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 = {
|
||||
-- Links
|
||||
{ '<CR>', 'n' }, { '<S-CR>', 'n' }, { '<C-CR>', 'n' }, { '<C-S-CR>', 'n' },
|
||||
{ '<BS>', 'n' }, { '<Tab>', 'n' }, { '<S-Tab>', 'n' }, { '+', 'nx' },
|
||||
-- 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' },
|
||||
-- Headers
|
||||
{ '=', 'n' }, { '-', 'n' }, { ']]', 'n' }, { '[[', 'n' },
|
||||
{ ']=', 'n' }, { '[=', 'n' }, { ']u', 'n' }, { '[u', 'n' },
|
||||
-- Tables
|
||||
{ 'gqq', 'n' }, { 'gq1', 'n' }, { 'gww', 'n' }, { 'gw1', 'n' },
|
||||
{ '<A-Left>', 'n' }, { '<A-Right>', 'n' },
|
||||
-- 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' },
|
||||
-- Mouse (opt-in)
|
||||
{ '<2-LeftMouse>', 'n' }, { '<S-2-LeftMouse>', 'n' }, { '<C-2-LeftMouse>', 'n' },
|
||||
{ '<MiddleMouse>', 'n' }, { '<RightMouse>', 'n' },
|
||||
-- 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' },
|
||||
-- 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', {
|
||||
lines = { '= test =', '- [ ] task' },
|
||||
cursor = { 2, 0 },
|
||||
keys = '<C-Space>',
|
||||
expect_line = '- [X] task',
|
||||
expect_at = 2,
|
||||
})
|
||||
run('list.toggle_via_C-@', {
|
||||
lines = { '= test =', '- [ ] task' },
|
||||
cursor = { 2, 0 },
|
||||
keys = '<C-@>',
|
||||
expect_line = '- [X] task',
|
||||
expect_at = 2,
|
||||
})
|
||||
run('list.toggle_via_Nul', {
|
||||
lines = { '= test =', '- [ ] task' },
|
||||
cursor = { 2, 0 },
|
||||
keys = '<Nul>',
|
||||
expect_line = '- [X] task',
|
||||
expect_at = 2,
|
||||
})
|
||||
run('list.cycle_via_gln', {
|
||||
lines = { '- [ ] task' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'gln',
|
||||
expect_line = '- [.] task',
|
||||
expect_at = 1,
|
||||
})
|
||||
run('list.reject_via_glx', {
|
||||
lines = { '- [ ] task' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'glx',
|
||||
expect_line = '- [-] task',
|
||||
expect_at = 1,
|
||||
})
|
||||
run('list.toggle_round_trip', {
|
||||
lines = { '- [X] done' },
|
||||
cursor = { 1, 0 },
|
||||
keys = '<C-Space>',
|
||||
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) =====
|
||||
|
||||
run('list.gnt_jumps_to_unfinished', {
|
||||
lines = { '- [X] done', '- [ ] todo', '- [ ] later' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'gnt',
|
||||
expect_cursor_line = 2,
|
||||
wait_ms = 600,
|
||||
})
|
||||
|
||||
-- ===== Heading levels =====
|
||||
|
||||
run('heading.add_level_with_=', {
|
||||
lines = { '= H1 =' },
|
||||
cursor = { 1, 0 },
|
||||
keys = '=',
|
||||
expect_line = '== H1 ==',
|
||||
expect_at = 1,
|
||||
})
|
||||
run('heading.remove_level_with_-', {
|
||||
lines = { '== H2 ==' },
|
||||
cursor = { 1, 0 },
|
||||
keys = '-',
|
||||
expect_line = '= H2 =',
|
||||
expect_at = 1,
|
||||
})
|
||||
run('heading.add_level_clamps_at_6', {
|
||||
lines = { '====== H6 ======' },
|
||||
cursor = { 1, 0 },
|
||||
keys = '=',
|
||||
expect_line = '====== H6 ======', -- unchanged
|
||||
expect_at = 1,
|
||||
})
|
||||
|
||||
-- ===== Header nav (pure Lua, no LSP) =====
|
||||
|
||||
run('headers.]]_jumps_to_next', {
|
||||
lines = { '= One =', 'body', '== Two ==', 'body', '= Three =' },
|
||||
cursor = { 1, 0 },
|
||||
keys = ']]',
|
||||
expect_cursor_line = 3,
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('headers.[[_jumps_to_prev', {
|
||||
lines = { '= One =', 'body', '== Two ==', 'body', '= Three =' },
|
||||
cursor = { 5, 0 },
|
||||
keys = '[[',
|
||||
expect_cursor_line = 3,
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('headers.]u_jumps_to_parent', {
|
||||
lines = { '= Parent =', '== Child ==', 'body' },
|
||||
cursor = { 3, 0 },
|
||||
keys = ']u',
|
||||
expect_cursor_line = 1,
|
||||
wait_ms = 100,
|
||||
})
|
||||
|
||||
-- ===== Link nav (pure Lua) =====
|
||||
|
||||
run('links.<Tab>_jumps_to_next_wikilink', {
|
||||
lines = { 'intro [[A]] more [[B]] end' },
|
||||
cursor = { 1, 0 },
|
||||
keys = '<Tab>',
|
||||
expect_cursor_line = 1,
|
||||
wait_ms = 100,
|
||||
})
|
||||
|
||||
-- ===== <CR> two-step =====
|
||||
|
||||
run('cr.wraps_bare_word_first_press', {
|
||||
lines = { 'MyPage rest' },
|
||||
cursor = { 1, 3 },
|
||||
keys = '<CR>',
|
||||
expect_line = '[[MyPage]] rest',
|
||||
expect_at = 1,
|
||||
wait_ms = 200,
|
||||
})
|
||||
|
||||
-- ===== Link helpers (Cluster C) =====
|
||||
|
||||
run('links.normalize_via_+', {
|
||||
lines = { 'MyPage rest' },
|
||||
cursor = { 1, 3 },
|
||||
keys = '+',
|
||||
expect_line = '[[MyPage]] rest',
|
||||
expect_at = 1,
|
||||
wait_ms = 200,
|
||||
})
|
||||
|
||||
-- ===== Bullet continuation (o/O) =====
|
||||
|
||||
run('lists.o_continues_list_marker', {
|
||||
lines = { '- first' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'o<Esc>',
|
||||
expect_lines = { '- first', '- ' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('lists.o_continues_checkbox', {
|
||||
lines = { '- [ ] first' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'o<Esc>',
|
||||
expect_lines = { '- [ ] first', '- [ ] ' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('lists.O_continues_above', {
|
||||
lines = { '- second' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'O<Esc>',
|
||||
expect_lines = { '- ', '- second' },
|
||||
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,
|
||||
})
|
||||
|
||||
-- ===== Text objects (Cluster 3) =====
|
||||
-- Exercise the pure helpers directly (cell ranges, heading block,
|
||||
-- table bounds). The visual-mode integration is verified in a single
|
||||
-- end-to-end case via mark inspection, which exercises the full
|
||||
-- operator-pending path.
|
||||
|
||||
local function tobj_case(name, fn)
|
||||
local ok, err = pcall(fn)
|
||||
record(ok, name, ok and '' or tostring(err))
|
||||
end
|
||||
|
||||
tobj_case('textobj.heading_block_section_only', function()
|
||||
set_buf({ '= h1 =', 'a', '== h2 ==', 'b', '== h3 ==', 'c' })
|
||||
local s, e = require('nuwiki.textobjects')._heading_block(1, false)
|
||||
if not (s == 1 and e == 2) then
|
||||
error(string.format('section_only: got (%s,%s) want (1,2)', s, e))
|
||||
end
|
||||
end)
|
||||
tobj_case('textobj.heading_block_subtree', function()
|
||||
set_buf({ '= h1 =', 'a', '== h2 ==', 'b', '== h3 ==', 'c' })
|
||||
local s, e = require('nuwiki.textobjects')._heading_block(1, true)
|
||||
if not (s == 1 and e == 6) then
|
||||
error(string.format('subtree: got (%s,%s) want (1,6)', s, e))
|
||||
end
|
||||
end)
|
||||
tobj_case('textobj.cell_ranges_three_cells', function()
|
||||
local r = require('nuwiki.textobjects')._cell_ranges('| a | b | c |')
|
||||
if #r ~= 3 then error('want 3 cells, got ' .. #r) end
|
||||
-- Inside each cell, content includes the surrounding spaces.
|
||||
if not (r[1][1] == 2 and r[2][1] > r[1][2] and r[3][1] > r[2][2]) then
|
||||
error('cell ordering broken: ' .. vim.inspect(r))
|
||||
end
|
||||
end)
|
||||
tobj_case('textobj.current_cell_picks_middle', function()
|
||||
-- `| a | b | c |` cursor on the `b` (1-based col 8).
|
||||
local idx = require('nuwiki.textobjects')._current_cell_for_line(
|
||||
'| a | b | c |', 8)
|
||||
if idx ~= 2 then error('expected cell 2, got ' .. tostring(idx)) end
|
||||
end)
|
||||
tobj_case('textobj.table_bounds_walks_contig_block', function()
|
||||
set_buf({ 'before', '| a | b |', '| c | d |', '', 'after' })
|
||||
vim.api.nvim_win_set_cursor(0, { 2, 0 })
|
||||
local s, e = require('nuwiki.textobjects')._table_bounds(2)
|
||||
if not (s == 2 and e == 3) then
|
||||
error(string.format('want (2,3) got (%s,%s)', s, e))
|
||||
end
|
||||
end)
|
||||
|
||||
-- One end-to-end: operator-pending `dah` should delete the heading
|
||||
-- section. This avoids the visual-mode `'<`/`'>` mark dance and
|
||||
-- relies on Vim's actual operator pipeline to verify the keymap is
|
||||
-- correctly wired into `omap`.
|
||||
tobj_case('textobj.dah_deletes_heading_section', function()
|
||||
set_buf({ '= h1 =', 'body', '= h2 =', 'rest' })
|
||||
vim.api.nvim_win_set_cursor(0, { 1, 0 })
|
||||
send('dah')
|
||||
sleep(100)
|
||||
local got = get_lines()
|
||||
-- After deleting heading section (lines 1-2), buffer should be the
|
||||
-- remaining two lines. There may be a trailing empty line depending
|
||||
-- on linewise delete semantics; allow both shapes.
|
||||
if not (vim.deep_equal(got, { '= h2 =', 'rest' })
|
||||
or vim.deep_equal(got, { '', '= h2 =', 'rest' })) then
|
||||
error('dah result: ' .. vim.inspect(got))
|
||||
end
|
||||
end)
|
||||
|
||||
-- ===== Smart <CR> (Cluster 5) =====
|
||||
|
||||
run('cr.continues_list_marker', {
|
||||
lines = { '- first' },
|
||||
cursor = { 1, 7 }, -- end of line
|
||||
keys = 'A<CR>second<Esc>',
|
||||
expect_lines = { '- first', '- second' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('cr.continues_checkbox', {
|
||||
lines = { '- [ ] first' },
|
||||
cursor = { 1, 11 },
|
||||
keys = 'A<CR>second<Esc>',
|
||||
expect_lines = { '- [ ] first', '- [ ] second' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('cr.breaks_on_empty_list_line', {
|
||||
lines = { '- ' },
|
||||
cursor = { 1, 2 },
|
||||
keys = 'A<CR>plain<Esc>',
|
||||
expect_lines = { '', 'plain' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('cr.adds_new_table_row', {
|
||||
lines = { '| a | b |' },
|
||||
cursor = { 1, 4 }, -- inside `a`
|
||||
keys = 'A<CR>x<Esc>',
|
||||
-- New row inserted below with empty cells; cursor jumps to first.
|
||||
expect_lines = { '| a | b |', '|x | |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
|
||||
-- ===== Insert-mode table navigation (Cluster 6) =====
|
||||
|
||||
run('tab.next_cell_in_table', {
|
||||
lines = { '| a | b |' },
|
||||
cursor = { 1, 2 }, -- inside cell `a`
|
||||
keys = 'A<Tab>x<Esc>',
|
||||
-- After A: cursor at end. <Tab>: no cell-start strictly after end
|
||||
-- so creates new row. Try cursor on inside-first-cell case below.
|
||||
-- Here cursor at end has no next cell so a new row is added.
|
||||
expect_lines = { '| a | b |', '|x | |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('tab.from_first_cell_moves_to_second', {
|
||||
lines = { '| a | b |' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'lli<Tab>x<Esc>',
|
||||
-- `ll` from col 0 → col 2 (on `a`). `i` enters insert. `<Tab>`
|
||||
-- jumps to col after next `|`. `x` types in second cell.
|
||||
expect_lines = { '| a |x b |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('tab.cycles_to_next_row_when_not_last', {
|
||||
lines = { '| a | b |', '|---|---|', '| c | d |' },
|
||||
cursor = { 1, 6 }, -- inside last cell of header row
|
||||
keys = 'i<Tab>x<Esc>',
|
||||
-- Past the final cell of row 1: skip separator, land in first cell of row 3.
|
||||
expect_lines = { '| a | b |', '|---|---|', '|x c | d |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('tab.inserts_row_only_when_on_last_row', {
|
||||
lines = { '| a | b |', '|---|---|', '| c | d |' },
|
||||
cursor = { 3, 6 }, -- inside last cell of last data row
|
||||
keys = 'i<Tab>x<Esc>',
|
||||
expect_lines = { '| a | b |', '|---|---|', '| c | d |', '|x | |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('shift_tab.prev_cell_in_table', {
|
||||
lines = { '| a | b |' },
|
||||
cursor = { 1, 6 }, -- inside cell `b`
|
||||
keys = 'i<S-Tab>x<Esc>',
|
||||
-- Cursor lands just after the previous cell's `|`; typing `x`
|
||||
-- inserts at col 2 (before the leading space inside cell 1).
|
||||
expect_lines = { '|x a | b |' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
run('tab.outside_table_passes_through', {
|
||||
lines = { 'plain text' },
|
||||
cursor = { 1, 0 },
|
||||
keys = 'i<Tab>x<Esc>',
|
||||
-- Default Tab inserts a literal tab.
|
||||
expect_lines = { '\tx' .. 'plain text' },
|
||||
wait_ms = 100,
|
||||
})
|
||||
|
||||
-- ===== Diary nav =====
|
||||
-- Tested via the LSP open-* responses; can't fully validate without
|
||||
-- a populated diary, but at least confirm the maps fire without
|
||||
-- error.
|
||||
|
||||
-- ===== Wiki picker (config-driven, no LSP) =====
|
||||
-- The picker must build its list from config so it works from any buffer
|
||||
-- before the server attaches.
|
||||
tobj_case('wiki_select.config_list_reads_g_var', function()
|
||||
vim.g.nuwiki_wikis = {
|
||||
{ name = 'Personal', root = '/tmp/nuwiki-a' },
|
||||
{ name = 'Work', root = '/tmp/nuwiki-b', file_extension = 'md' },
|
||||
}
|
||||
local list = require('nuwiki.config').wiki_list()
|
||||
vim.g.nuwiki_wikis = nil
|
||||
if #list ~= 2 then error('want 2 wikis, got ' .. #list) end
|
||||
if list[1].name ~= 'Personal' or list[2].name ~= 'Work' then
|
||||
error('names/order broken: ' .. vim.inspect(list))
|
||||
end
|
||||
if list[2].ext ~= '.md' then
|
||||
error('per-wiki extension not honoured: ' .. tostring(list[2].ext))
|
||||
end
|
||||
end)
|
||||
|
||||
-- ===== 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!')
|
||||
end, 1500)
|
||||
Executable
+71
@@ -0,0 +1,71 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# development/tests/test-keymaps.sh — drive development/tests/test-keymaps.lua under
|
||||
# headless Neovim and report pass/fail per buffer-local keymap.
|
||||
#
|
||||
# The Lua harness needs a real LSP attachment (most keymaps dispatch
|
||||
# `workspace/executeCommand`), so we:
|
||||
# 1. ensure `bin/nuwiki-ls` is built (release),
|
||||
# 2. spin up a scratch wiki under a temp dir,
|
||||
# 3. invoke `nvim --headless` with a minimal init that loads the
|
||||
# plugin + calls `setup()`, then sources the harness Lua.
|
||||
#
|
||||
# Exit code mirrors the harness: 0 on green, 1 on any failure.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
TMP="$(mktemp -d -t nuwiki-keymap-test-XXXXXX)"
|
||||
trap 'rm -rf "$TMP"' EXIT
|
||||
|
||||
log() { printf '\033[1;34m[keymap-test]\033[0m %s\n' "$*"; }
|
||||
|
||||
# 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"
|
||||
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"
|
||||
|
||||
INIT="$TMP/init.lua"
|
||||
cat > "$INIT" <<EOF
|
||||
-- Keep the harness self-contained; no plugin manager required.
|
||||
vim.opt.runtimepath:prepend('$REPO_ROOT')
|
||||
vim.g.nuwiki_binary_path = '$BIN'
|
||||
-- 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"
|
||||
log "running harness…"
|
||||
|
||||
# `nvim --headless -u init.lua FILE +luafile …` — the harness's
|
||||
# `vim.defer_fn` lets the LSP attach before tests fire, then writes
|
||||
# the results file and exits. Output path is plumbed through env.
|
||||
# Wrapped in `timeout` so a hung LSP / harness fails fast instead of
|
||||
# stalling CI runners indefinitely.
|
||||
NUWIKI_KEYMAP_RESULTS="$RESULTS" \
|
||||
timeout 60 nvim --clean -u "$INIT" --headless "$TMP/wiki/index.wiki" \
|
||||
-c "luafile $REPO_ROOT/development/tests/test-keymaps.lua" \
|
||||
>"$TMP/nvim.log" 2>&1 || true
|
||||
|
||||
if [[ ! -f "$RESULTS" ]]; then
|
||||
echo 'keymap test harness produced no output' >&2
|
||||
echo '--- nvim log ---' >&2
|
||||
cat "$TMP/nvim.log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
cat "$RESULTS"
|
||||
|
||||
if grep -qE '^SUMMARY: [0-9]+ passed, 0 failed' "$RESULTS"; then
|
||||
exit 0
|
||||
fi
|
||||
exit 1
|
||||
Reference in New Issue
Block a user