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
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
75 changes: 60 additions & 15 deletions nix/lib/lock-app.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions nix/lib/locked-lazy-seed.nix
Original file line number Diff line number Diff line change
@@ -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 ""
1 change: 1 addition & 0 deletions tests/checks/default.nix
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
9 changes: 8 additions & 1 deletion tests/checks/lock-app.nix
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -29,4 +35,5 @@ pkgs.runCommand "lock-app" {
stubNix
];
fx = ../fixtures/basic-config;
NVIMX_STUB_SEED = "${lazy-nvim}";
} "source ${./scripts/lock-app.sh}"
88 changes: 88 additions & 0 deletions tests/checks/lock-lazy-seed.nix
Original file line number Diff line number Diff line change
@@ -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}"
123 changes: 123 additions & 0 deletions tests/checks/scripts/lock-lazy-seed.sh
Original file line number Diff line number Diff line change
@@ -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() { # <pattern> <file>
if grep -q -- "$1" "$2"; then
echo "unexpected \"$1\" in $2" >&2
exit 1
fi
}
fail_line() { # <exit code>
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 <dir>/plugins.json
only_plugin() { # <dir> <name>
[ "$(jq -r '.plugins | keys | join(",")' "$1/plugins.json")" = "$2" ]
}
want_argv() { # <line>...
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 <evals)" -eq 1 ]
want_argv 'flake lock path:.' 'flake lock path:.'

# 2b. The re-extraction must not be served the first run's vim.loader bytecode: both runs load
# the module from the same symlinked path, with the same size and mtime. Only this step covers the
# cache and state wipe; the others read their marker with io.open and would pass without it.
reset
export NVIMX_STUB_SEED=$seedA
"$lockAppX/bin/nvimx-lock" --config "$seeds/loader-config" --out s2b 2>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 <evals)" -eq 2 ]
want_argv 'flake lock path:.'

# 4. --update moves lazy.nvim: extraction starts from the old pin and is redone against the new.
reset
export NVIMX_STUB_SEED=$seedA NVIMX_STUB_SEED_AFTER=$seedB
nvimx-lock --config "$seeds/config" --out s2 --update 2>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"
10 changes: 10 additions & 0 deletions tests/fixtures/lazy-seed/config/init.lua
Original file line number Diff line number Diff line change
@@ -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" } })
6 changes: 6 additions & 0 deletions tests/fixtures/lazy-seed/loader-config/init.lua
Original file line number Diff line number Diff line change
@@ -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" } })
1 change: 1 addition & 0 deletions tests/fixtures/lazy-seed/no-lazy/flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading