Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).

Expand Down Expand Up @@ -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.<host>.config.home-manager.users.<me>.programs.nvimx.env.check
```

Without the module, `lib.<system>.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 <file>:`.
- **`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
Expand Down
167 changes: 167 additions & 0 deletions lua/nvimx/startup-check.lua
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
-- nvimx startup check driver (#79)
--
-- Usage (nix/lib/check.nix runs this inside the sandbox):
-- NVIMX_CHECK_FARM=<farm> NVIMX_CHECK_IGNORE=<json list> NVIMX_CHECK_SENTINEL=<file> \
-- 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
-- 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"))
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 = is_callable(v) 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,
})
31 changes: 29 additions & 2 deletions nix/home-manager/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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 <file>:`).

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;
Expand Down Expand Up @@ -475,13 +494,19 @@ 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 = <hm config>.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
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).
'';
};
};
Expand Down Expand Up @@ -548,6 +573,8 @@ in
devPlugins
devPath
devPatterns
configDir
check
;
}
);
Expand Down
71 changes: 71 additions & 0 deletions nix/lib/check.nix
Original file line number Diff line number Diff line change
@@ -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
''
2 changes: 2 additions & 0 deletions nix/lib/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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; };
}
Loading
Loading