diff --git a/README.md b/README.md index be45c3b..3d2cf6b 100644 --- a/README.md +++ b/README.md @@ -671,6 +671,10 @@ Two things these options do not do: Network access happens only while locking. `nvimx-lock` runs a headless Neovim that really evaluates your lazy spec, then persists the plugin list to `plugins.json` and the pins to `flake.lock`. +Extraction runs against the lazy.nvim your `flake.lock` pins, which is the one your Neovim loads. +nvimx's own pinned lazy.nvim is used only on the very first lock, and the spec is extracted again +whenever locking or updating moves lazy.nvim, so `plugins.json` always matches `flake.lock`. + Builds merely read those files, so evaluation is fully pure — no network, no `--impure`. From them nvimx builds one derivation per plugin, collects them into a linkFarm, and wraps Neovim with a generated `bootstrap.lua` so lazy.nvim loads from the Nix store instead of running its own diff --git a/nix/lib/lock-app.nix b/nix/lib/lock-app.nix index 2ae8786..f24bc3d 100644 --- a/nix/lib/lock-app.nix +++ b/nix/lib/lock-app.nix @@ -151,10 +151,6 @@ pkgs.writeShellApplication { mkdir -p "$out" out=$(realpath "$out") - # TODO: if the existing lock pins lazy.nvim, use that as the seed (to avoid skew). - # For now nvimx's own flake input is always used as the seed. - seed="${lazyNvimSeed}" - sandbox=$(mktemp -d) # --update's summary (below) is a before/after diff, so the "before" half has to be snapshot # ahead of any of the steps that are about to move it. --summary-json (#77) needs it for a plain @@ -221,22 +217,46 @@ pkgs.writeShellApplication { fi } trap cleanup EXIT + + # The lazy.nvim that extraction (and resolve's semver.lua) runs against has to be the one the + # built neovim loads, i.e. the `lazy-nvim` input pinned in $out/flake.lock (make-env.nix), or + # the spec recorded in plugins.json can come from a different lazy.nvim than the one that + # runs it (#32). locked_seed prints that store path, or nothing when no lock pins one yet. + # A failing `nix eval` is fatal on purpose: falling back silently would reintroduce the skew. + # --impure because $out/flake.lock is an absolute non-store path; the env var carries it in + # because `nix eval --expr` cannot be handed arguments for a function. + locked_seed() { + [ -f "$out/flake.lock" ] || return 0 + NVIMX_LOCK_DIR="$out" nix eval --raw --impure --expr \ + 'import ${./locked-lazy-seed.nix} { lockDir = builtins.getEnv "NVIMX_LOCK_DIR"; sources = ${./sources.nix}; }' + } + seed=$(locked_seed) + if [ -n "$seed" ]; then + echo "nvimx-lock: lazy.nvim seed: $seed (pinned in $out/flake.lock)" >&2 + else + seed="${lazyNvimSeed}" + echo "nvimx-lock: lazy.nvim seed: $seed (nvimx's own; no lock pins lazy.nvim yet)" >&2 + fi + mkdir -p "$sandbox/config" "$sandbox/data/nvim/lazy" "$sandbox/state" "$sandbox/cache" ln -sfT "$config" "$sandbox/config/nvim" ln -sfT "$seed" "$sandbox/data/nvim/lazy/lazy.nvim" + extract_spec() { + # extract.lua exits 0 when it captures setup, and exits 1 from VimEnter when it does not. + # The timeout plus closing stdin is a safety net for unexpected cases where it still + # stays in the event loop (#3). + env \ + XDG_CONFIG_HOME="$sandbox/config" \ + XDG_DATA_HOME="$sandbox/data" \ + XDG_STATE_HOME="$sandbox/state" \ + XDG_CACHE_HOME="$sandbox/cache" \ + NVIMX_LAZY_SEED="$seed" \ + NVIMX_OUT="$sandbox/raw-spec.json" \ + timeout 120 nvim --headless --cmd "luafile ${luaDir}/extract.lua" < /dev/null + } echo "nvimx-lock: extracting spec from $config" >&2 - # extract.lua exits 0 when it captures setup, and exits 1 from VimEnter when it does not. - # The timeout plus closing stdin is a safety net for unexpected cases where it still - # stays in the event loop (#3). - env \ - XDG_CONFIG_HOME="$sandbox/config" \ - XDG_DATA_HOME="$sandbox/data" \ - XDG_STATE_HOME="$sandbox/state" \ - XDG_CACHE_HOME="$sandbox/cache" \ - NVIMX_LAZY_SEED="$seed" \ - NVIMX_OUT="$sandbox/raw-spec.json" \ - timeout 120 nvim --headless --cmd "luafile ${luaDir}/extract.lua" < /dev/null + extract_spec echo "nvimx-lock: resolving plugins" >&2 # Whatever the previous lock decided is an input to this one: resolve.lua carries already @@ -318,11 +338,36 @@ pkgs.writeShellApplication { fi fi + # `nix flake lock` / `nix flake update` above may have moved the lazy.nvim pin away from the + # seed extraction used (#32): the first lock pins lazy.nvim's HEAD of that moment rather than + # nvimx's own rev, `--update` moves it on purpose, and a spec that changes its own lazy.nvim + # entry changes the input URL (#49). Extract again against the newly pinned one so the final + # plugins.json matches the final flake.lock. If this extraction fails, flake.lock has already + # moved; the next plain lock seeds from the new pin and fails the same way. + new_seed=$(locked_seed) + if [ -n "$new_seed" ] && [ "$new_seed" != "$seed" ]; then + seed=$new_seed + echo "nvimx-lock: lazy.nvim seed: $seed (newly pinned in $out/flake.lock); re-extracting spec" >&2 + ln -sfT "$seed" "$sandbox/data/nvim/lazy/lazy.nvim" + # Wiped because vim.loader.enable() (common at the top of init.lua) keeps bytecode in + # stdpath("cache")/luac, keyed by module path and validated only by size and mtime, never + # by content. Both extractions load lazy's modules through the same symlink path, and store + # files all have mtime 1, so a module that changed but kept its size would be served from + # the first run's cache and silently run the OLD lazy.nvim. State goes too, so nothing the + # first run persisted leaks into the second. + rm -rf "$sandbox/cache" "$sandbox/state" + mkdir -p "$sandbox/cache" "$sandbox/state" + extract_spec + fi + # A plugin pinned for the first time (or right after its spec changed) has no usable rev in # flake.lock when the resolve above runs, so its freeze had to be deferred. Resolve once more # now that flake.lock has caught up, and redo the flake only if that actually changed # something. One retry is enough: the third resolve would read back the same flake.lock and # produce the same file, so this is already the fixed point. + # (The lazy-nvim URL comes only from the spec's own lazy.nvim entry, so the freezing lock below + # can move lazy-nvim again only if re-extraction changed that entry; that is not looped -- the + # next plain lock seeds from whatever was pinned and converges.) # The log is overwritten on purpose: only this run's warnings are shown. resolve.lua is # written so that the two runs report the same set of problems -- in particular a `pin` that # only freezes here still warns about an unvalidated `version` on the first run, and a diff --git a/nix/lib/locked-lazy-seed.nix b/nix/lib/locked-lazy-seed.nix new file mode 100644 index 0000000..f6dc76c --- /dev/null +++ b/nix/lib/locked-lazy-seed.nix @@ -0,0 +1,10 @@ +# lockDir/flake.lock -> store path of the lazy.nvim it pins (the `lazy-nvim` root input), or "" +# when it pins none. nvimx-lock evaluates this before extracting (#32): make-env.nix loads exactly +# this input at runtime, so extracting against anything else can derive different names. `sources` +# is passed in rather than imported relatively: lock-app.nix copies this file to the store alone. +{ lockDir, sources }: +let + lock = builtins.fromJSON (builtins.readFile (lockDir + "/flake.lock")); + rootInputs = lock.nodes.${lock.root}.inputs or { }; +in +if rootInputs ? "lazy-nvim" then (import sources { inherit lockDir; } "lazy-nvim").outPath else "" diff --git a/tests/checks/default.nix b/tests/checks/default.nix index 8261f0a..9bdfba8 100644 --- a/tests/checks/default.nix +++ b/tests/checks/default.nix @@ -47,6 +47,7 @@ pkgs.lib.genAttrs [ "resolve-import-lazy-lock" "lock-app" "lock-untracked-config" + "lock-lazy-seed" "resolve-sources" "resolve-golden" "genflake-golden" diff --git a/tests/checks/lock-app.nix b/tests/checks/lock-app.nix index b21e896..d291c81 100644 --- a/tests/checks/lock-app.nix +++ b/tests/checks/lock-app.nix @@ -1,16 +1,22 @@ # The lock app itself, end to end (#71): what nix/lib/lock-app.nix hands its `nix` calls, what # the EXIT trap prints, and everything after them -- no other check gets past those calls, # since recursive nix cannot run in a build. `nix` comes from the caller's PATH, so a stub -# stands in: it records argv and cwd, fails on NVIMX_STUB_FAIL, and otherwise writes a fixture +# stands in: it answers the seed lookup (`eval`, #32) with nvimx's own lazy.nvim, records argv and +# cwd, fails on NVIMX_STUB_FAIL, and otherwise writes a fixture # flake.lock (the convergence pass reads it right after). That the real nix accepts `path:.` # stays a manual check. { pkgs, nvimxLib, + lazy-nvim, ... }: let stubNix = pkgs.writeShellScriptBin "nix" '' + if [ "$1" = eval ]; then + printf '%s' "$NVIMX_STUB_SEED" + exit 0 + fi printf '%s\n' "$*" >> "$NVIMX_STUB_ARGV" pwd >> "$NVIMX_STUB_CWD" if [ -n "''${NVIMX_STUB_FAIL:-}" ]; then @@ -29,4 +35,5 @@ pkgs.runCommand "lock-app" { stubNix ]; fx = ../fixtures/basic-config; + NVIMX_STUB_SEED = "${lazy-nvim}"; } "source ${./scripts/lock-app.sh}" diff --git a/tests/checks/lock-lazy-seed.nix b/tests/checks/lock-lazy-seed.nix new file mode 100644 index 0000000..a7bddb9 --- /dev/null +++ b/tests/checks/lock-lazy-seed.nix @@ -0,0 +1,88 @@ +# Which lazy.nvim nvimx-lock extracts against (#32): the one flake.lock pins -- what the built +# neovim loads -- not always nvimx's own input. Two layers, because recursive nix cannot run in a +# build: the `nix eval` expression that reads the pin is evaluated for real here, at check +# evaluation time (like lib.nix's blinkSrc), while the CLI around it runs against a stub `nix`, as +# in checks.lock-app. Each seed is a copy of lazy.nvim carrying a marker the fixture configs turn +# into a plugin name, so plugins.json shows which seed an extraction really ran against. +{ + pkgs, + nvimxLib, + lazy-nvim, + ... +}: +let + seedOf = + lockDir: + import ../../nix/lib/locked-lazy-seed.nix { + inherit lockDir; + sources = ../../nix/lib/sources.nix; + }; + # Compared against a literal fetchTree rather than `lazy-nvim`, so bumping nvimx's own input + # never breaks this. The rev is the one basic-config's fixture flake.lock pins. + fixtureLazy = builtins.fetchTree { + type = "github"; + owner = "folke"; + repo = "lazy.nvim"; + rev = "306a05526ada86a7b30af95c5cc81ffba93fef97"; + narHash = "sha256-5A4kducPwKb5fKX4oSUFvo898P0dqfsqqLxFaXBsbQY="; + }; + # lazy.nvim plus a marker file (read with io.open) and a same-size Lua module (read through + # vim.loader, step 2b): a vim.loader cache keyed by path, size and mtime cannot tell them apart. + mkSeed = + v: + pkgs.runCommand "lazy-seed-${v}" { } '' + cp -r ${lazy-nvim} $out + chmod -R u+w $out + echo ${v} > $out/NVIMX_SEED_MARKER + printf 'return "%s"' ${v} > $out/lua/nvimx_seed_marker.lua + ''; + seedA = mkSeed "a"; + seedB = mkSeed "b"; + seedX = mkSeed "x"; + # The pre-lock seed of step 2b is seedX instead of nvimx's own input. + lockAppX = import ../../nix/lib/lock-app.nix { + inherit pkgs; + lazyNvimSeed = seedX; + }; + # `eval` is the seed lookup: it answers with NVIMX_STUB_SEED until a `flake update` has run and + # NVIMX_STUB_SEED_AFTER from then on, and fails first on NVIMX_STUB_FAIL. Anything else is + # recorded and writes a fixture flake.lock, as lock-app's stub does. + stubNix = pkgs.writeShellScriptBin "nix" '' + case "$1" in + eval) + printf '%s\n' "$NVIMX_LOCK_DIR" >> "$NVIMX_STUB_EVAL" + if [ -n "''${NVIMX_STUB_FAIL:-}" ]; then + echo "error: stub nix was told to fail" >&2 + exit "$NVIMX_STUB_FAIL" + fi + if [ -e "$NVIMX_STUB_STATE/updated" ]; then + printf '%s' "$NVIMX_STUB_SEED_AFTER" + else + printf '%s' "$NVIMX_STUB_SEED" + fi + ;; + *) + printf '%s\n' "$*" >> "$NVIMX_STUB_ARGV" + if [ "$2" = update ]; then touch "$NVIMX_STUB_STATE/updated"; fi + cp "$NVIMX_STUB_LOCK" flake.lock + chmod u+w flake.lock + ;; + esac + ''; +in +pkgs.runCommand "lock-lazy-seed" { + nativeBuildInputs = [ + pkgs.jq + nvimxLib.lockApp + stubNix + ]; + fx = ../fixtures/basic-config; + seeds = ../fixtures/lazy-seed; + inherit lockAppX; + lazyInput = "${lazy-nvim}"; + seedA = "${seedA}"; + seedB = "${seedB}"; + pinned = seedOf ../fixtures/basic-config/nvimx-lock; + unpinned = seedOf ../fixtures/lazy-seed/no-lazy; + fixtureLazy = "${fixtureLazy}"; +} "source ${./scripts/lock-lazy-seed.sh}" diff --git a/tests/checks/scripts/lock-lazy-seed.sh b/tests/checks/scripts/lock-lazy-seed.sh new file mode 100644 index 0000000..74da1ef --- /dev/null +++ b/tests/checks/scripts/lock-lazy-seed.sh @@ -0,0 +1,123 @@ +# shellcheck shell=bash disable=SC2154 +# Body of checks.lock-lazy-seed, sourced by the runCommand in ../lock-lazy-seed.nix. +# $fx $seeds $lockAppX $lazyInput $seedA $seedB $pinned $unpinned $fixtureLazy come from its env. +export HOME=$TMPDIR +# nvimx-lock realpath's --out, so compare against resolved paths (see lock-app.sh). +here=$(realpath .) +mkdir locktmp +export TMPDIR=$here/locktmp +no_leftover() { + if [ -n "$(find "$TMPDIR" -mindepth 1 -maxdepth 1 -name 'tmp.*')" ]; then + echo "nvimx-lock left its sandbox behind in $TMPDIR" >&2 + exit 1 + fi +} +# `! grep -q` would be vacuous: a `!`-negated command never trips errexit. +absent() { # + if grep -q -- "$1" "$2"; then + echo "unexpected \"$1\" in $2" >&2 + exit 1 + fi +} +fail_line() { # + echo "nvimx-lock: failed (exit $1) -- look for the error above; it may sit above the import report and warnings" +} +export NVIMX_STUB_LOCK=$fx/nvimx-lock/flake.lock +export NVIMX_STUB_ARGV=$here/argv NVIMX_STUB_EVAL=$here/evals NVIMX_STUB_STATE=$here/state +# Before every step: the `updated` flag step 4 leaves behind would otherwise make the later +# steps answer with NVIMX_STUB_SEED_AFTER. +reset() { + : >argv + : >evals + rm -rf "$NVIMX_STUB_STATE" + mkdir "$NVIMX_STUB_STATE" + unset NVIMX_STUB_FAIL NVIMX_STUB_SEED_AFTER +} +# The only plugin in /plugins.json +only_plugin() { # + [ "$(jq -r '.plugins | keys | join(",")' "$1/plugins.json")" = "$2" ] +} +want_argv() { # ... + printf '%s\n' "$@" >want-argv + diff -u want-argv argv +} + +# 0. The expression layer, evaluated for real: the lazy.nvim pinned in a lock, and "" for one +# that pins none. +[ "$pinned" = "$fixtureLazy" ] +[ -z "$unpinned" ] + +# 1. First lock: nothing pins lazy.nvim yet, so nvimx's own input is the seed. The post-lock +# lookup finds the same path, so there is no second extraction. +reset +export NVIMX_STUB_SEED=$lazyInput +nvimx-lock --config "$seeds/config" --out s1 2>s1.log +no_leftover +grep -qxF "nvimx-lock: lazy.nvim seed: $lazyInput (nvimx's own; no lock pins lazy.nvim yet)" s1.log +absent 're-extracting' s1.log +only_plugin s1 seed-input.nvim +[ "$(cat evals)" = "$here/s1" ] +want_argv 'flake lock path:.' + +# 2. First lock, but the lock pinned a lazy.nvim other than nvimx's (lazy HEAD of that moment): +# the spec is extracted again against it, and the final plugins.json matches. +reset +export NVIMX_STUB_SEED=$seedA +nvimx-lock --config "$seeds/config" --out s2 2>s2.log +no_leftover +grep -qxF "nvimx-lock: lazy.nvim seed: $seedA (newly pinned in $here/s2/flake.lock); re-extracting spec" s2.log +only_plugin s2 seed-a.nvim +[ "$(wc -l s2b.log +no_leftover +grep -q 're-extracting' s2b.log +only_plugin s2b seed-a.nvim + +# 3. Re-lock: the lock pins lazy.nvim, so that is the seed and nothing is extracted twice. +reset +export NVIMX_STUB_SEED=$seedA +nvimx-lock --config "$seeds/config" --out s2 2>s3.log +grep -qxF "nvimx-lock: lazy.nvim seed: $seedA (pinned in $here/s2/flake.lock)" s3.log +absent 're-extracting' s3.log +only_plugin s2 seed-a.nvim +[ "$(wc -l s4.log +grep -qxF "nvimx-lock: lazy.nvim seed: $seedA (pinned in $here/s2/flake.lock)" s4.log +grep -qxF "nvimx-lock: lazy.nvim seed: $seedB (newly pinned in $here/s2/flake.lock); re-extracting spec" s4.log +only_plugin s2 seed-b.nvim +# Starts with these two; the changed plugin set may add a freezing lock after them. +head -n 2 argv >argv.head +printf '%s\n' 'flake lock path:.' 'flake update --flake path:. lazy-nvim seed-a-nvim' | diff -u - argv.head + +# 5. A flake.lock that pins no lazy.nvim falls back to nvimx's own input. +reset +mkdir s5 +cp "$fx"/nvimx-lock/flake.lock s5/ +export NVIMX_STUB_SEED= +nvimx-lock --config "$seeds/config" --out s5 2>s5.log +grep -qxF "nvimx-lock: lazy.nvim seed: ${lazyInput} (nvimx's own; no lock pins lazy.nvim yet)" s5.log +only_plugin s5 seed-input.nvim + +# 6. A failing lookup is fatal. Falling back silently would reintroduce the skew. +reset +cp -r s2 s6 +chmod -R u+w s6 +rc=0 +NVIMX_STUB_FAIL=9 nvimx-lock --config "$seeds/config" --out s6 2>s6.log || rc=$? +[ "$rc" -eq 9 ] +[ "$(tail -n 1 s6.log)" = "$(fail_line 9)" ] +cmp s6/plugins.json s2/plugins.json +no_leftover +touch "$out" diff --git a/tests/fixtures/lazy-seed/config/init.lua b/tests/fixtures/lazy-seed/config/init.lua new file mode 100644 index 0000000..f76b8b6 --- /dev/null +++ b/tests/fixtures/lazy-seed/config/init.lua @@ -0,0 +1,10 @@ +-- The spec depends on which lazy.nvim nvimx-lock linked in, so plugins.json records which seed +-- the extraction actually ran against (#32). +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +vim.opt.rtp:prepend(lazypath) +local f = io.open(lazypath .. "/NVIMX_SEED_MARKER") +local which = f and f:read("*l") or "input" +if f then + f:close() +end +require("lazy").setup({ { "nvimx-test/seed-" .. which .. ".nvim" } }) diff --git a/tests/fixtures/lazy-seed/loader-config/init.lua b/tests/fixtures/lazy-seed/loader-config/init.lua new file mode 100644 index 0000000..70e3570 --- /dev/null +++ b/tests/fixtures/lazy-seed/loader-config/init.lua @@ -0,0 +1,6 @@ +-- Same idea as ../config, but the marker comes from a Lua module and vim.loader is on, so a stale +-- bytecode cache from an earlier extraction would be visible in plugins.json (#32). +vim.loader.enable() +local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim" +vim.opt.rtp:prepend(lazypath) +require("lazy").setup({ { "nvimx-test/seed-" .. require("nvimx_seed_marker") .. ".nvim" } }) diff --git a/tests/fixtures/lazy-seed/no-lazy/flake.lock b/tests/fixtures/lazy-seed/no-lazy/flake.lock new file mode 100644 index 0000000..70b4cfb --- /dev/null +++ b/tests/fixtures/lazy-seed/no-lazy/flake.lock @@ -0,0 +1 @@ +{"nodes":{"root":{}},"root":"root","version":7}