From 7881606c2e147e593d37aeaa76a447bf77616337 Mon Sep 17 00:00:00 2001 From: myuron Date: Sat, 17 Oct 2026 13:30:29 +0900 Subject: [PATCH 1/5] feat(hm): add env.check, a headless startup check for the wrapped neovim Co-Authored-By: Claude Opus 5.5 --- lua/nvimx/startup-check.lua | 159 +++++++++++++++++++++++++++++++++++ nix/home-manager/default.nix | 28 +++++- nix/lib/check.nix | 71 ++++++++++++++++ nix/lib/default.nix | 2 + nix/lib/make-env.nix | 16 ++++ 5 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 lua/nvimx/startup-check.lua create mode 100644 nix/lib/check.nix diff --git a/lua/nvimx/startup-check.lua b/lua/nvimx/startup-check.lua new file mode 100644 index 0000000..8ed8593 --- /dev/null +++ b/lua/nvimx/startup-check.lua @@ -0,0 +1,159 @@ +-- nvimx startup check driver (#79) +-- +-- Usage (nix/lib/check.nix runs this inside the sandbox): +-- NVIMX_CHECK_FARM= NVIMX_CHECK_IGNORE= NVIMX_CHECK_SENTINEL= \ +-- nvim --headless --cmd "luafile lua/nvimx/startup-check.lua" +-- +-- Runs after the wrapper's own bootstrap --cmd, so package.preload["lazy"] is already in place. +-- Collects every error neovim actually shows (ext_messages), plus ERROR notifications even when a +-- plugin has replaced vim.notify, then loads every farm plugin on VimEnter (a lazy-loaded plugin's +-- broken `config` is otherwise never run) and fails if anything was recorded. On success it writes +-- the sentinel file: a config that quits during startup exits 0 without reaching that point, and +-- the runner must not take that for a pass. +-- +-- v:errmsg is deliberately not a signal: `silent!` sets it without showing anything. + +local farm = vim.env.NVIMX_CHECK_FARM +local ignore = vim.json.decode(vim.env.NVIMX_CHECK_IGNORE or "[]") +local sentinel = vim.env.NVIMX_CHECK_SENTINEL +local errors = {} +local function record(source, msg) + errors[#errors + 1] = { source = source, msg = tostring(msg) } +end + +local ERROR_KINDS = { emsg = true, echoerr = true, lua_error = true, rpc_error = true } +vim.ui_attach(vim.api.nvim_create_namespace("nvimx-check"), { ext_messages = true }, function(event, kind, content) + if event == "msg_show" and ERROR_KINDS[kind] then + local t = {} + for _, c in ipairs(content) do + t[#t + 1] = c[2] + end + record(kind, table.concat(t)) + end +end) + +-- notify guard, for plugins that replace vim.notify (nvim-notify, snacks, noice) and would swallow +-- an ERROR into a float. Every function assigned to vim.notify is stored wrapped and reads return +-- that wrapped function, so `local orig = vim.notify` captures the previous function, never a +-- shared wrapper that would loop back to the current one (a single shared wrapper hangs on the +-- common `local orig = vim.notify; vim.notify = function(...) return orig(...) end` chain). +-- depth: only the outermost call of a chain records, so a chained ERROR counts once. +-- Trade-off: an ERROR is recorded before the user's own filter runs, so one a filter deliberately +-- drops still fails; ignoreErrors is the way out. Limitation: rawget(vim, "notify") sees nil. +-- If vim's metatable is not shaped as expected the guard is skipped; UI collection still works. +local ERROR = vim.log.levels.ERROR +local depth = 0 +local function wrap(f) + return function(msg, level, opts) + if depth == 0 and type(level) == "number" and level >= ERROR then + record("vim.notify", msg) + end + depth = depth + 1 + local r = { pcall(f, msg, level, opts) } + depth = depth - 1 + if not r[1] then + error(r[2], 0) + end + return unpack(r, 2, table.maxn(r)) + end +end +local mt = getmetatable(vim) +if mt and type(mt.__index) == "function" and mt.__newindex == nil then + local current = wrap(rawget(vim, "notify")) + rawset(vim, "notify", nil) + local index = mt.__index + mt.__index = function(t, k) + if k == "notify" then + return current + end + return index(t, k) + end + mt.__newindex = function(t, k, v) + if k == "notify" then + current = type(v) == "function" and wrap(v) or v + return + end + rawset(t, k, v) + end +end + +local skip_msgs = {} +local function finish() + vim.cmd.redraw() + vim.schedule(function() + local notified = {} + for _, e in ipairs(errors) do + if e.source == "vim.notify" then + notified[#notified + 1] = e.msg + end + end + local filters = vim.list_extend(vim.list_extend({}, ignore), skip_msgs) + local failed = {} + for _, e in ipairs(errors) do + local drop = false + -- the builtin notify echoes an ERROR as echoerr: the same error, already recorded + if e.source ~= "vim.notify" then + for _, m in ipairs(notified) do + if e.msg:find(m, 1, true) then + drop = true + break + end + end + end + for _, s in ipairs(filters) do + if e.msg:find(s, 1, true) then + drop = true + break + end + end + if not drop then + failed[#failed + 1] = e + end + end + if #failed == 0 then + io.stderr:write("[nvimx] check passed\n") + if sentinel then + local f = assert(io.open(sentinel, "w")) + f:close() + end + vim.cmd("qall!") + else + for _, e in ipairs(failed) do + io.stderr:write("[nvimx] check: " .. e.source .. ": " .. e.msg .. "\n") + end + vim.cmd("cquit! 1") + end + end) +end + +vim.api.nvim_create_autocmd("VimEnter", { + once = true, + callback = function() + vim.schedule(function() + if not vim.g.lazy_did_setup then + record("nvimx", "require('lazy').setup() was never called -- is configDir the directory holding init.lua?") + else + local lazy = require("lazy") + local names = {} + for _, p in ipairs(lazy.plugins()) do + if p.name ~= "lazy.nvim" then + if p.dir and vim.startswith(p.dir, farm .. "/") then + if not p._.installed then + record("nvimx", p.name .. " is in the spec but has no farm entry -- re-run nvimx-lock") + elseif p._.cond ~= false then + names[#names + 1] = p.name + end + else + skip_msgs[#skip_msgs + 1] = "Plugin " .. p.name .. " is not installed" + end + end + end + local ok, err = pcall(lazy.load, { plugins = names }) + if not ok then + record("lazy.load", err) + end + end + vim.defer_fn(finish, 1000) + end) + end, +}) diff --git a/nix/home-manager/default.nix b/nix/home-manager/default.nix index 65178c8..53091f5 100644 --- a/nix/home-manager/default.nix +++ b/nix/home-manager/default.nix @@ -438,6 +438,25 @@ in }; }; + check = { + ignoreErrors = lib.mkOption { + type = lib.types.listOf lib.types.str; + default = [ ]; + example = [ "copilot" ]; + description = '' + Errors that env.check tolerates: any error whose whole message contains one of + these strings is dropped. Plain substrings, not Lua patterns, matched against the + message as it shows in the build log (which can carry a prefix such as + `Error in :`). + + For plugins that need the network at startup, prefer fixing the cause + (treesitter.grammars instead of :TSInstall) or switching the plugin off for the + check: NVIMX_CHECK=1 is set in neovim's environment, so + `cond = not vim.env.NVIMX_CHECK` works. + ''; + }; + }; + lock = { installCommand = lib.mkOption { type = lib.types.bool; @@ -475,7 +494,12 @@ in description = '' The result of makeEnv (farm / bootstrap / wrapped / pluginDrvs / unknownPluginNames / treesitterWithoutPlugin / devDirs / devPatterns / - unknownDevPluginNames / unusedPython3Packages / hasLock). + unknownDevPluginNames / unusedPython3Packages / hasLock), plus `check`: a derivation that starts the wrapped + neovim headless and offline with configDir, loads every plugin in the farm and fails on + any startup error. Wire it into your flake with + `checks.''${system}.nvim = .config.programs.nvimx.env.check;`. It needs + configDir (accessing it without one is an evaluation error). `dev` plugins are + skipped. See `check.ignoreErrors`. By default it is built automatically from the options above. This is an escape hatch for advanced users who want to supply it directly. Setting it replaces the makeEnv call, so every option that only feeds makeEnv is @@ -548,6 +572,8 @@ in devPlugins devPath devPatterns + configDir + check ; } ); diff --git a/nix/lib/check.nix b/nix/lib/check.nix new file mode 100644 index 0000000..ceecdf2 --- /dev/null +++ b/nix/lib/check.nix @@ -0,0 +1,71 @@ +# env.check (#79): start the exact wrapped neovim headless, offline, with the user's config, +# load every plugin in the farm and fail on any startup error. The driver is +# lua/nvimx/startup-check.lua; this file is the sandbox around it. +# +# The runner is exposed as passthru.runner so tests can run the very same script and assert +# that it fails: building the check only ever shows that it passes. +{ pkgs }: +{ + env, + configDir, + ignoreErrors ? [ ], +}: +let + inherit (pkgs) lib; + # A path literal is copied to the store under a stable name; a derivation (writeTextDir, ...) + # passes through as its store path. An absolute string is not accepted: in pure mode Nix + # refuses it at evaluation with its own clear error, rather than a confusing cp failure inside + # the sandbox. + src = + if builtins.hasContext (toString configDir) then + toString configDir + else + builtins.path { + path = configDir; + name = "nvimx-check-config"; + }; + + runner = pkgs.writeShellScript "nvimx-check" '' + set -eu + tmp=$(mktemp -d) + export HOME="$tmp/home" XDG_CONFIG_HOME="$tmp/config" XDG_DATA_HOME="$tmp/data" \ + XDG_STATE_HOME="$tmp/state" XDG_CACHE_HOME="$tmp/cache" + mkdir -p "$HOME" "$XDG_CONFIG_HOME" "$XDG_DATA_HOME/nvim/lazy" "$XDG_STATE_HOME" "$XDG_CACHE_HOME" + # A writable copy: the store copy is read-only and plugins may write into their config dir. + cp -R ${src} "$XDG_CONFIG_HOME/nvim" + chmod -R u+w "$XDG_CONFIG_HOME/nvim" + # What the hm module's xdg.dataFile does, so the standard lazy bootstrap snippet never clones. + ln -s ${env.farm}/lazy.nvim "$XDG_DATA_HOME/nvim/lazy/lazy.nvim" + + # rc is captured explicitly so that set -e does not abort before it is mapped below. + # -k: a busy-looping config ignores SIGTERM, so it needs a SIGKILL behind it. + rc=0 + NVIMX_CHECK=1 \ + NVIMX_CHECK_FARM=${env.farm} \ + NVIMX_CHECK_IGNORE=${lib.escapeShellArg (builtins.toJSON ignoreErrors)} \ + NVIMX_CHECK_SENTINEL="$tmp/passed" \ + ${pkgs.coreutils}/bin/timeout -k 10 120 ${env.wrapped}/bin/nvim --headless \ + --cmd "luafile ${../../lua/nvimx/startup-check.lua}" < /dev/null || rc=$? + + case $rc in + 124 | 137) + echo "[nvimx] check: timed out after 120s" >&2 + exit 1 + ;; + 0) + if [ ! -e "$tmp/passed" ]; then + echo "[nvimx] check: neovim exited before the check finished (something ran :qa / :cq during startup)" >&2 + exit 1 + fi + ;; + *) + # the driver already printed why + exit "$rc" + ;; + esac + ''; +in +pkgs.runCommand "nvimx-check" { passthru = { inherit runner; }; } '' + ${runner} + touch $out +'' diff --git a/nix/lib/default.nix b/nix/lib/default.nix index 7afe160..2bda77a 100644 --- a/nix/lib/default.nix +++ b/nix/lib/default.nix @@ -8,6 +8,8 @@ resolvePlugin = import ./resolve-plugin.nix { inherit pkgs; }; # nvim-treesitter + grammars, merged into one derivation mkTreesitter = import ./treesitter.nix { inherit pkgs; }; + # Headless startup check of a wrapped neovim (#79); makeEnv exposes it as env.check + mkCheck = import ./check.nix { inherit pkgs; }; buildNetwork = import ./build-network.nix { inherit (pkgs) lib; }; lockApp = import ./lock-app.nix { inherit pkgs lazyNvimSeed; }; } diff --git a/nix/lib/make-env.nix b/nix/lib/make-env.nix index a8a5b6b..821416f 100644 --- a/nix/lib/make-env.nix +++ b/nix/lib/make-env.nix @@ -40,6 +40,10 @@ # Substrings of a plugin's url that route it to / (#74), like lazy's # dev.patterns. Flat for the same reason as the two above. devPatterns ? [ ], + # Same as the module's programs.nvimx.configDir; only `check` reads it. + configDir ? null, + # Same shape as programs.nvimx.check + check ? { }, }: let inherit (pkgs) lib; @@ -56,6 +60,7 @@ let mkTreesitter = import ./treesitter.nix { inherit pkgs; }; mkBootstrap = import ./bootstrap.nix { inherit pkgs; }; mkWrapper = import ./wrapper.nix { inherit pkgs; }; + mkCheck = import ./check.nix { inherit pkgs; }; lazyNvimDrv = mkPluginDrv { name = "lazy.nvim"; @@ -184,6 +189,17 @@ let }; in { + # Lazy on purpose: nothing reads it unless asked, so a null configDir only throws for whoever + # asks for the check. + check = + if configDir == null then + throw "nvimx: env.check needs a config directory; set programs.nvimx.configDir, or call lib.mkCheck { env; configDir; } directly" + else + mkCheck { + env = { inherit farm wrapped; }; + inherit configDir; + ignoreErrors = check.ignoreErrors or [ ]; + }; inherit farm bootstrap From 356bf644c9ea3f9ae2a00ff67a042b49f12a3bff Mon Sep 17 00:00:00 2001 From: myuron Date: Sat, 17 Oct 2026 13:30:29 +0900 Subject: [PATCH 2/5] test(hm): prove env.check passes on a good config and fails on broken ones Co-Authored-By: Claude Opus 5.5 --- tests/checks/default.nix | 2 + tests/checks/hm-module-startup-check.nix | 29 ++++++++++ tests/checks/scripts/startup-check.sh | 38 +++++++++++++ tests/checks/startup-check.nix | 53 +++++++++++++++++++ .../startup-check/chain-error/init.lua | 21 ++++++++ .../startup-check/chain-warn/init.lua | 25 +++++++++ .../startup-check/early-quit/init.lua | 13 +++++ .../startup-check/init-error/init.lua | 7 +++ .../startup-check/lazy-config-error/init.lua | 14 +++++ tests/fixtures/startup-check/no-lazy/init.lua | 2 + .../startup-check/notify-error/init.lua | 18 +++++++ .../startup-check/stale-lock/init.lua | 9 ++++ .../fixtures/startup-check/tolerated/init.lua | 11 ++++ 13 files changed, 242 insertions(+) create mode 100644 tests/checks/hm-module-startup-check.nix create mode 100644 tests/checks/scripts/startup-check.sh create mode 100644 tests/checks/startup-check.nix create mode 100644 tests/fixtures/startup-check/chain-error/init.lua create mode 100644 tests/fixtures/startup-check/chain-warn/init.lua create mode 100644 tests/fixtures/startup-check/early-quit/init.lua create mode 100644 tests/fixtures/startup-check/init-error/init.lua create mode 100644 tests/fixtures/startup-check/lazy-config-error/init.lua create mode 100644 tests/fixtures/startup-check/no-lazy/init.lua create mode 100644 tests/fixtures/startup-check/notify-error/init.lua create mode 100644 tests/fixtures/startup-check/stale-lock/init.lua create mode 100644 tests/fixtures/startup-check/tolerated/init.lua diff --git a/tests/checks/default.nix b/tests/checks/default.nix index cb9f8ac..f35d69a 100644 --- a/tests/checks/default.nix +++ b/tests/checks/default.nix @@ -58,4 +58,6 @@ pkgs.lib.genAttrs [ "extractor-dev-opts" "e2e-offline" "demo" + "startup-check" + "hm-module-startup-check" ] (name: import (./. + "/${name}.nix") checkArgs) diff --git a/tests/checks/hm-module-startup-check.nix b/tests/checks/hm-module-startup-check.nix new file mode 100644 index 0000000..de3c772 --- /dev/null +++ b/tests/checks/hm-module-startup-check.nix @@ -0,0 +1,29 @@ +# The module's pass-through to env.check (#79). mkHmCheck asserts nothing about it, and makeEnv's +# new formals have defaults, so a dropped configDir or check.ignoreErrors would go unnoticed. +# This builds only if both reach makeEnv: without configDir env.check throws, without +# ignoreErrors the notify-error fixture fails at runtime. Same module setup as +# checks.extra-wrapper-args' moduleWrapped. +{ + pkgs, + self, + home-manager, + ... +}: +(home-manager.lib.homeManagerConfiguration { + inherit pkgs; + modules = [ + self.homeModules.nvimx + { + # Same values as mkHmCheck. Only .config is read. + home.username = "nvimx-test"; + home.homeDirectory = "/home/nvimx-test"; + home.stateVersion = "25.05"; + programs.nvimx = { + enable = true; + configDir = ../fixtures/startup-check/notify-error; + lockDir = ../fixtures/basic-config/nvimx-lock; + check.ignoreErrors = [ "failed to download server" ]; + }; + } + ]; +}).config.programs.nvimx.env.check diff --git a/tests/checks/scripts/startup-check.sh b/tests/checks/scripts/startup-check.sh new file mode 100644 index 0000000..fc91b4b --- /dev/null +++ b/tests/checks/scripts/startup-check.sh @@ -0,0 +1,38 @@ +# shellcheck shell=bash disable=SC2154 +# Body of checks.startup-check, sourced by the runCommand in ../startup-check.nix. +# The *Runner variables come from that derivation's env; each must fail with its message. +export HOME=$TMPDIR + +# expect_fail : the runner exits non-zero and says why on stderr. +expect_fail() { + if "$2" 2>"$1.log"; then + echo "$1: the check must fail" >&2 + exit 1 + fi + if ! grep -qF -- "$3" "$1.log"; then + echo "$1: expected '$3' in:" >&2 + cat "$1.log" >&2 + exit 1 + fi +} + +expect_fail init-error "$initErrorRunner" 'nvimx-check: init boom' +expect_fail lazy-config-error "$lazyConfigErrorRunner" 'nvimx-check: config boom' +expect_fail notify-error "$notifyErrorRunner" 'vim.notify: copilot: failed to download server' +expect_fail chain-error "$chainErrorRunner" 'vim.notify: nvimx-check: chained error' +# once: the chain must not double-count, and must not hang into the timeout +test "$(grep -cF 'nvimx-check: chained error' chain-error.log)" = 1 || { + echo "chain-error: reported more than once:" >&2 + cat chain-error.log >&2 + exit 1 +} +if grep -qF 'timed out' chain-error.log; then + echo "chain-error: the notify chain hung" >&2 + exit 1 +fi +expect_fail early-quit "$earlyQuitRunner" 'exited before the check finished' +expect_fail stale-lock "$staleLockRunner" 'plenary.nvim' +grep -qF 'nvimx-lock' stale-lock.log +expect_fail no-lazy "$noLazyRunner" 'setup() was never called' + +touch "$out" diff --git a/tests/checks/startup-check.nix b/tests/checks/startup-check.nix new file mode 100644 index 0000000..ad8fa51 --- /dev/null +++ b/tests/checks/startup-check.nix @@ -0,0 +1,53 @@ +# env.check (#79). Building a check only shows that it passes, so this is the one place that +# proves it also fails: the broken fixtures run env.check.runner directly and must exit non-zero +# with the expected message (scripts/startup-check.sh). The passing fixtures are plain build +# dependencies. The cases pin what is and is not a failure: Lua errors, an error in a lazy-loaded +# plugin's config (only the load-all step finds it, cf. checks.hm-module's plain startup), ERROR +# notifications even through a replaced / chained vim.notify, a spec plugin missing from the +# lock, a config that never calls lazy.setup, and a config that quits during startup (exit 0 +# without the sentinel); but not stderr noise, silent!, dev plugins with no working tree, WARN. +# The 120 s timeout is not exercised: a deliberate hang is too slow for CI. +{ pkgs, nvimxLib, ... }: +let + mkEnv = + args: + nvimxLib.makeEnv ( + { + package = pkgs.neovim-unwrapped; + lockDir = ../fixtures/basic-config/nvimx-lock; + } + // args + ); + fixture = name: ../fixtures/startup-check + "/${name}"; + env = mkEnv { }; + runner = + name: + (nvimxLib.mkCheck { + inherit env; + configDir = fixture name; + }).runner; +in +pkgs.runCommand "startup-check" { + goodCheck = (mkEnv { configDir = ../fixtures/basic-config; }).check; + # tokyonight is a dev plugin whose tree is absent: skipped, and lazy's startup + # "not installed" error for it filtered + toleratedCheck = + (mkEnv { + configDir = fixture "tolerated"; + devPlugins = [ "tokyonight.nvim" ]; + devPath = "/nvimx-check/no-such-dir"; + }).check; + allowedCheck = + (mkEnv { + configDir = fixture "notify-error"; + check.ignoreErrors = [ "failed to download server" ]; + }).check; + chainWarnCheck = (mkEnv { configDir = fixture "chain-warn"; }).check; + initErrorRunner = runner "init-error"; + lazyConfigErrorRunner = runner "lazy-config-error"; + notifyErrorRunner = runner "notify-error"; + chainErrorRunner = runner "chain-error"; + earlyQuitRunner = runner "early-quit"; + staleLockRunner = runner "stale-lock"; + noLazyRunner = runner "no-lazy"; +} "source ${./scripts/startup-check.sh}" diff --git a/tests/fixtures/startup-check/chain-error/init.lua b/tests/fixtures/startup-check/chain-error/init.lua new file mode 100644 index 0000000..0992541 --- /dev/null +++ b/tests/fixtures/startup-check/chain-error/init.lua @@ -0,0 +1,21 @@ +-- Fixture for checks.startup-check. Same lock as basic-config (tokyonight only). +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +vim.opt.rtp:prepend(lazypath) + +-- The common chaining pattern; a filter drops "noise" but not the ERROR under test. +require("lazy").setup({ + { + "folke/tokyonight.nvim", + lazy = false, + config = function() + local orig = vim.notify + vim.notify = function(m, l, o) + if m:find("noise") then + return + end + return orig(m, l, o) + end + vim.notify("nvimx-check: chained error", vim.log.levels.ERROR) + end, + }, +}) diff --git a/tests/fixtures/startup-check/chain-warn/init.lua b/tests/fixtures/startup-check/chain-warn/init.lua new file mode 100644 index 0000000..4f9d06f --- /dev/null +++ b/tests/fixtures/startup-check/chain-warn/init.lua @@ -0,0 +1,25 @@ +-- Fixture for checks.startup-check. Same lock as basic-config (tokyonight only). +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +vim.opt.rtp:prepend(lazypath) + +-- Two chained wrappers (one filtering) and only a WARN: must pass. +require("lazy").setup({ + { + "folke/tokyonight.nvim", + lazy = false, + config = function() + local first = vim.notify + vim.notify = function(m, l, o) + if m:find("noise") then + return + end + return first(m, l, o) + end + local second = vim.notify + vim.notify = function(m, l, o) + return second(m, l, o) + end + vim.notify("nvimx-check: just a warning", vim.log.levels.WARN) + end, + }, +}) diff --git a/tests/fixtures/startup-check/early-quit/init.lua b/tests/fixtures/startup-check/early-quit/init.lua new file mode 100644 index 0000000..658418d --- /dev/null +++ b/tests/fixtures/startup-check/early-quit/init.lua @@ -0,0 +1,13 @@ +-- Fixture for checks.startup-check. Same lock as basic-config (tokyonight only). +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +vim.opt.rtp:prepend(lazypath) + +-- Quits during startup: nvim exits 0 before the driver finishes, which must not pass. +require("lazy").setup({ { "folke/tokyonight.nvim", lazy = false } }) + +vim.cmd("autocmd VimEnter * ++once qall!") +vim.api.nvim_create_autocmd("VimEnter", { + callback = function() + error("nvimx-check: early quit") + end, +}) diff --git a/tests/fixtures/startup-check/init-error/init.lua b/tests/fixtures/startup-check/init-error/init.lua new file mode 100644 index 0000000..b93fc25 --- /dev/null +++ b/tests/fixtures/startup-check/init-error/init.lua @@ -0,0 +1,7 @@ +-- Fixture for checks.startup-check. Same lock as basic-config (tokyonight only). +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +vim.opt.rtp:prepend(lazypath) + +require("lazy").setup({ { "folke/tokyonight.nvim", lazy = false } }) + +error("nvimx-check: init boom") diff --git a/tests/fixtures/startup-check/lazy-config-error/init.lua b/tests/fixtures/startup-check/lazy-config-error/init.lua new file mode 100644 index 0000000..ebed8df --- /dev/null +++ b/tests/fixtures/startup-check/lazy-config-error/init.lua @@ -0,0 +1,14 @@ +-- Fixture for checks.startup-check. Same lock as basic-config (tokyonight only). +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +vim.opt.rtp:prepend(lazypath) + +-- lazy = true: startup never loads it, only the check's load-all step runs its config +require("lazy").setup({ + { + "folke/tokyonight.nvim", + lazy = true, + config = function() + error("nvimx-check: config boom") + end, + }, +}) diff --git a/tests/fixtures/startup-check/no-lazy/init.lua b/tests/fixtures/startup-check/no-lazy/init.lua new file mode 100644 index 0000000..4ef54e6 --- /dev/null +++ b/tests/fixtures/startup-check/no-lazy/init.lua @@ -0,0 +1,2 @@ +-- Fixture for checks.startup-check: never calls require("lazy").setup(). +vim.o.number = true diff --git a/tests/fixtures/startup-check/notify-error/init.lua b/tests/fixtures/startup-check/notify-error/init.lua new file mode 100644 index 0000000..06f3f64 --- /dev/null +++ b/tests/fixtures/startup-check/notify-error/init.lua @@ -0,0 +1,18 @@ +-- Fixture for checks.startup-check. Same lock as basic-config (tokyonight only). +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +vim.opt.rtp:prepend(lazypath) + +-- A plugin that replaces vim.notify (so the ERROR never reaches the screen), then a deferred +-- ERROR like a server download failing. +require("lazy").setup({ + { + "folke/tokyonight.nvim", + lazy = false, + config = function() + vim.notify = function() end + vim.defer_fn(function() + vim.notify("copilot: failed to download server", vim.log.levels.ERROR) + end, 100) + end, + }, +}) diff --git a/tests/fixtures/startup-check/stale-lock/init.lua b/tests/fixtures/startup-check/stale-lock/init.lua new file mode 100644 index 0000000..b26752b --- /dev/null +++ b/tests/fixtures/startup-check/stale-lock/init.lua @@ -0,0 +1,9 @@ +-- Fixture for checks.startup-check. Same lock as basic-config (tokyonight only). +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +vim.opt.rtp:prepend(lazypath) + +-- plenary.nvim is in the spec but not in the lock +require("lazy").setup({ + { "folke/tokyonight.nvim", lazy = false }, + { "nvim-lua/plenary.nvim", lazy = false }, +}) diff --git a/tests/fixtures/startup-check/tolerated/init.lua b/tests/fixtures/startup-check/tolerated/init.lua new file mode 100644 index 0000000..03407d9 --- /dev/null +++ b/tests/fixtures/startup-check/tolerated/init.lua @@ -0,0 +1,11 @@ +-- Fixture for checks.startup-check. Same lock as basic-config (tokyonight only). +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +vim.opt.rtp:prepend(lazypath) + +-- None of this is a failure: stderr noise, a silent! error, a dev plugin with no working tree. +require("lazy").setup({ { "folke/tokyonight.nvim", lazy = false } }) + +io.stderr:write("Downloading Copilot server ...\n") +vim.cmd("silent! colorscheme no-such-scheme") +-- v:errmsg is set although nothing was shown: it is not a failure signal (#79) +assert(vim.v.errmsg ~= "") From 6b5c94eacda95c5aa34cd16791ec3065a961f9c3 Mon Sep 17 00:00:00 2001 From: myuron Date: Sat, 17 Oct 2026 13:30:29 +0900 Subject: [PATCH 3/5] docs(hm): document the startup check and check.ignoreErrors Co-Authored-By: Claude Opus 5.5 --- README.md | 40 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 39 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 43b66ca..4a9edcf 100644 --- a/README.md +++ b/README.md @@ -319,11 +319,12 @@ All options live under `programs.nvimx`. | `plugins.overrides` | `attrsOf (functionTo package)` | `{ }` | Per-plugin derivation overrides, keyed by the plugin name lazy derived. See [Escape hatches](#escape-hatches). | | `plugins.nixpkgsFallback` | `listOf str` | `[ ]` | Plugin names to take from `pkgs.vimPlugins` as-is instead of building from the lock. Opt-in per plugin. See [Escape hatches](#escape-hatches). | | `treesitter.grammars` | `nullOr (either (enum [ "all" ]) (listOf str))` | `null` | Tree-sitter grammars to merge into the locked `nvim-treesitter`, so no `:TSInstall` ever runs. See [Tree-sitter grammars](#tree-sitter-grammars). | +| `check.ignoreErrors` | `listOf str` | `[ ]` | Errors `env.check` tolerates: plain substrings matched against the whole message. See [Startup check](#startup-check). | | `lock.installCommand` | `bool` | `true` | Add the `nvimx-lock` command to `home.packages`. | | `lock.projectDir` | `nullOr str` | `null` | The working tree of your dotfiles repository. When set, running `nvimx-lock` with no arguments targets `configDirRelative` / `lockDirRelative`. | | `lock.configDirRelative` | `str` | `"nvim"` | Path to `configDir`, relative to `projectDir`. | | `lock.lockDirRelative` | `str` | `"nvim/nvimx-lock"` | Path to `lockDir`, relative to `projectDir`. | -| `env` | `attrs` | _(derived)_ | The result of `makeEnv` (`farm` / `bootstrap` / `wrapped` / `pluginDrvs` / `unknownPluginNames` / `treesitterWithoutPlugin` / `devDirs` / `unknownDevPluginNames` / `unusedPython3Packages` / `hasLock`). Built automatically from the options above; a direct escape hatch for advanced users. Setting it replaces the `makeEnv` call, so `extraPackages`, `extraLuaPackages`, `extraWrapperArgs` and every other option above that only feeds `makeEnv` are silently ignored. | +| `env` | `attrs` | _(derived)_ | The result of `makeEnv` (`farm` / `bootstrap` / `wrapped` / `pluginDrvs` / `unknownPluginNames` / `treesitterWithoutPlugin` / `devDirs` / `unknownDevPluginNames` / `unusedPython3Packages` / `hasLock` / `check`). Built automatically from the options above; a direct escape hatch for advanced users. Setting it replaces the `makeEnv` call, so `extraPackages`, `extraLuaPackages`, `extraWrapperArgs` and every other option above that only feeds `makeEnv` are silently ignored. | `lockDir` is the only option without a default, so it must always be set. `configDir` is also required whenever `manageConfig` is `true` (the default), which is enforced by an assertion in the module. On NixOS, `defaultEditor` shares `EDITOR` with a default NixOS sets on its own; see [`defaultEditor` on NixOS](#defaulteditor-on-nixos). @@ -721,6 +722,43 @@ Two things these options do not do: - **There is no `withPerl`.** Neovim's own discovery applies to Perl as it always has; put `vim.g.loaded_perl_provider = 0` in your `init.lua` to switch it off. +### Startup check + +`programs.nvimx.env.check` is a derivation that starts the exact wrapped Neovim a switch would +install, headless and offline, with a copy of your `configDir` in a throwaway `HOME` / `XDG_*`. It +loads every plugin in the farm (so a broken `config` of a lazy-loaded plugin is found too) and +fails the build on any startup error. Put it in your flake's checks: + +```nix +checks.x86_64-linux.nvim = self.homeConfigurations.me.config.programs.nvimx.env.check; +# or, with home-manager embedded in NixOS: +# self.nixosConfigurations..config.home-manager.users..programs.nvimx.env.check +``` + +Without the module, `lib..mkCheck { env; configDir; ignoreErrors; }` does the same for any +`makeEnv` result. `configDir` must be a path literal or a store path; an absolute string fails at +evaluation in pure mode. Reading `env.check` without `configDir` is an evaluation error. + +- **A failure** is anything Neovim actually shows as an error: a Lua error in `init.lua`, in an + autocmd or in a scheduled callback, an error in a plugin's `config`, an `ERROR` notification + (even when a plugin such as nvim-notify has replaced `vim.notify`), a plugin in your spec that is + not in the lock (re-run `nvimx-lock`), a config that never calls `require("lazy").setup()`, and a + config that quits during startup. The reasons show up in the build log as `[nvimx] check: ...`. +- **Not a failure** are writes to stderr (`Downloading Copilot server ...`), errors swallowed by + `silent!` or `pcall`, `WARN` / `INFO` notifications, and `dev` / `devPlugins` plugins whose + working tree is absent (they are skipped). `v:errmsg` is deliberately not used: `silent!` sets it + without showing anything. +- **Plugins that need the network at startup** fail the check. Prefer the real fix + (`treesitter.grammars` instead of `:TSInstall`), or turn the plugin off for the check: the + environment has `NVIMX_CHECK=1`, so `cond = not vim.env.NVIMX_CHECK` works. Otherwise list the + message in `check.ignoreErrors`: plain substrings (not Lua patterns) matched against the whole + message, which can start with a prefix such as `Error in :`. +- **`vim.notify` filters:** an `ERROR` is recorded when `vim.notify` is called, before your own + wrapper runs. One your filter drops on purpose still fails the check; list it in + `check.ignoreErrors`. +- **Time limit:** the run is killed after 120 s and reported as timed out. Errors that arrive + more than about a second after startup are not seen. + ## How it works Network access happens only while locking. `nvimx-lock` runs a headless Neovim that really evaluates From 3e7204f715c31f7aaf093e329c3b1e406afb7845 Mon Sep 17 00:00:00 2001 From: myuron Date: Sat, 17 Oct 2026 13:35:43 +0900 Subject: [PATCH 4/5] docs(hm): list devPatterns and the check options among those env bypasses Co-Authored-By: Claude Opus 5.5 --- nix/home-manager/default.nix | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/nix/home-manager/default.nix b/nix/home-manager/default.nix index 53091f5..3b61228 100644 --- a/nix/home-manager/default.nix +++ b/nix/home-manager/default.nix @@ -505,7 +505,8 @@ in Setting it replaces the makeEnv call, so every option that only feeds makeEnv is silently ignored: package, lockDir, extraPackages, extraLuaPackages, extraWrapperArgs, withPython3, withRuby, withNodeJs, extraPython3Packages, vimAlias, viAlias, plugins.*, - treesitter.*, devPlugins and devPath. + treesitter.*, devPlugins, devPath, devPatterns and check.* (configDir too, as far as + env.check is concerned: manageConfig still deploys it). ''; }; }; From 1695abdac6471286202df5f6ab476d833e6d54d1 Mon Sep 17 00:00:00 2001 From: myuron Date: Sat, 17 Oct 2026 13:52:09 +0900 Subject: [PATCH 5/5] fix(hm): wrap callable-table notifiers in the startup check Co-Authored-By: Claude Opus 5.5 --- lua/nvimx/startup-check.lua | 10 +++++++++- tests/checks/scripts/startup-check.sh | 1 + tests/checks/startup-check.nix | 1 + .../startup-check/notify-callable/init.lua | 16 ++++++++++++++++ 4 files changed, 27 insertions(+), 1 deletion(-) create mode 100644 tests/fixtures/startup-check/notify-callable/init.lua diff --git a/lua/nvimx/startup-check.lua b/lua/nvimx/startup-check.lua index 8ed8593..162616f 100644 --- a/lua/nvimx/startup-check.lua +++ b/lua/nvimx/startup-check.lua @@ -57,6 +57,14 @@ local function wrap(f) return unpack(r, 2, table.maxn(r)) end end +-- nvim-notify is `vim.notify = require("notify")`: a table with __call, not a function +local function is_callable(v) + if type(v) == "function" then + return true + end + local m = getmetatable(v) + return type(m) == "table" and m.__call ~= nil +end local mt = getmetatable(vim) if mt and type(mt.__index) == "function" and mt.__newindex == nil then local current = wrap(rawget(vim, "notify")) @@ -70,7 +78,7 @@ if mt and type(mt.__index) == "function" and mt.__newindex == nil then end mt.__newindex = function(t, k, v) if k == "notify" then - current = type(v) == "function" and wrap(v) or v + current = is_callable(v) and wrap(v) or v return end rawset(t, k, v) diff --git a/tests/checks/scripts/startup-check.sh b/tests/checks/scripts/startup-check.sh index fc91b4b..16582b5 100644 --- a/tests/checks/scripts/startup-check.sh +++ b/tests/checks/scripts/startup-check.sh @@ -30,6 +30,7 @@ if grep -qF 'timed out' chain-error.log; then echo "chain-error: the notify chain hung" >&2 exit 1 fi +expect_fail notify-callable "$notifyCallableRunner" 'vim.notify: nvimx-check: callable notifier error' expect_fail early-quit "$earlyQuitRunner" 'exited before the check finished' expect_fail stale-lock "$staleLockRunner" 'plenary.nvim' grep -qF 'nvimx-lock' stale-lock.log diff --git a/tests/checks/startup-check.nix b/tests/checks/startup-check.nix index ad8fa51..eb3b27c 100644 --- a/tests/checks/startup-check.nix +++ b/tests/checks/startup-check.nix @@ -47,6 +47,7 @@ pkgs.runCommand "startup-check" { lazyConfigErrorRunner = runner "lazy-config-error"; notifyErrorRunner = runner "notify-error"; chainErrorRunner = runner "chain-error"; + notifyCallableRunner = runner "notify-callable"; earlyQuitRunner = runner "early-quit"; staleLockRunner = runner "stale-lock"; noLazyRunner = runner "no-lazy"; diff --git a/tests/fixtures/startup-check/notify-callable/init.lua b/tests/fixtures/startup-check/notify-callable/init.lua new file mode 100644 index 0000000..33c7d0c --- /dev/null +++ b/tests/fixtures/startup-check/notify-callable/init.lua @@ -0,0 +1,16 @@ +-- Fixture for checks.startup-check. Same lock as basic-config (tokyonight only). +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +vim.opt.rtp:prepend(lazypath) + +-- nvim-notify style: vim.notify is a table with __call, not a function. The ERROR must still +-- be recorded. +require("lazy").setup({ + { + "folke/tokyonight.nvim", + lazy = false, + config = function() + vim.notify = setmetatable({}, { __call = function() end }) + vim.notify("nvimx-check: callable notifier error", vim.log.levels.ERROR) + end, + }, +})