Move development scripts into development/, leaving scripts/ plugin-only
CI / cargo fmt --check (push) Successful in 32s
CI / cargo clippy (push) Successful in 25s
CI / cargo test (push) Successful in 33s
CI / cargo fmt --check (pull_request) Successful in 30s
CI / cargo clippy (pull_request) Successful in 49s
CI / cargo test (pull_request) Successful in 43s
CI / editor keymaps (push) Successful in 1m49s
CI / editor keymaps (pull_request) Successful in 1m36s

The root scripts/ folder now holds only download_bin.vim, the one script
the plugin itself invokes (via :NuwikiInstall and the vim-plug/Dein build
hooks). All developer tooling — the start-nvim/start-vim launchers,
test-personal-wiki, the keymap harnesses, and syntax-diag — moves to
development/ alongside the developer docs.

REPO_ROOT resolution is unchanged (development/ sits one level under root,
same as scripts/ did); only the explicit cross-references, the CI harness
invocations, the README launcher paths, and the ONBOARDING structure tree
are updated.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-30 14:35:24 -03:00
parent dd92e070d0
commit f32f54e8a8
11 changed files with 44 additions and 32 deletions
+13 -1
View File
@@ -65,9 +65,21 @@ nuwiki/
├── doc/ # Vim help documentation
│ └── nuwiki.txt
├── scripts/
├── scripts/ # Plugin-runtime scripts only
│ └── download_bin.vim # VimL binary download (used by Dein/vim-plug build hooks)
├── development/ # Developer-only tooling and docs (not shipped)
│ ├── ONBOARDING.md # This file
│ ├── nuwiki-architecture.html
│ ├── start-nvim.sh # Launch Neovim against a scratch wiki
│ ├── start-vim.sh # Launch Vim against a scratch wiki
│ ├── test-personal-wiki.sh # Launch Vim against your real wiki
│ ├── test-keymaps.sh # Neovim keymap harness (CI)
│ ├── test-keymaps.lua
│ ├── test-keymaps-vim.sh # Vim keymap harness (CI)
│ ├── test-keymaps-vim.vim
│ └── syntax-diag.vim # Dump highlighting state for debugging
└── .gitea/
└── workflows/
├── ci.yaml # Lint, test, fmt check on every push/PR
+158
View File
@@ -0,0 +1,158 @@
#!/usr/bin/env bash
#
# start-nvim.sh — launch Neovim with a minimal config that loads nuwiki.
#
# Builds the language server (release mode), symlinks it into `bin/` so
# the plugin's default install path resolves, then spawns Neovim with
# `--clean` and a generated `init.lua` that:
# * adds this repo to `runtimepath`
# * configures wiki_root to a scratch directory under
# $XDG_CACHE_HOME/nuwiki-dev/wiki
# * calls `require('nuwiki').setup({...})`
#
# Usage: ./development/start-nvim.sh [extra args...]
# ./development/start-nvim.sh # open the scratch wiki's index
# ./development/start-nvim.sh path/to/note.wiki # open a specific file
# NUWIKI_DEV_WIKI=/tmp/foo ./development/start-nvim.sh
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-nvim.sh # reuse the cached binary
#
# The release binary is rebuilt on every launch so source changes
# always reach the running LSP. `cargo build --release` is incremental,
# so this is a fast no-op when nothing changed. Set
# NUWIKI_DEV_SKIP_BUILD=1 to skip the build step entirely.
#
# Tested with Neovim 0.10+. State (cache/data/config) lives under
# $XDG_CACHE_HOME/nuwiki-dev/ so your real Neovim install stays
# untouched.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEV_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/nuwiki-dev"
WIKI_DIR="${NUWIKI_DEV_WIKI:-$DEV_DIR/wiki}"
INIT_FILE="$DEV_DIR/init.lua"
STATE_DIR="$DEV_DIR/nvim-state"
DATA_DIR="$DEV_DIR/nvim-data"
CONFIG_DIR="$DEV_DIR/nvim-config"
log() { printf '\033[1;34m[nuwiki-dev]\033[0m %s\n' "$*"; }
ensure_binary() {
local bin="$REPO_ROOT/bin/nuwiki-ls"
local built="$REPO_ROOT/target/release/nuwiki-ls"
if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then
if [[ ! -x "$bin" ]]; then
log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing — building anyway"
else
log "NUWIKI_DEV_SKIP_BUILD=1 — using existing $bin"
return
fi
fi
log "building nuwiki-ls (release)…"
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml"
mkdir -p "$REPO_ROOT/bin"
ln -sf "$built" "$bin"
log "binary ready: $bin (built $(date -r "$built" '+%Y-%m-%d %H:%M:%S'))"
}
seed_wiki() {
mkdir -p "$WIKI_DIR" "$WIKI_DIR/diary"
if [[ ! -f "$WIKI_DIR/index.wiki" ]]; then
cat > "$WIKI_DIR/index.wiki" <<'EOF'
= Welcome to nuwiki =
This is a scratch wiki created by start-nvim.sh. Edit, run commands,
or jump to the linked pages to test the plugin.
== Smoke test ==
- [[Notes]]
- [[diary/]]
- [ ] Toggle me with <C-Space>
- [ ] Or with :VimwikiToggleListItem
== Commands to try ==
- :VimwikiTOC
- :VimwikiGenerateLinks
- :VimwikiCheckLinks
- :VimwikiMakeDiaryNote
- :Vimwiki2HTMLBrowse
== Tags ==
:smoke-test:sandbox:
== Links ==
* [[Notes]]
* [[Notes#section-one]]
* https://example.com
EOF
fi
if [[ ! -f "$WIKI_DIR/Notes.wiki" ]]; then
cat > "$WIKI_DIR/Notes.wiki" <<'EOF'
= Notes =
A second page so [[index]] has somewhere to point.
== Section one ==
Anchor target for :checkhealth-style smoke tests.
EOF
fi
}
write_init() {
mkdir -p "$DEV_DIR"
cat > "$INIT_FILE" <<EOF
-- start-nvim.sh: generated minimal config for nuwiki development.
-- Regenerated on every launch; edit start-nvim.sh, not this file.
-- Confine state to the dev cache so we never touch the user's real
-- Neovim install.
vim.env.XDG_STATE_HOME = '$STATE_DIR'
vim.env.XDG_DATA_HOME = '$DATA_DIR'
vim.env.XDG_CONFIG_HOME = '$CONFIG_DIR'
vim.opt.runtimepath:prepend('$REPO_ROOT')
vim.g.nuwiki_binary_path = '$REPO_ROOT/bin/nuwiki-ls'
-- Friendly defaults for an interactive smoke test.
vim.opt.number = true
vim.opt.signcolumn = 'yes'
vim.opt.termguicolors = true
vim.opt.swapfile = false
vim.opt.writebackup = false
vim.opt.undofile = false
vim.g.mapleader = ' '
require('nuwiki').setup({
wiki_root = '$WIKI_DIR',
})
-- Surface log lines (the LSP logs via window/logMessage). Without this
-- they're swallowed; with it they land in :messages.
vim.lsp.set_log_level('info')
print('[nuwiki-dev] ready — wiki root: $WIKI_DIR')
print('[nuwiki-dev] :LspInfo to inspect, :checkhealth nuwiki for diagnostics')
EOF
}
main() {
ensure_binary
seed_wiki
write_init
mkdir -p "$STATE_DIR" "$DATA_DIR" "$CONFIG_DIR"
log "launching: nvim --clean -u $INIT_FILE"
if [[ $# -gt 0 ]]; then
exec nvim --clean -u "$INIT_FILE" "$@"
else
exec nvim --clean -u "$INIT_FILE" "$WIKI_DIR/index.wiki"
fi
}
main "$@"
+197
View File
@@ -0,0 +1,197 @@
#!/usr/bin/env bash
#
# start-vim.sh — launch Vim with a minimal config that loads nuwiki.
#
# The plugin's Vim path (autoload/nuwiki/lsp.vim) prefers `vim-lsp` and
# falls back to `coc.nvim`. This script clones vim-lsp + async.vim into
# the dev cache on first run so the LSP integration is exercisable
# without polluting your real Vim install.
#
# Usage: ./development/start-vim.sh [extra args...]
# ./development/start-vim.sh # open the scratch wiki's index
# ./development/start-vim.sh path/to/note.wiki # open a specific file
# NUWIKI_DEV_WIKI=/tmp/foo ./development/start-vim.sh
# NUWIKI_DEV_NO_LSP=1 ./development/start-vim.sh # skip vim-lsp setup
# NUWIKI_DEV_NO_SEED=1 ./development/start-vim.sh # use WIKI_DIR as-is (no scratch seeding)
# NUWIKI_DEV_SKIP_BUILD=1 ./development/start-vim.sh # reuse the cached binary
#
# The release binary is rebuilt on every launch so source changes
# always reach the running LSP. `cargo build --release` is incremental,
# so this is a fast no-op when nothing changed. Set
# NUWIKI_DEV_SKIP_BUILD=1 to skip the build step entirely.
#
# Tested with Vim 9.1+. State (viminfo/sessions) lives under
# $XDG_CACHE_HOME/nuwiki-dev/ so your real Vim install stays untouched.
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
DEV_DIR="${XDG_CACHE_HOME:-$HOME/.cache}/nuwiki-dev"
WIKI_DIR="${NUWIKI_DEV_WIKI:-$DEV_DIR/wiki}"
VIMRC="$DEV_DIR/vimrc"
VIM_STATE="$DEV_DIR/vim-state"
VIM_LSP_DIR="$DEV_DIR/vim-lsp"
ASYNC_DIR="$DEV_DIR/async.vim"
log() { printf '\033[1;34m[nuwiki-dev]\033[0m %s\n' "$*"; }
ensure_binary() {
local bin="$REPO_ROOT/bin/nuwiki-ls"
local built="$REPO_ROOT/target/release/nuwiki-ls"
if [[ "${NUWIKI_DEV_SKIP_BUILD:-0}" == "1" ]]; then
if [[ ! -x "$bin" ]]; then
log "NUWIKI_DEV_SKIP_BUILD=1 but $bin is missing — building anyway"
else
log "NUWIKI_DEV_SKIP_BUILD=1 — using existing $bin"
return
fi
fi
log "building nuwiki-ls (release)…"
cargo build --release -p nuwiki-ls --manifest-path "$REPO_ROOT/Cargo.toml"
mkdir -p "$REPO_ROOT/bin"
ln -sf "$built" "$bin"
log "binary ready: $bin (built $(date -r "$built" '+%Y-%m-%d %H:%M:%S'))"
}
clone_if_missing() {
local repo_url="$1"
local dest="$2"
if [[ -d "$dest/.git" ]]; then
return
fi
if ! command -v git >/dev/null 2>&1; then
log "git not found — skipping $repo_url"
return 1
fi
log "cloning $repo_url$dest"
git clone --depth 1 "$repo_url" "$dest" >/dev/null 2>&1
}
ensure_vim_lsp() {
if [[ "${NUWIKI_DEV_NO_LSP:-0}" == "1" ]]; then
log "NUWIKI_DEV_NO_LSP=1 — skipping vim-lsp setup"
return
fi
mkdir -p "$DEV_DIR"
clone_if_missing https://github.com/prabirshrestha/vim-lsp.git "$VIM_LSP_DIR" || true
clone_if_missing https://github.com/prabirshrestha/async.vim.git "$ASYNC_DIR" || true
}
seed_wiki() {
if [[ "${NUWIKI_DEV_NO_SEED:-0}" == "1" ]]; then
log "NUWIKI_DEV_NO_SEED=1 — skipping wiki seed (using $WIKI_DIR as-is)"
return
fi
mkdir -p "$WIKI_DIR" "$WIKI_DIR/diary"
if [[ ! -f "$WIKI_DIR/index.wiki" ]]; then
cat > "$WIKI_DIR/index.wiki" <<'EOF'
= Welcome to nuwiki =
This is a scratch wiki created by start-vim.sh. Edit, run commands,
or jump to the linked pages to test the plugin.
== Smoke test ==
- [[Notes]]
- [[diary/]]
- [ ] Toggle me with :VimwikiToggleListItem
- [ ] Inspect the LSP via :LspStatus
== Commands to try ==
- :VimwikiTOC
- :VimwikiGenerateLinks
- :VimwikiCheckLinks
- :VimwikiMakeDiaryNote
== Tags ==
:smoke-test:sandbox:
EOF
fi
if [[ ! -f "$WIKI_DIR/Notes.wiki" ]]; then
cat > "$WIKI_DIR/Notes.wiki" <<'EOF'
= Notes =
A second page so [[index]] has somewhere to point.
== Section one ==
Anchor target for cross-page link follows.
EOF
fi
}
write_vimrc() {
mkdir -p "$DEV_DIR"
cat > "$VIMRC" <<EOF
" start-vim.sh: generated minimal config for nuwiki development.
" Regenerated on every launch; edit start-vim.sh, not this file.
" Confine state to the dev cache so we never touch the user's real Vim
" install.
set viminfo='100,n$VIM_STATE/viminfo
let &directory = '$VIM_STATE/swap//'
let &backupdir = '$VIM_STATE/backup//'
let &undodir = '$VIM_STATE/undo//'
" Make Vim look for runtime paths in the dev cache.
set nocompatible
let &runtimepath = '$REPO_ROOT' . ',' . &runtimepath
let &runtimepath .= ',$VIM_LSP_DIR'
let &runtimepath .= ',$ASYNC_DIR'
let g:nuwiki_binary_path = '$REPO_ROOT/bin/nuwiki-ls'
let g:nuwiki_wiki_root = '$WIKI_DIR'
let g:nuwiki_file_extension = '.wiki'
let g:nuwiki_syntax = 'vimwiki'
let g:nuwiki_log_level = 'info'
filetype plugin indent on
syntax enable
set hidden
set termguicolors
set number
set signcolumn=yes
set noswapfile
set nobackup
set nowritebackup
let mapleader = ' '
" Auto-detect filetype for .wiki files (the plugin ships ftdetect, but
" --clean disables those — copy the rule here).
augroup NuwikiFtdetect
autocmd!
autocmd BufRead,BufNewFile *.wiki setfiletype vimwiki
augroup END
" If vim-lsp is on the runtimepath, the plugin's autoload entry point
" registers the server automatically. Triggering it on FileType keeps
" the script's responsibility minimal.
augroup NuwikiStart
autocmd!
autocmd FileType vimwiki call nuwiki#lsp#start()
augroup END
" Status messages go to :messages so they don't trigger "Press ENTER".
echomsg '[nuwiki-dev] ready — wiki root: $WIKI_DIR'
echomsg '[nuwiki-dev] :LspStatus for diagnostics, :messages for log'
EOF
}
main() {
ensure_binary
ensure_vim_lsp
seed_wiki
write_vimrc
mkdir -p "$VIM_STATE/swap" "$VIM_STATE/backup" "$VIM_STATE/undo"
log "launching: vim --clean -u $VIMRC"
if [[ $# -gt 0 ]]; then
exec vim --clean -u "$VIMRC" "$@"
else
exec vim --clean -u "$VIMRC" "$WIKI_DIR/index.wiki"
fi
}
main "$@"
+75
View File
@@ -0,0 +1,75 @@
" development/syntax-diag.vim — dump highlighting state to /tmp/nuwiki-syndiag.log
"
" Usage: open a .wiki file with VARIED markup (heading, *bold*, _italic_,
" `code`), then:
" :source development/syntax-diag.vim
" Then paste /tmp/nuwiki-syndiag.log back.
redir! > /tmp/nuwiki-syndiag.log
echo '=== state ==='
echo 'filetype=' . &filetype
echo 'syntax=' . &syntax
echo 'b:current_syntax=' . get(b:, 'current_syntax', '<unset>')
echo 'syntax_on=' . exists('g:syntax_on')
echo 'colors_name=' . get(g:, 'colors_name', '<unset>')
echo 'background=' . &background
echo 'termguicolors=' . &termguicolors
echo 't_Co=' . &t_Co
echo 'has(nvim)=' . has('nvim')
echo 'g:loaded_nuwiki=' . get(g:, 'loaded_nuwiki', '<unset>')
echo 'g:loaded_vimwiki=' . get(g:, 'loaded_vimwiki', '<unset>')
echo ''
echo '=== every syntax/vimwiki.vim in runtimepath (first wins) ==='
for s:p in split(&runtimepath, ',')
let s:f = s:p . '/syntax/vimwiki.vim'
if filereadable(s:f)
echo s:f
endif
endfor
echo ''
echo '=== :hi for our groups (NO silent — capture into redir) ==='
for s:g in ['nuwikiHeading', 'nuwikiBold', 'nuwikiItalic', 'nuwikiCode',
\ 'nuwikiPlaceholder', 'nuwikiCommentLine', 'nuwikiWikilink',
\ 'Title', 'PreProc', 'String', 'Comment', 'Underlined', 'Normal']
execute 'highlight ' . s:g
endfor
echo ''
echo '=== synID walk over current buffer (first 30 lines) ==='
for s:ln in range(1, min([line('$'), 30]))
let s:l = getline(s:ln)
let s:segs = []
let s:last = ''
let s:start = 1
for s:c in range(1, len(s:l) + 1)
let s:g = s:c <= len(s:l) ? synIDattr(synIDtrans(synID(s:ln, s:c, 1)), 'name') : ''
if s:g != s:last
if s:start <= s:c - 1
let s:txt = strpart(s:l, s:start - 1, s:c - s:start)
call add(s:segs, s:last . ':' . s:txt)
endif
let s:last = s:g
let s:start = s:c
endif
endfor
echo printf('L%d %s', s:ln, join(s:segs, ' | '))
endfor
echo ''
echo '=== :syntax list (full) ==='
syntax list
echo ''
echo '=== :scriptnames (filtered) ==='
let s:sn = execute('scriptnames')
for s:line in split(s:sn, '\n')
if s:line =~? 'vimwiki\|nuwiki\|syntax\|colors'
echo s:line
endif
endfor
redir END
echo 'Wrote /tmp/nuwiki-syndiag.log'
+65
View File
@@ -0,0 +1,65 @@
#!/usr/bin/env bash
#
# development/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/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/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
+460
View File
@@ -0,0 +1,460 @@
" development/test-keymaps-vim.vim — driven by development/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/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 (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', {
\ '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!')
+706
View File
@@ -0,0 +1,706 @@
-- development/test-keymaps.lua — driven by development/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 (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', {
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 (§13.1 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)
+71
View File
@@ -0,0 +1,71 @@
#!/usr/bin/env bash
#
# development/test-keymaps.sh — drive development/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/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
+34
View File
@@ -0,0 +1,34 @@
#!/usr/bin/env bash
#
# test-personal-wiki.sh — open the user's real personal wiki via start-vim.sh.
#
# Reuses start-vim.sh's plumbing (binary build, vim-lsp clone, viminfo
# under the dev cache) but points the wiki root at ~/.vimwiki/personal_wiki
# and disables seed_wiki so we don't drop a scratch `Notes.wiki` into your
# real notes.
#
# Useful when reproducing a user-reported bug against actual content rather
# than the seeded smoke-test wiki. State (viminfo/sessions) still lives
# under $XDG_CACHE_HOME/nuwiki-dev/ so your real Vim install stays untouched.
#
# Usage:
# ./development/test-personal-wiki.sh # open index.wiki
# ./development/test-personal-wiki.sh path/to/note.wiki # open a specific file
# NUWIKI_PERSONAL_WIKI=/other/path ./development/test-personal-wiki.sh
# NUWIKI_DEV_SKIP_BUILD=1 ./development/test-personal-wiki.sh # reuse the cached binary
set -euo pipefail
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
WIKI_DIR="${NUWIKI_PERSONAL_WIKI:-$HOME/.vimwiki/personal_wiki}"
if [[ ! -d "$WIKI_DIR" ]]; then
echo "test-personal-wiki: $WIKI_DIR does not exist" >&2
echo "set NUWIKI_PERSONAL_WIKI to point at your wiki, or create the default path" >&2
exit 1
fi
export NUWIKI_DEV_WIKI="$WIKI_DIR"
export NUWIKI_DEV_NO_SEED=1
exec "$REPO_ROOT/development/start-vim.sh" "$@"