From b922f0d61202c46d634f2c8be5bd5473aa966fbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Gabriel=20Fr=C3=B3es=20Franco?= Date: Sat, 30 May 2026 19:24:20 -0300 Subject: [PATCH] test(config): verify Vim/Neovim config payload parity Expose the init_options payload builders publicly in both clients (M.init_options/M.server_settings in Lua, nuwiki#lsp#settings() in Vim) so they can be inspected. Add editor harnesses that dump each client's server-bound config as deterministic key=value lines and diff against a shared golden file: nvim == golden and vim == golden together prove the two clients send identical config. Add a server-side test asserting the Vim flat shape and the Neovim {nuwiki:{...}} wrapper desugar to the same WikiConfig. Wire both harnesses into CI. Co-Authored-By: Claude Opus 4.7 --- .gitea/workflows/ci.yaml | 4 ++ autoload/nuwiki/lsp.vim | 8 +++ crates/nuwiki-lsp/tests/index_and_config.rs | 48 +++++++++++++ development/tests/config-expected.txt | 17 +++++ development/tests/test-config-vim.sh | 58 ++++++++++++++++ development/tests/test-config-vim.vim | 50 ++++++++++++++ development/tests/test-config.lua | 74 +++++++++++++++++++++ development/tests/test-config.sh | 52 +++++++++++++++ lua/nuwiki/lsp.lua | 14 ++-- 9 files changed, 319 insertions(+), 6 deletions(-) create mode 100644 development/tests/config-expected.txt create mode 100755 development/tests/test-config-vim.sh create mode 100644 development/tests/test-config-vim.vim create mode 100644 development/tests/test-config.lua create mode 100755 development/tests/test-config.sh diff --git a/.gitea/workflows/ci.yaml b/.gitea/workflows/ci.yaml index b7df8e4..2eed90b 100644 --- a/.gitea/workflows/ci.yaml +++ b/.gitea/workflows/ci.yaml @@ -96,3 +96,7 @@ jobs: run: ./development/tests/test-keymaps.sh - name: run Vim keymap harness run: ./development/tests/test-keymaps-vim.sh + - name: run Neovim config parity harness + run: ./development/tests/test-config.sh + - name: run Vim config parity harness + run: ./development/tests/test-config-vim.sh diff --git a/autoload/nuwiki/lsp.vim b/autoload/nuwiki/lsp.vim index 34759c0..cdef885 100644 --- a/autoload/nuwiki/lsp.vim +++ b/autoload/nuwiki/lsp.vim @@ -91,3 +91,11 @@ function! s:settings() abort endif return l:cfg endfunction + +" Public accessor for the `initialization_options` payload. Mirrors the +" Neovim client's `require('nuwiki.lsp').init_options()` so the config +" parity harness can inspect the exact dict each client sends to the +" server. +function! nuwiki#lsp#settings() abort + return s:settings() +endfunction diff --git a/crates/nuwiki-lsp/tests/index_and_config.rs b/crates/nuwiki-lsp/tests/index_and_config.rs index 4a787a7..7267c15 100644 --- a/crates/nuwiki-lsp/tests/index_and_config.rs +++ b/crates/nuwiki-lsp/tests/index_and_config.rs @@ -319,6 +319,54 @@ fn raw_wiki_parses_every_new_key() { assert!(w.auto_toc); } +#[test] +fn vim_flat_and_neovim_wrapped_payloads_desugar_identically() { + // Parity guard for the two client shapes: + // * Vim (`autoload/nuwiki/lsp.vim`) → flat init_options dict. + // * Neovim (`lua/nuwiki/lsp.lua`) → `{ nuwiki = { … } }` for + // `workspace/didChangeConfiguration` (server_settings()). + // Both must produce the same wikis; otherwise switching editors would + // silently change how the server resolves links/diary/HTML paths. + let inner = json!({ + "wiki_root": "/tmp/base", + "file_extension": ".md", + "syntax": "vimwiki", + "log_level": "debug", + "wikis": [ + { + "name": "personal", + "root": "/tmp/personal", + "file_extension": ".wiki", + "index": "home", + "diary_rel_path": "journal", + }, + { "name": "work", "root": "/tmp/work" }, + ], + }); + + let flat = config_from_json(inner.clone()); + let wrapped = config_from_json(json!({ "nuwiki": inner })); + + assert_eq!(flat.wikis.len(), wrapped.wikis.len()); + for (a, b) in flat.wikis.iter().zip(wrapped.wikis.iter()) { + assert_eq!(a.name, b.name); + assert_eq!(a.root, b.root); + assert_eq!(a.file_extension, b.file_extension); + assert_eq!(a.syntax, b.syntax); + assert_eq!(a.index, b.index); + assert_eq!(a.diary_rel_path, b.diary_rel_path); + } + + // And the desugared list matches the per-wiki values both clients send. + assert_eq!(flat.wikis[0].name, "personal"); + assert_eq!(flat.wikis[0].root, PathBuf::from("/tmp/personal")); + assert_eq!(flat.wikis[0].file_extension, ".wiki"); + assert_eq!(flat.wikis[0].index, "home"); + assert_eq!(flat.wikis[0].diary_rel_path, "journal"); + assert_eq!(flat.wikis[1].name, "work"); + assert_eq!(flat.wikis[1].root, PathBuf::from("/tmp/work")); +} + #[test] fn legacy_single_wiki_shape_picks_up_defaults() { let cfg = config_from_json(serde_json::json!({ diff --git a/development/tests/config-expected.txt b/development/tests/config-expected.txt new file mode 100644 index 0000000..62dce97 --- /dev/null +++ b/development/tests/config-expected.txt @@ -0,0 +1,17 @@ +[default] +wiki_root=~/vimwiki +file_extension=.wiki +syntax=vimwiki +log_level=warn +[sample] +wiki_root=/tmp/base +file_extension=.md +syntax=vimwiki +log_level=debug +wikis[0].diary_rel_path=journal +wikis[0].file_extension=.wiki +wikis[0].index=home +wikis[0].name=personal +wikis[0].root=/tmp/personal +wikis[1].name=work +wikis[1].root=/tmp/work diff --git a/development/tests/test-config-vim.sh b/development/tests/test-config-vim.sh new file mode 100755 index 0000000..ae78a83 --- /dev/null +++ b/development/tests/test-config-vim.sh @@ -0,0 +1,58 @@ +#!/usr/bin/env bash +# +# development/tests/test-config-vim.sh — Vim counterpart of test-config.sh. +# +# Runs development/tests/test-config-vim.vim under headless Vim, which dumps +# the Vim client's `initialization_options` payload (nuwiki#lsp#settings()) +# as `key=value` lines, then diffs against the shared golden +# config-expected.txt. Matching the same golden the Neovim harness uses is +# what proves Vim/Neovim config parity. +# +# Exit code: 0 when the payload matches the golden, 1 otherwise. Skips +# (exit 0) when vim is not installed. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" + +log() { printf '\033[1;34m[config-vim]\033[0m %s\n' "$*"; } + +if ! command -v vim >/dev/null 2>&1; then + log 'vim not installed — skipping' + exit 0 +fi + +TMP="$(mktemp -d -t nuwiki-config-vim-test-XXXXXX)" +trap 'rm -rf "$TMP"' EXIT + +GOLDEN="$REPO_ROOT/development/tests/config-expected.txt" +OUT="$TMP/vim-config.txt" + +VIMRC="$TMP/vimrc" +cat > "$VIMRC" <"$TMP/vim.log" 2>&1 || true + +if [[ ! -f "$OUT" ]]; then + echo 'config harness produced no output' >&2 + echo '--- vim log ---' >&2 + cat "$TMP/vim.log" >&2 || true + exit 1 +fi + +if diff -u "$GOLDEN" "$OUT"; then + log 'Vim config payload matches golden ✓' + exit 0 +fi + +echo 'Vim config payload DIVERGED from golden (see diff above)' >&2 +exit 1 diff --git a/development/tests/test-config-vim.vim b/development/tests/test-config-vim.vim new file mode 100644 index 0000000..e5ec45e --- /dev/null +++ b/development/tests/test-config-vim.vim @@ -0,0 +1,50 @@ +" development/tests/test-config-vim.vim — Vim counterpart of +" test-config.lua. Dumps the Vim client's `initialization_options` payload +" (nuwiki#lsp#settings()) for the same two scenarios as the Neovim harness, +" as byte-identical `key=value` lines, so both can be diffed against the +" shared golden development/tests/config-expected.txt. +" +" "nvim == golden" and "vim == golden" together prove Vim/Neovim config +" parity for everything the server consumes. + +let s:out = $NUWIKI_CONFIG_OUT +let s:lines = [] + +function! s:dump(payload) abort + call add(s:lines, 'wiki_root=' . a:payload.wiki_root) + call add(s:lines, 'file_extension=' . a:payload.file_extension) + call add(s:lines, 'syntax=' . a:payload.syntax) + call add(s:lines, 'log_level=' . a:payload.log_level) + if has_key(a:payload, 'wikis') + let l:i = 0 + for l:w in a:payload.wikis + for l:k in sort(keys(l:w)) + call add(s:lines, printf('wikis[%d].%s=%s', l:i, l:k, l:w[l:k])) + endfor + let l:i += 1 + endfor + endif +endfunction + +" Scenario 1: defaults (no globals set). +unlet! g:nuwiki_wiki_root g:nuwiki_file_extension g:nuwiki_syntax +unlet! g:nuwiki_log_level g:nuwiki_wikis +call add(s:lines, '[default]') +call s:dump(nuwiki#lsp#settings()) + +" Scenario 2: same representative config as the Neovim harness. +let g:nuwiki_wiki_root = '/tmp/base' +let g:nuwiki_file_extension = '.md' +let g:nuwiki_syntax = 'vimwiki' +let g:nuwiki_log_level = 'debug' +let g:nuwiki_wikis = [ + \ { 'name': 'personal', 'root': '/tmp/personal', + \ 'file_extension': '.wiki', 'index': 'home', + \ 'diary_rel_path': 'journal' }, + \ { 'name': 'work', 'root': '/tmp/work' }, + \ ] +call add(s:lines, '[sample]') +call s:dump(nuwiki#lsp#settings()) + +call writefile(s:lines, s:out) +qall! diff --git a/development/tests/test-config.lua b/development/tests/test-config.lua new file mode 100644 index 0000000..057e4d2 --- /dev/null +++ b/development/tests/test-config.lua @@ -0,0 +1,74 @@ +-- development/tests/test-config.lua — dump the Neovim client's +-- `initialization_options` payload for two scenarios (default config and a +-- representative multi-wiki sample) as deterministic `key=value` lines. +-- +-- The runner (test-config.sh) diffs the output against +-- development/tests/config-expected.txt. The Vim harness +-- (test-config-vim.vim) emits byte-identical lines against the SAME golden, +-- so "nvim == golden" and "vim == golden" together prove the two clients +-- send the same server-bound config — i.e. Vim/Neovim config parity. +-- +-- Only the server-relevant keys are emitted (wiki_root, file_extension, +-- syntax, log_level, wikis[*]). `mappings`/`folding` are client-side +-- concerns the server ignores, so they're intentionally excluded. + +local out = assert(os.getenv('NUWIKI_CONFIG_OUT'), 'NUWIKI_CONFIG_OUT unset') + +local config = require('nuwiki.config') +local lsp = require('nuwiki.lsp') + +local lines = {} +local function emit(s) + lines[#lines + 1] = s +end + +local function dump(payload) + emit('wiki_root=' .. tostring(payload.wiki_root)) + emit('file_extension=' .. tostring(payload.file_extension)) + emit('syntax=' .. tostring(payload.syntax)) + emit('log_level=' .. tostring(payload.log_level)) + if payload.wikis then + for i, w in ipairs(payload.wikis) do + local keys = {} + for k in pairs(w) do + keys[#keys + 1] = k + end + table.sort(keys) + for _, k in ipairs(keys) do + emit(string.format('wikis[%d].%s=%s', i - 1, k, tostring(w[k]))) + end + end + end +end + +-- Scenario 1: defaults (no user config, no VimL globals). +vim.g.nuwiki_wikis = nil +config.apply({}) +emit('[default]') +dump(lsp.init_options()) + +-- Scenario 2: representative config exercising scalar keys + a wikis list. +config.apply({ + wiki_root = '/tmp/base', + file_extension = '.md', + syntax = 'vimwiki', + log_level = 'debug', + wikis = { + { + name = 'personal', + root = '/tmp/personal', + file_extension = '.wiki', + index = 'home', + diary_rel_path = 'journal', + }, + { name = 'work', root = '/tmp/work' }, + }, +}) +emit('[sample]') +dump(lsp.init_options()) + +local f = assert(io.open(out, 'w')) +f:write(table.concat(lines, '\n') .. '\n') +f:close() + +vim.cmd('qa!') diff --git a/development/tests/test-config.sh b/development/tests/test-config.sh new file mode 100755 index 0000000..ee39c61 --- /dev/null +++ b/development/tests/test-config.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# +# development/tests/test-config.sh — verify the Neovim client builds the +# exact `initialization_options` payload the server expects. +# +# Runs development/tests/test-config.lua under headless Neovim, which dumps +# the server-bound config (wiki_root, file_extension, syntax, log_level, +# wikis[*]) for a default and a sample scenario as `key=value` lines, then +# diffs the result against the shared golden config-expected.txt. +# +# The Vim harness (test-config-vim.sh) diffs against the SAME golden, so a +# green run of both proves Vim/Neovim config parity. +# +# Exit code: 0 when the payload matches the golden, 1 otherwise. + +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +TMP="$(mktemp -d -t nuwiki-config-test-XXXXXX)" +trap 'rm -rf "$TMP"' EXIT + +log() { printf '\033[1;34m[config-test]\033[0m %s\n' "$*"; } + +GOLDEN="$REPO_ROOT/development/tests/config-expected.txt" +OUT="$TMP/nvim-config.txt" + +# No LSP server needed — we only inspect the payload the client would send. +INIT="$TMP/init.lua" +cat > "$INIT" <"$TMP/nvim.log" 2>&1 || true + +if [[ ! -f "$OUT" ]]; then + echo 'config harness produced no output' >&2 + echo '--- nvim log ---' >&2 + cat "$TMP/nvim.log" >&2 || true + exit 1 +fi + +if diff -u "$GOLDEN" "$OUT"; then + log 'Neovim config payload matches golden ✓' + exit 0 +fi + +echo 'Neovim config payload DIVERGED from golden (see diff above)' >&2 +exit 1 diff --git a/lua/nuwiki/lsp.lua b/lua/nuwiki/lsp.lua index 977c9cc..6338907 100644 --- a/lua/nuwiki/lsp.lua +++ b/lua/nuwiki/lsp.lua @@ -28,14 +28,16 @@ end --- `initializationOptions` payload: the server reads it once at --- `initialize` time via `Config::from_init_params`. This is what --- delivers `wiki_root`/`wikis`/HTML/diary config to Rust. -local function init_options() +--- Public so the config parity harness can inspect the exact payload. +function M.init_options() return resolved_opts() end --- `settings` payload: the same shape, but sent via --- `workspace/didChangeConfiguration` after startup. Lets the server --- pick up config changes without a restart. -local function server_settings() +--- Public so the config parity harness can inspect the exact payload. +function M.server_settings() return { nuwiki = resolved_opts() } end @@ -69,8 +71,8 @@ function M.register() cmd = command(), filetypes = { 'vimwiki' }, root_markers = { '.git', '.nuwiki' }, - init_options = init_options(), - settings = server_settings(), + init_options = M.init_options(), + settings = M.server_settings(), }) vim.lsp.enable('nuwiki') return @@ -85,8 +87,8 @@ function M.register() name = 'nuwiki', cmd = command(), root_dir = root_dir_for(ev.file), - init_options = init_options(), - settings = server_settings(), + init_options = M.init_options(), + settings = M.server_settings(), }) end, })