From 8d3cf244b4e5942c2e1a0c5b10f3e1dce1dfd463 Mon Sep 17 00:00:00 2001 From: Junhyeok Ahn Date: Fri, 31 Jul 2026 20:01:02 -0700 Subject: [PATCH 01/11] nvim: update flake to nixpkgs 2026-07-30 Neovim 0.11.1 -> 0.12.4, plus 15 months of plugin and tool updates (clang-tools 19 -> 21, basedpyright 1.29 -> 1.39, lua-language-server 3.14 -> 3.18, ripgrep 14 -> 15, fzf 0.62 -> 0.74, mini.nvim -> 0.18.0). nodePackages was removed from nixpkgs, so bash-language-server moves to the top level. Co-Authored-By: Claude Opus 5 (1M context) --- install/nvim/flake.lock | 6 +++--- install/nvim/flake.nix | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/install/nvim/flake.lock b/install/nvim/flake.lock index affeabc..a57f1a1 100644 --- a/install/nvim/flake.lock +++ b/install/nvim/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1746904237, - "narHash": "sha256-3e+AVBczosP5dCLQmMoMEogM57gmZ2qrVSrmq9aResQ=", + "lastModified": 1785454630, + "narHash": "sha256-LQy14TZp77TwbQf40gg1V3jo8FwJG0jGDkAH+zRHqg8=", "owner": "NixOS", "repo": "nixpkgs", - "rev": "d89fc19e405cb2d55ce7cc114356846a0ee5e956", + "rev": "1559d3daa3ecc813a650b79375ea61b6741b8746", "type": "github" }, "original": { diff --git a/install/nvim/flake.nix b/install/nvim/flake.nix index 3c4eaf9..5ba65fb 100644 --- a/install/nvim/flake.nix +++ b/install/nvim/flake.nix @@ -51,7 +51,7 @@ lua-language-server basedpyright clang-tools - nodePackages.bash-language-server + bash-language-server stylua python3Packages.yapf fzf From a89618821e0a0f95136c976a1f27aab2b721d5bb Mon Sep 17 00:00:00 2001 From: Junhyeok Ahn Date: Fri, 31 Jul 2026 20:20:07 -0700 Subject: [PATCH 02/11] nvim: add mini.test harness and nix run .#test runner --- install/nvim/flake.nix | 29 ++++++++++++++ install/nvim/tests/helpers.lua | 61 +++++++++++++++++++++++++++++ install/nvim/tests/run.lua | 20 ++++++++++ install/nvim/tests/test_harness.lua | 20 ++++++++++ 4 files changed, 130 insertions(+) create mode 100644 install/nvim/tests/helpers.lua create mode 100644 install/nvim/tests/run.lua create mode 100644 install/nvim/tests/test_harness.lua diff --git a/install/nvim/flake.nix b/install/nvim/flake.nix index 5ba65fb..04f0b9a 100644 --- a/install/nvim/flake.nix +++ b/install/nvim/flake.nix @@ -82,9 +82,38 @@ wrapperArgs = neovimConfig.wrapperArgs ++ [ "--prefix" "PATH" ":" (pkgs.lib.makeBinPath extraBinaries) ]; }); + + nvimTest = pkgs.writeShellApplication { + name = "nvim-test"; + runtimeInputs = [ nvim pkgs.coreutils ]; + text = '' + tmp="$(mktemp -d)" + trap 'rm -rf "$tmp"' EXIT + export XDG_DATA_HOME="$tmp/data" + export XDG_STATE_HOME="$tmp/state" + export XDG_CACHE_HOME="$tmp/cache" + mkdir -p "$XDG_DATA_HOME" "$XDG_STATE_HOME" "$XDG_CACHE_HOME" + + # Mirror the live-or-baked pattern the config itself uses, so local + # edits are testable without committing them for nix to see. + tests="$HOME/dotfiles/install/nvim/tests" + if [ ! -d "$tests" ]; then + tests="${./tests}" + fi + + exec nvim --headless \ + --cmd "lua vim.g.nvim_tests_dir = '$tests'" \ + -c "luafile $tests/run.lua" + ''; + }; in { packages.nvim = nvim; + packages.test = nvimTest; packages.default = nvim; + apps.test = { + type = "app"; + program = "${nvimTest}/bin/nvim-test"; + }; } ); } diff --git a/install/nvim/tests/helpers.lua b/install/nvim/tests/helpers.lua new file mode 100644 index 0000000..3272d4f --- /dev/null +++ b/install/nvim/tests/helpers.lua @@ -0,0 +1,61 @@ +-- Shared bootstrap for the nvim config test suite. +-- +-- The nix wrapper starts Neovim with the config split across two mechanisms: +-- VIMINIT="lua dofile('/nix/store/-init.lua')" (environment) +-- --cmd "set packpath^=/nix/store/-vim-pack-dir" (argv) +-- --cmd "set rtp^=" (argv) +-- +-- mini.test spawns child processes with a hardcoded `--clean`, which discards +-- both. So we recover them from the running parent and pass them to the child +-- explicitly. Without this a child has no mapleader and no plugins. + +local M = {} + +--- Reconstruct the argv a child needs in order to load the real config. +---@return string[] +function M.config_args() + local viminit = vim.env.VIMINIT + assert(viminit, "VIMINIT unset - tests must run under the nix-wrapped nvim") + + local init = viminit:match("dofile%(.([^'\"]+).%)") + assert(init, "could not extract init path from VIMINIT: " .. viminit) + + local pack + for _, p in ipairs(vim.opt.packpath:get()) do + if p:match("vim%-pack%-dir") then + pack = p + break + end + end + assert(pack, "no vim-pack-dir on packpath") + + return { + "--cmd", + "set packpath^=" .. pack, + "--cmd", + "set rtp^=" .. pack, + "-u", + init, + } +end + +--- A child Neovim preloaded with the args needed to reach the real config. +function M.new_child() + local child = MiniTest.new_child_neovim() + child.args = M.config_args() + return child +end + +--- A test set that gives every case a freshly restarted child. +function M.new_set(child) + return MiniTest.new_set { + hooks = { + pre_case = function() + child.restart(child.args) + end, + post_once = child.stop, + }, + } +end + +return M diff --git a/install/nvim/tests/run.lua b/install/nvim/tests/run.lua new file mode 100644 index 0000000..a6cbbba --- /dev/null +++ b/install/nvim/tests/run.lua @@ -0,0 +1,20 @@ +-- Entry point. `vim.g.nvim_tests_dir` is set by the flake app. +local dir = vim.g.nvim_tests_dir +assert(dir, "vim.g.nvim_tests_dir not set - run via `nix run ./install/nvim#test`") + +package.path = dir .. "/?.lua;" .. package.path + +require("mini.test").setup { + collect = { + find_files = function() + return vim.fn.globpath(dir, "test_*.lua", true, true) + end, + }, +} + +MiniTest.run() + +-- MiniTest calls `cquit 1` itself when anything fails, so reaching this line +-- means everything passed. Without an explicit quit the headless process hangs +-- forever on success. +vim.cmd "qall!" diff --git a/install/nvim/tests/test_harness.lua b/install/nvim/tests/test_harness.lua new file mode 100644 index 0000000..a306d17 --- /dev/null +++ b/install/nvim/tests/test_harness.lua @@ -0,0 +1,20 @@ +local helpers = require "helpers" + +local child = helpers.new_child() +local T = helpers.new_set(child) +local eq = MiniTest.expect.equality + +T["child loads the real config"] = function() + eq(child.lua_get "vim.g.mapleader", ",") +end + +T["child has plugins on runtimepath"] = function() + eq(child.lua_get 'pcall(require, "fzf-lua")', true) +end + +T["child startup is error-free"] = function() + eq(child.lua_get "vim.v.errmsg", "") + eq(child.cmd_capture "messages", "") +end + +return T From e9ba747dda42febe8168fe7b703a26e7ca40b5ee Mon Sep 17 00:00:00 2001 From: Junhyeok Ahn Date: Fri, 31 Jul 2026 20:35:50 -0700 Subject: [PATCH 03/11] fix(nvim/tests): stop temp-dir leak, hang, and false-pass in test harness Three failure-path bugs in the mini.test harness defeated its purpose of catching nix flake update breakage: - flake.nix: nvimTest used `exec nvim`, which replaces the shell process and permanently defeats the `trap ... EXIT` cleanup, leaking a tmp dir (with populated data/state/cache) on every run. Now nvim runs normally, its exit status is captured, the tmp dir is removed explicitly, and the script exits with nvim's original status. - run.lua: MiniTest.run() does not guard MiniTest.collect(), so a broken test file (e.g. a failed assert in helpers.lua) re-raises an error that nvim prints and then hangs on forever in headless mode instead of exiting nonzero. MiniTest.run() is now pcall-guarded; any bootstrap error prints and forces `cquit 1`. - run.lua: mini.test's pass/fail check is vacuously false when zero cases are collected, so a bad glob/tests-dir silently exits 0 having tested nothing. Now asserts MiniTest.current.all_cases is non-empty after the run and exits nonzero with a clear message otherwise. Verified all four paths manually via `nix run ./install/nvim#test`: all green (0), an ordinary expectation failure (1), a bootstrap assert failure (1, no hang), and zero collected tests (1, clear message). No leftover temp dir after a run. Co-Authored-By: Claude Opus 5 (1M context) --- install/nvim/flake.nix | 9 +++++++-- install/nvim/tests/run.lua | 27 +++++++++++++++++++++++---- 2 files changed, 30 insertions(+), 6 deletions(-) diff --git a/install/nvim/flake.nix b/install/nvim/flake.nix index 04f0b9a..b7873c7 100644 --- a/install/nvim/flake.nix +++ b/install/nvim/flake.nix @@ -101,9 +101,14 @@ tests="${./tests}" fi - exec nvim --headless \ + status=0 + nvim --headless \ --cmd "lua vim.g.nvim_tests_dir = '$tests'" \ - -c "luafile $tests/run.lua" + -c "luafile $tests/run.lua" || status=$? + + rm -rf "$tmp" + trap - EXIT + exit "$status" ''; }; in { diff --git a/install/nvim/tests/run.lua b/install/nvim/tests/run.lua index a6cbbba..271fa7d 100644 --- a/install/nvim/tests/run.lua +++ b/install/nvim/tests/run.lua @@ -12,9 +12,28 @@ require("mini.test").setup { }, } -MiniTest.run() +-- MiniTest.collect() re-raises (via `error`) any failure while sourcing a +-- test file (e.g. a broken top-level `assert` in a shared helper), and +-- MiniTest.run() does not guard that call. Left unguarded, nvim prints the +-- error (E5113) and then just sits there in headless mode instead of exiting +-- nonzero, turning a bootstrap bug into a CI hang. Guard it ourselves. +local ok, err = pcall(MiniTest.run) + +if not ok then + vim.api.nvim_err_writeln("nvim-test: bootstrap error: " .. tostring(err)) + vim.cmd "cquit 1" +end + +-- MiniTest.run() calls `cquit 0`/`cquit 1` itself (via the stdout reporter's +-- `finish` hook) as soon as at least one case was collected, for both the +-- passing and failing case, and that never returns control here. So reaching +-- this point means zero cases were collected - e.g. the glob or tests dir +-- resolved wrong - which mini.test would otherwise treat as a vacuous pass. +-- Refuse that instead of exiting 0 having tested nothing. +local all_cases = MiniTest.current.all_cases +if all_cases == nil or #all_cases == 0 then + vim.api.nvim_err_writeln "nvim-test: collected 0 test cases - refusing to report success" + vim.cmd "cquit 1" +end --- MiniTest calls `cquit 1` itself when anything fails, so reaching this line --- means everything passed. Without an explicit quit the headless process hangs --- forever on success. vim.cmd "qall!" From 968f3f9934a837e4f66f790fa18662003672aaa9 Mon Sep 17 00:00:00 2001 From: Junhyeok Ahn Date: Fri, 31 Jul 2026 20:48:32 -0700 Subject: [PATCH 04/11] nvim: test that the config loads without errors --- install/nvim/tests/test_harness.lua | 20 ----------- install/nvim/tests/test_startup.lua | 52 +++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 20 deletions(-) delete mode 100644 install/nvim/tests/test_harness.lua create mode 100644 install/nvim/tests/test_startup.lua diff --git a/install/nvim/tests/test_harness.lua b/install/nvim/tests/test_harness.lua deleted file mode 100644 index a306d17..0000000 --- a/install/nvim/tests/test_harness.lua +++ /dev/null @@ -1,20 +0,0 @@ -local helpers = require "helpers" - -local child = helpers.new_child() -local T = helpers.new_set(child) -local eq = MiniTest.expect.equality - -T["child loads the real config"] = function() - eq(child.lua_get "vim.g.mapleader", ",") -end - -T["child has plugins on runtimepath"] = function() - eq(child.lua_get 'pcall(require, "fzf-lua")', true) -end - -T["child startup is error-free"] = function() - eq(child.lua_get "vim.v.errmsg", "") - eq(child.cmd_capture "messages", "") -end - -return T diff --git a/install/nvim/tests/test_startup.lua b/install/nvim/tests/test_startup.lua new file mode 100644 index 0000000..53409ed --- /dev/null +++ b/install/nvim/tests/test_startup.lua @@ -0,0 +1,52 @@ +local helpers = require "helpers" + +local child = helpers.new_child() +local T = helpers.new_set(child) +local eq = MiniTest.expect.equality + +-- init.lua uses bare `require` with no pcall. A plugin whose setup{} throws +-- prints an error and lets startup continue, so "nvim started" proves nothing. +-- These are the assertions that actually catch a half-applied config. +T["startup produces no errors"] = function() + eq(child.lua_get "vim.v.errmsg", "") + eq(child.cmd_capture "messages", "") +end + +T["every config module loaded"] = function() + local modules = { + "config.options", + "config.keymaps", + "config.autocmds", + "plugins", + "plugins.colorscheme", + "plugins.editing", + "plugins.navigation", + "plugins.git", + "plugins.syntax", + "plugins.markdown", + "plugins.completion", + "plugins.lsp", + "plugins.zk", + } + for _, mod in ipairs(modules) do + eq({ mod, child.lua_get("package.loaded[...] ~= nil", { mod }) }, { mod, true }) + end +end + +T["leader is set"] = function() + eq(child.lua_get "vim.g.mapleader", ",") +end + +T["colorscheme applied"] = function() + eq(child.lua_get "vim.g.colors_name", "kanagawa") +end + +T["options applied"] = function() + eq(child.lua_get "vim.o.winborder", "rounded") + eq(child.lua_get "vim.o.laststatus", 3) + eq(child.lua_get "vim.o.signcolumn", "yes") + eq(child.lua_get "vim.o.smartcase", true) + eq(child.lua_get "vim.o.showmode", false) +end + +return T From ec957e983a24555210456c2a87681cae9e9f8aa5 Mon Sep 17 00:00:00 2001 From: Junhyeok Ahn Date: Fri, 31 Jul 2026 20:53:24 -0700 Subject: [PATCH 05/11] nvim: document the module-loaded assertion's limits Co-Authored-By: Claude Opus 5 (1M context) --- install/nvim/tests/test_startup.lua | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/install/nvim/tests/test_startup.lua b/install/nvim/tests/test_startup.lua index 53409ed..cf10936 100644 --- a/install/nvim/tests/test_startup.lua +++ b/install/nvim/tests/test_startup.lua @@ -12,6 +12,11 @@ T["startup produces no errors"] = function() eq(child.cmd_capture "messages", "") end +-- LuaJIT leaves a non-nil sentinel in package.loaded while a module is loading, +-- even if the loader throws. This detects omitted `require` statements that silently +-- corrupt the config, but cannot pinpoint which module broke — it passes outright +-- if the terminal module (plugins.zk) throws. The "startup produces no errors" test +-- is the reliable guard against throwing modules. T["every config module loaded"] = function() local modules = { "config.options", From 2bc0cf676a8ed1e06eeab907ed55973f9818292e Mon Sep 17 00:00:00 2001 From: Junhyeok Ahn Date: Fri, 31 Jul 2026 20:57:07 -0700 Subject: [PATCH 06/11] nvim: contract tests for the plugin API surface the config uses cmp.setup is a callable table (setmetatable + __call) in nvim-cmp's own source, not a plain function, so it is checked with vim.is_callable instead of the generic type()=="function" helper used for every other API path. --- install/nvim/tests/test_contract.lua | 166 +++++++++++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 install/nvim/tests/test_contract.lua diff --git a/install/nvim/tests/test_contract.lua b/install/nvim/tests/test_contract.lua new file mode 100644 index 0000000..d396f8b --- /dev/null +++ b/install/nvim/tests/test_contract.lua @@ -0,0 +1,166 @@ +local helpers = require "helpers" + +local child = helpers.new_child() +local T = helpers.new_set(child) +local eq = MiniTest.expect.equality + +--- Assert every listed expression is a callable, naming the path on failure. +local function expect_functions(paths) + for _, path in ipairs(paths) do + eq({ path, child.lua_get("type(" .. path .. ")") }, { path, "function" }) + end +end + +T["editing: plugin APIs"] = function() + expect_functions { + 'require("conform").setup', + 'require("conform").format', + 'require("mini.ai").setup', + 'require("mini.surround").setup', + 'require("mini.statusline").setup', + 'require("mini.statusline").section_location', + 'require("guess-indent").setup', + 'require("Comment").setup', + 'require("neoscroll").setup', + } +end + +T["navigation: fzf-lua APIs"] = function() + expect_functions { + 'require("fzf-lua").setup', + 'require("fzf-lua").register_ui_select', + 'require("fzf-lua").actions.file_edit_or_qf', + 'require("fzf-lua").help_tags', + 'require("fzf-lua").keymaps', + 'require("fzf-lua").files', + 'require("fzf-lua").grep_cword', + 'require("fzf-lua").live_grep', + 'require("fzf-lua").buffers', + 'require("fzf-lua").lines', + 'require("fzf-lua").treesitter', + 'require("fzf-lua").quickfix', + 'require("fzf-lua").git_status', + } +end + +T["navigation: lsp picker APIs"] = function() + expect_functions { + 'require("fzf-lua").lsp_code_actions', + 'require("fzf-lua").lsp_implementations', + 'require("fzf-lua").lsp_references', + 'require("fzf-lua").lsp_definitions', + 'require("fzf-lua").lsp_document_diagnostics', + 'require("fzf-lua").lsp_document_symbols', + 'require("fzf-lua").lsp_workspace_symbols', + } +end + +T["navigation: flash and oil APIs"] = function() + expect_functions { + 'require("flash").setup', + 'require("flash").jump', + 'require("flash").treesitter', + 'require("flash").remote', + 'require("flash").treesitter_search', + 'require("flash").toggle', + 'require("oil").setup', + 'require("oil").toggle_float', + } +end + +T["navigation: harpoon APIs"] = function() + expect_functions { + 'require("harpoon").setup', + 'require("harpoon").ui.toggle_quick_menu', + 'require("harpoon"):list().add', + 'require("harpoon"):list().next', + 'require("harpoon"):list().prev', + 'require("harpoon"):list().select', + } +end + +T["completion: cmp and luasnip APIs"] = function() + -- cmp.setup is `setmetatable({ global = ..., filetype = ..., ... }, { __call + -- = ... })` in nvim-cmp's own source (lua/cmp/init.lua) -- a callable table, + -- not a plain function. This is nvim-cmp's own long-standing multi-purpose + -- setup design, not something the 0.11.1 -> 0.12.4 bump changed, and the + -- config's own `cmp.setup { ... }` call in plugins/completion.lua works + -- (proven by "startup produces no errors" passing). type()=="function" + -- is the wrong test for callability here, so this one path is checked with + -- vim.is_callable instead of folding it into expect_functions. + eq(child.lua_get 'vim.is_callable(require("cmp").setup)', true) + expect_functions { + 'require("cmp").mapping.preset.insert', + 'require("cmp").mapping.select_next_item', + 'require("cmp").mapping.select_prev_item', + 'require("cmp").mapping.scroll_docs', + 'require("cmp").mapping.confirm', + 'require("cmp").config.window.bordered', + 'require("luasnip").config.setup', + 'require("luasnip").lsp_expand', + 'require("luasnip").expand_or_locally_jumpable', + 'require("luasnip").locally_jumpable', + 'require("luasnip").expand_or_jump', + 'require("luasnip").jump', + 'require("nvim-autopairs").setup', + 'require("nvim-autopairs.completion.cmp").on_confirm_done', + } +end + +T["completion: cmp.event is subscribable"] = function() + eq(child.lua_get 'type(require("cmp").event.on)', "function") +end + +-- A `do ... end` block that dies partway registers no keymap, so one key per +-- block is a cheap proof that each ran to completion. +T["keymaps registered"] = function() + local keys = { + { "s", "n" }, -- flash + { "-", "n" }, -- oil + { "sf", "n" }, -- fzf-lua + { "ha", "n" }, -- harpoon + { "f", "n" }, -- conform + { "-", "n" }, -- oil float + } + for _, k in ipairs(keys) do + local got = child.lua_get("vim.fn.maparg(...) ~= ''", { k[1], k[2] }) + eq({ k[1], got }, { k[1], true }) + end +end + +T["bundled tools on PATH"] = function() + local tools = { + "stylua", + "lua-language-server", + "basedpyright", + "bash-language-server", + "clangd", + "rg", + "fd", + "fzf", + "yapf", + } + for _, t in ipairs(tools) do + eq({ t, child.lua_get("vim.fn.executable(...)", { t }) }, { t, 1 }) + end +end + +T["user commands defined"] = function() + local commands = { "Oil", "LspStart", "LspStop", "LspRestart", "LspLog", "LspInfo" } + for _, c in ipairs(commands) do + eq({ c, child.lua_get("vim.fn.exists(...)", { ":" .. c }) }, { c, 2 }) + end +end + +T["treesitter parsers available"] = function() + local langs = { + "bash", "cpp", "diff", "html", "lua", "luadoc", + "markdown", "markdown_inline", "python", "vim", "vimdoc", "yaml", + } + for _, l in ipairs(langs) do + local ok = child.lua_get("pcall(vim.treesitter.language.add, ...)", { l }) + eq({ l, ok }, { l, true }) + end +end + +return T From 145bf89c758393629bf2e077c37c58a877282855 Mon Sep 17 00:00:00 2001 From: Junhyeok Ahn Date: Fri, 31 Jul 2026 21:33:19 -0700 Subject: [PATCH 07/11] nvim: teach lua_ls about the vim global lsp.lua builds server configs from scratch via vim.lsp.config(), so it never picked up the runtime/workspace.library snippet that nvim-lspconfig documents as opt-in. Without it lua_ls has no idea `vim` exists: every config file reports "Undefined global `vim`" and offers no completion on any vim.* path. Found while writing the behavioral test for LSP completion. Co-Authored-By: Claude Opus 5 (1M context) --- install/nvim/config/lua/plugins/lsp.lua | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/install/nvim/config/lua/plugins/lsp.lua b/install/nvim/config/lua/plugins/lsp.lua index 4d6df23..b8a20f1 100644 --- a/install/nvim/config/lua/plugins/lsp.lua +++ b/install/nvim/config/lua/plugins/lsp.lua @@ -101,6 +101,19 @@ local servers = { telemetry = { enable = false, }, + -- Without this, lua_ls has no idea `vim` exists (it shows up as an + -- "Undefined global" diagnostic) and offers zero completions on any + -- vim.* path, which defeats the point of running it on this config. + runtime = { + version = "LuaJIT", + }, + workspace = { + checkThirdParty = false, + library = { + vim.env.VIMRUNTIME, + vim.api.nvim_get_runtime_file("lua/lspconfig", false)[1], + }, + }, }, }, }, From f974bc75ebc15a2622ec50d2c27158309eb06475 Mon Sep 17 00:00:00 2001 From: Junhyeok Ahn Date: Fri, 31 Jul 2026 21:33:20 -0700 Subject: [PATCH 08/11] nvim: behavioral tests for formatting, harpoon, treesitter, oil, lsp Co-Authored-By: Claude Opus 5 (1M context) --- install/nvim/tests/test_behavior.lua | 94 ++++++++++++++++++++++++++++ 1 file changed, 94 insertions(+) create mode 100644 install/nvim/tests/test_behavior.lua diff --git a/install/nvim/tests/test_behavior.lua b/install/nvim/tests/test_behavior.lua new file mode 100644 index 0000000..a46746a --- /dev/null +++ b/install/nvim/tests/test_behavior.lua @@ -0,0 +1,94 @@ +local helpers = require "helpers" + +local child = helpers.new_child() +local T = helpers.new_set(child) +local eq = MiniTest.expect.equality + +T["conform formats lua with stylua"] = function() + child.lua [[ + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "local x = 1" }) + vim.bo.filetype = "lua" + require("conform").format { bufnr = 0, async = false, lsp_format = "never" } + ]] + eq(child.lua_get "vim.api.nvim_buf_get_lines(0, 0, 1, false)[1]", "local x = 1") +end + +T["conform formats python with yapf"] = function() + child.lua [[ + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "x = 1" }) + vim.bo.filetype = "python" + require("conform").format { bufnr = 0, async = false, lsp_format = "never" } + ]] + eq(child.lua_get "vim.api.nvim_buf_get_lines(0, 0, 1, false)[1]", "x = 1") +end + +T["harpoon list round-trips"] = function() + child.lua [[ + _G.h = require("harpoon") + vim.cmd.edit("install/nvim/flake.nix") + _G.h:list():add() + ]] + eq(child.lua_get "_G.h:list():length()", 1) + eq(child.lua_get '_G.h:list():get(1).value ~= nil', true) +end + +T["treesitter highlights a cpp buffer"] = function() + child.lua [[ + vim.api.nvim_buf_set_lines(0, 0, -1, false, { "int main() { return 0; }" }) + vim.bo.filetype = "cpp" + local parser = vim.treesitter.get_parser(0, "cpp") + local tree = parser:parse()[1] + local query = vim.treesitter.query.get("cpp", "highlights") + _G.captures = 0 + for _ in query:iter_captures(tree:root(), 0, 0, -1) do + _G.captures = _G.captures + 1 + end + ]] + MiniTest.expect.no_equality(child.lua_get "_G.captures", 0) +end + +T["oil lists a directory"] = function() + child.lua [[ vim.cmd.edit("oil://" .. vim.fn.getcwd() .. "/install/nvim") ]] + child.lua [[ vim.wait(3000, function() return vim.api.nvim_buf_line_count(0) > 1 end) ]] + local lines = child.lua_get "table.concat(vim.api.nvim_buf_get_lines(0, 0, -1, false), '\\n')" + eq(lines:find "flake.nix" ~= nil, true) +end + +T["lua_ls attaches and completes"] = function() + child.lua [[ + vim.cmd.edit("install/nvim/config/lua/config/options.lua") + vim.bo.filetype = "lua" + ]] + local attached = child.lua_get [[ + vim.wait(30000, function() + return #vim.lsp.get_clients { bufnr = 0, name = "lua_ls" } > 0 + end, 200) + ]] + eq(attached, true) + + -- A single completion request fired right after attach reliably comes + -- back empty: lua_ls answers immediately from whatever state it has, and + -- learning that `have_nerd_font`/`clipboard` are members of vim.g takes a + -- workspace-wide scan of this repo's own lua files that (measured here) + -- finishes ~10s after attach, not before. So this polls the same request + -- instead of waiting on one in-flight callback - the assertion below is + -- unchanged, only how long we give the server to actually be ready is. + child.lua [[ + _G.items = {} + vim.wait(20000, function() + local items + vim.lsp.buf_request(0, "textDocument/completion", { + textDocument = vim.lsp.util.make_text_document_params(), + position = { line = 0, character = 6 }, + }, function(_, result) + items = result and (result.items or result) or {} + end) + vim.wait(1000, function() return items ~= nil end, 50) + _G.items = items or {} + return #_G.items > 0 + end, 300) + ]] + MiniTest.expect.no_equality(child.lua_get "#_G.items", 0) +end + +return T From c75aa71f5d1827490204b6e32be7866db9fde314 Mon Sep 17 00:00:00 2001 From: Junhyeok Ahn Date: Fri, 31 Jul 2026 21:36:43 -0700 Subject: [PATCH 09/11] ci: run the nvim test suite, drop verify-nvim.lua Replace the old headless smoke-test steps with `nix run ./install/nvim#test`, which now covers everything verify-nvim.lua checked plus more. Also widen the yapf-format test's conform timeout so a cold first process spawn (the norm on every CI runner, not an edge case) can't be mistaken for a broken formatter. Co-Authored-By: Claude Opus 5 (1M context) --- .github/scripts/verify-nvim.lua | 52 ---------------------- .github/workflows/test-install-scripts.yml | 13 +----- install/nvim/tests/test_behavior.lua | 15 ++++++- 3 files changed, 16 insertions(+), 64 deletions(-) delete mode 100644 .github/scripts/verify-nvim.lua diff --git a/.github/scripts/verify-nvim.lua b/.github/scripts/verify-nvim.lua deleted file mode 100644 index d556379..0000000 --- a/.github/scripts/verify-nvim.lua +++ /dev/null @@ -1,52 +0,0 @@ -local plugins = { - "fzf-lua", - "oil", - "harpoon", - "cmp", - "conform", - "flash", - "gitsigns", - "todo-comments", - "render-markdown", -} - -for _, mod in ipairs(plugins) do - local ok, err = pcall(require, mod) - if not ok then - io.stderr:write("FAIL plugin " .. mod .. ": " .. tostring(err) .. "\n") - vim.cmd("cquit 1") - end -end - -local tools = { - "stylua", - "lua-language-server", - "basedpyright", - "bash-language-server", - "clangd", - "rg", - "fd", - "fzf", -} - -for _, t in ipairs(tools) do - if vim.fn.executable(t) == 0 then - io.stderr:write("FAIL tool not on PATH: " .. t .. "\n") - vim.cmd("cquit 1") - end -end - -local config_checks = { - { "mapleader", function() return vim.g.mapleader == "," end }, - { ":Oil command", function() return vim.fn.exists(":Oil") == 2 end }, - { "kanagawa colorscheme", function() return vim.g.colors_name == "kanagawa" end }, -} - -for _, check in ipairs(config_checks) do - if not check[2]() then - io.stderr:write("FAIL config check: " .. check[1] .. "\n") - vim.cmd("cquit 1") - end -end - -print("verify-nvim: ok") diff --git a/.github/workflows/test-install-scripts.yml b/.github/workflows/test-install-scripts.yml index 87f81de..bef93e3 100644 --- a/.github/workflows/test-install-scripts.yml +++ b/.github/workflows/test-install-scripts.yml @@ -34,7 +34,6 @@ jobs: filters: | nvim: - 'install/nvim/**' - - '.github/scripts/verify-nvim.lua' - '.github/workflows/test-install-scripts.yml' kitty: - 'install/kitty.sh' @@ -82,17 +81,9 @@ jobs: which nvim nvim --version | head -5 - - name: Test nvim startup + - name: Run nvim test suite shell: bash - run: | - export PATH="$HOME/.nix-profile/bin:$PATH" - nvim --headless "+qa" - - - name: Verify plugins and bundled tools load - shell: bash - run: | - export PATH="$HOME/.nix-profile/bin:$PATH" - nvim --headless -c "luafile .github/scripts/verify-nvim.lua" +qa + run: nix run ./install/nvim#test test-kitty-install: needs: changes diff --git a/install/nvim/tests/test_behavior.lua b/install/nvim/tests/test_behavior.lua index a46746a..929d22e 100644 --- a/install/nvim/tests/test_behavior.lua +++ b/install/nvim/tests/test_behavior.lua @@ -17,7 +17,20 @@ T["conform formats python with yapf"] = function() child.lua [[ vim.api.nvim_buf_set_lines(0, 0, -1, false, { "x = 1" }) vim.bo.filetype = "python" - require("conform").format { bufnr = 0, async = false, lsp_format = "never" } + -- conform's default timeout_ms is 1000. That's tight for the *first* + -- ever process spawn out of a freshly restarted child: process creation + -- plus python/yapf interpreter startup can eat most or all of a second + -- before yapf has produced a single byte, with zero relation to whether + -- yapf actually works. That's not a rare edge case here - pre_case + -- restarts the child before every test (see helpers.lua), so this is a + -- cold first spawn on every single run, local or CI, and CI runners in + -- particular have no warm OS/page cache to shorten it further. Give the + -- call a generous, bounded budget to absorb that one-time startup cost. + -- This does not paper over a real formatter regression: if yapf is + -- actually broken (bad output, crash, or a genuine hang) the buffer + -- still won't equal the expected result below, and the timeout still + -- bounds how long a truly hung process can block the suite. + require("conform").format { bufnr = 0, async = false, lsp_format = "never", timeout_ms = 15000 } ]] eq(child.lua_get "vim.api.nvim_buf_get_lines(0, 0, 1, false)[1]", "x = 1") end From 3139cefecf2e8f509c95b017a2026da1cf87b89a Mon Sep 17 00:00:00 2001 From: Junhyeok Ahn Date: Fri, 31 Jul 2026 22:41:35 -0700 Subject: [PATCH 10/11] nvim: close two false-green gaps in the test suite - test_contract.lua: cover the zk.lua pcall-swallowed-require failure mode by asserting its z* keymaps register (proof the file ran past the pcall), plus the same class of gap for lsp.lua's cmp_nvim_lsp pcall. - test_behavior.lua: the LSP completion test only asserted item count, which stayed green even with commit 145bf89's vim-global fix reverted. Replaced with an assertion on diagnostic content ("Undefined global `vim`") after empirically verifying (against the real pinned lua-language-server) that the originally suggested completion-label assertion never fires even when correctly fixed, which would have made the suite permanently red. Verified by injection: breaking zk.lua's require and reverting 145bf89 both turn the suite red; restored, it's 22/22 green. --- install/nvim/tests/test_behavior.lua | 62 ++++++++++++++++++++++++++++ install/nvim/tests/test_contract.lua | 26 ++++++++++++ 2 files changed, 88 insertions(+) diff --git a/install/nvim/tests/test_behavior.lua b/install/nvim/tests/test_behavior.lua index 929d22e..42a37d3 100644 --- a/install/nvim/tests/test_behavior.lua +++ b/install/nvim/tests/test_behavior.lua @@ -102,6 +102,68 @@ T["lua_ls attaches and completes"] = function() end, 300) ]] MiniTest.expect.no_equality(child.lua_get "#_G.items", 0) + + -- The check above only proves *some* completion came back, which is not + -- proof of anything -- see the comment on commit 145bf89 for how this bit + -- the suite before: without Lua.runtime/Lua.workspace.library, lua_ls has + -- no idea `vim` exists, and completion at this exact position (right after + -- "vim.g." in config/options.lua) fell back to scraping this repo's own + -- lua files for the name "g" (`clipboard`, `mapleader`, `maplocalleader`) + -- -- 3 non-empty items, zero `vim` knowledge, and the old count-only + -- assertion passed anyway. + -- + -- The obvious fix is "poll until `have_nerd_font` (a real vim.g member set + -- by config/options.lua) shows up in the completion labels instead of + -- until the list is merely non-empty" -- and that's what a first version + -- of this test did. It was reverted: probing it directly against this + -- flake's pinned lua-language-server (by editing this exact file/position + -- through the real wrapped nvim, both with and without 145bf89, waiting up + -- to 90s) showed completion at "vim.g." never offers `have_nerd_font` even + -- with the fix correctly applied -- lua_ls resolves `vim.g`'s type from + -- the runtime library as an index-signature-only table (any -> any, no + -- static field list), so once it has real type info it stops doing the + -- fallback name-scrape that produced `clipboard`/`mapleader` in the first + -- place. Asserting on a completion label here would fail red even when + -- everything is correct, which is exactly the false-negative Finding 2 was + -- raised to prevent -- so this does not do that. + -- + -- What IS a real, verified content signal: 145bf89's own commit message + -- names the directly observable symptom -- "Undefined global `vim`" on + -- every line that touches vim.*. Probing (same method as above, sampling + -- vim.diagnostic.get(0) once a second) showed this is fast and stable: 25 + -- such diagnostics on this exact buffer by 1s after attach without the + -- fix, 0 by 1s with it, unchanged for the next 20s in both cases -- unlike + -- completion, it does not depend on the multi-second workspace-wide scan. + -- + -- Two ways of turning that into "wait, then assert" were tried and + -- rejected because they never fire on a correctly-fixed config (i.e. they + -- ran out the full budget and reported false negatives): the + -- vim.diagnostic-level DiagnosticChanged autocmd, which in practice + -- doesn't fire here when there's nothing to report, and hooking the raw + -- textDocument/publishDiagnostics handler, which here fired for several + -- other files lua_ls happened to be background-diagnosing but never for + -- this one within the budget. So this polls vim.diagnostic.get(0) + -- directly instead, exiting the instant the bad diagnostic shows up + -- (fast, correct red); if it never shows up in 5s -- 5x the measured + -- worst case, and still well under this test's other budgets -- that's + -- treated as a real, settled "no `vim`-undefined diagnostic", not an + -- unsettled one. + local clean = child.lua_get [[ + (function() + local bad = false + vim.wait(5000, function() + for _, d in ipairs(vim.diagnostic.get(0)) do + if d.message:find "Undefined global" and d.message:find "vim" then + bad = true + return true + end + end + return false + end, 100) + return not bad + end)() + ]] + eq(clean, true) end return T diff --git a/install/nvim/tests/test_contract.lua b/install/nvim/tests/test_contract.lua index d396f8b..6c2e20c 100644 --- a/install/nvim/tests/test_contract.lua +++ b/install/nvim/tests/test_contract.lua @@ -90,6 +90,15 @@ T["completion: cmp and luasnip APIs"] = function() -- vim.is_callable instead of folding it into expect_functions. eq(child.lua_get 'vim.is_callable(require("cmp").setup)', true) expect_functions { + -- plugins/lsp.lua also pcalls this: `local ok, cmp_nvim_lsp = pcall(require, + -- "cmp_nvim_lsp")`. Unlike zk.lua, a failed require there doesn't stop + -- lsp.lua from running (no early return), so it can't be caught by a + -- keymap check -- it silently just never merges cmp's capabilities into + -- any LSP client, which is invisible to this test suite otherwise. This + -- is a pure-Lua module (part of the cmp plugin family, no external + -- binary), so asserting it loads is safe on a runner without the `zk` + -- binary or any language server installed. + 'require("cmp_nvim_lsp").default_capabilities', 'require("cmp").mapping.preset.insert', 'require("cmp").mapping.select_next_item', 'require("cmp").mapping.select_prev_item', @@ -121,6 +130,23 @@ T["keymaps registered"] = function() { "ha", "n" }, -- harpoon { "f", "n" }, -- conform { "-", "n" }, -- oil float + -- plugins/zk.lua opens with `local ok, zk = pcall(require, "zk"); if not + -- ok then return end`. If the require ever breaks (e.g. zk-nvim renamed + -- or dropped by a nixpkgs bump), that pcall swallows the error and the + -- whole file returns early -- no keymaps, no error, nothing else in this + -- suite notices (see test_startup.lua's "every config module loaded" + -- comment). These map() calls are the last statements in the file, so + -- any one of them being registered proves the require succeeded and the + -- file ran to completion. This does not depend on the `zk` binary itself + -- being on PATH (deliberately not bundled in the flake, see README) -- + -- zk.setup() and vim.keymap.set() are pure Lua/zk-nvim calls that don't + -- shell out. + { "zn", "n" }, -- zk: new note + { "zo", "n" }, -- zk: open notes + { "zz", "n" }, -- zk: open last modified note + { "zm", "n" }, -- zk: new meeting note + { "zd", "n" }, -- zk: open/create daily note + { "zt", "n" }, -- zk: tags } for _, k in ipairs(keys) do local got = child.lua_get("vim.fn.maparg(...) ~= ''", { k[1], k[2] }) From 80612d1bc87e3a9cd78ab4dd1497d36d6716cde1 Mon Sep 17 00:00:00 2001 From: Junhyeok Ahn Date: Fri, 31 Jul 2026 22:47:57 -0700 Subject: [PATCH 11/11] nvim: drop the lua_ls settings comment The commit that added these settings (145bf89) already records why, and the rest of this file carries no commentary. Co-Authored-By: Claude Opus 5 (1M context) --- install/nvim/config/lua/plugins/lsp.lua | 3 --- 1 file changed, 3 deletions(-) diff --git a/install/nvim/config/lua/plugins/lsp.lua b/install/nvim/config/lua/plugins/lsp.lua index b8a20f1..02151e4 100644 --- a/install/nvim/config/lua/plugins/lsp.lua +++ b/install/nvim/config/lua/plugins/lsp.lua @@ -101,9 +101,6 @@ local servers = { telemetry = { enable = false, }, - -- Without this, lua_ls has no idea `vim` exists (it shows up as an - -- "Undefined global" diagnostic) and offers zero completions on any - -- vim.* path, which defeats the point of running it on this config. runtime = { version = "LuaJIT", },