diff --git a/README.md b/README.md index 4a9edcf..c97e0cb 100644 --- a/README.md +++ b/README.md @@ -714,11 +714,32 @@ can report a host pinned by Nix as out of date, or report errors when you are of the provider is broken, and the `npm install -g` style advice it gives does not apply: the host's version follows your nixpkgs. -Two things these options do not do: +#### Remote plugins + +With `true`, the remote plugins of that host in your lock are registered while nvimx builds, as +with `programs.neovim`. There is no `:UpdateRemotePlugins` to run after a switch or a plugin +update, and a lazy-loaded plugin's functions and commands work without the plugin being loaded. + +- Every plugin in the lock is registered, whatever its `lazy` / `cond` / `enabled` settings (again + as with `programs.neovim`). A `cond = false` plugin is still locked and in the plugin farm, so its + remote functions and commands exist even though lazy never loads the plugin's own Lua/Vimscript. + Remove the plugin from the spec and re-lock to drop them. +- Your own manifest, `stdpath("data")/rplugin.vim` (what `:UpdateRemotePlugins` writes), is still + read. For the hosts nvimx registers, entries for paths under `/nix/store` are ignored in favour + of the build's, so an old manifest cannot shadow an updated plugin. Everything else in it is + kept. +- `devPlugins` and dev/dir plugins are not registered by nvimx: run `:UpdateRemotePlugins` for + them, as before. (A working tree that itself lives under `/nix/store` would be treated as a + store plugin and ignored.) +- A plugin whose registration fails at build time (for example, a package missing from + `extraPython3Packages`) is left out, with the error in the build log, instead of failing the + build. A package a remote plugin imports at registration time is needed at build time too. +- Nothing is loaded with `--clean`, or when rplugin is disabled (`vim.g.loaded_remote_plugins` set + before plugins load, or `performance.rtp.disabled_plugins = { "rplugin" }`), matching Neovim. +- `extraWrapperArgs` do not apply while the manifest is generated. + +One thing these options do not do: -- **No remote-plugin manifest is generated.** Run `:UpdateRemotePlugins` yourself, and again after - every plugin update: the manifest records each plugin's path, which is a Nix store path that - changes with the lock. - **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. diff --git a/lua/nvimx/rplugin-gen.lua b/lua/nvimx/rplugin-gen.lua new file mode 100644 index 0000000..f332aac --- /dev/null +++ b/lua/nvimx/rplugin-gen.lua @@ -0,0 +1,47 @@ +-- Runs under `nvim -l` inside the manifest derivation (nix/lib/rplugin-manifest.nix), and only +-- when something was staged. arg[1] = the staging dir (one subdir per plugin, holding the rplugin/ +-- dirs of the hosts that are on), arg[2] = the raw manifest path (= $NVIM_RPLUGIN_MANIFEST). +-- Writes the manifest to stdout, which the derivation appends after its header. Under `-l` only +-- io.stdout reaches stdout (echomsg output such as "remote/host: ... registered plugins" goes to +-- stderr), so host tracebacks and the warnings below stay in the build log. +local stage, raw = arg[1], arg[2] + +for _, name in ipairs(vim.fn.readdir(stage)) do + vim.opt.rtp:append(stage .. "/" .. name) +end +vim.cmd("runtime! plugin/rplugin.vim") +vim.cmd("UpdateRemotePlugins") + +-- A plugin whose host cannot load it (a missing Python package, say) is skipped by neovim and the +-- run still succeeds. Say so instead of leaving it out silently. The patterns are the ones +-- runtime/autoload/remote/host.vim passes to remote#host#Register for each host. +local patterns = { python3 = "*", ruby = "*.rb", node = "*" } +local f = assert(io.open(raw, "r")) +local manifest = f:read("*a") +f:close() + +-- the host's last message has no trailing newline +io.stderr:write("\n") +local missing = {} +for host, pattern in pairs(patterns) do + for _, p in ipairs(vim.api.nvim_get_runtime_file("rplugin/" .. host .. "/" .. pattern, true)) do + local path = vim.fn.resolve(p) + if not manifest:find(path, 1, true) then + local plugin = vim.fn.fnamemodify(path, ":h:h:h:t") + io.stderr:write( + ("nvimx: %s (%s) did not register with the %s host and is left out of the manifest; see the host error above (a package missing from extraPython3Packages?)\n"):format( + path, + plugin, + host + ) + ) + table.insert(missing, '" not registered: ' .. path) + end + end +end + +table.sort(missing) +if #missing > 0 then + io.stdout:write(table.concat(missing, "\n"), "\n") +end +io.stdout:write(manifest) diff --git a/lua/nvimx/rplugin-load.lua b/lua/nvimx/rplugin-load.lua new file mode 100644 index 0000000..7e85dff --- /dev/null +++ b/lua/nvimx/rplugin-load.lua @@ -0,0 +1,78 @@ +-- Loads nvimx's build-time remote-plugin manifest right after runtime/plugin/rplugin.vim (#85). +-- dofile'd from the provider rc (nvimx-providers.lua) with +-- { manifest = "", hosts = { "python3", ... }, store = "/nix/store/" } +-- +-- The user's own manifest (stdpath("data")/rplugin.vim, what :UpdateRemotePlugins writes) is still +-- read by runtime. Ours goes after it, and first every store-path registration of a host we +-- generated is dropped, because a stale one wins even when ours is sourced later: +-- - upstream remote#host#RegisterPlugin throws on a path registered twice (nixpkgs' patch turns +-- the throw into a return), which would abort the rest of the user's file if ours went first; +-- - pynvim imports plugin modules by basename, in registration order, so an old plugin version +-- that is still in the store (or the same one) wins the module name and answers with old code; +-- - a stale autocmd spec points at a handler that the new host never registered +-- ("no request handler registered"), so the new handler never runs. +-- Functions and commands would be redefined by the later registration, which is why only the +-- three above need the removal. Registrations from outside the store (dev plugins) stay. +return function(opts) + -- runtime's rplugin.vim `finish`es early when g:loaded_remote_plugins exists, but SourcePost + -- still fires after that; without this, a user who disabled rplugin that way would get it + -- switched back on. SourcePre sees the variable before the file runs. + local disabled_before = false + local pre = vim.api.nvim_create_autocmd("SourcePre", { + pattern = "*/plugin/rplugin.vim", + callback = function() + disabled_before = vim.g.loaded_remote_plugins ~= nil + end, + }) + vim.api.nvim_create_autocmd("SourcePost", { + pattern = "*/plugin/rplugin.vim", + -- Not `once`: a plugin shipping its own plugin/rplugin.vim and sourced earlier must not use + -- the hook up. Returning true removes it after the real one has run. + callback = function() + if disabled_before then + pcall(vim.api.nvim_del_autocmd, pre) + return true + end + -- not runtime's rplugin.vim (that one sets this variable) + if vim.g.loaded_remote_plugins == nil then + return + end + -- runtime skips its manifest with --clean; so do we + if vim.tbl_contains(vim.v.argv, "--clean") then + pcall(vim.api.nvim_del_autocmd, pre) + return true + end + for _, host in ipairs(opts.hosts) do + local needle = 'Bootstrap("' .. host .. '", "' .. opts.store + -- PluginsForHost returns the live list, so filter() edits it in place. A lambda with + -- vim.fn.string() arguments, not a string expression: nested quotes broke that one. + vim.cmd( + ("call filter(remote#host#PluginsForHost(%s), {_, p -> stridx(p.path, %s) != 0})"):format( + vim.fn.string(host), + vim.fn.string(opts.store) + ) + ) + for _, au in ipairs(vim.api.nvim_get_autocmds({})) do + -- Vimscript-defined autocmds have no id, so nvim_del_autocmd cannot remove them: clear + -- their RPC_DEFINE_AUTOCMD_GROUP_* group instead. + if au.command and au.command:find(needle, 1, true) and au.group then + vim.api.nvim_clear_autocmds({ group = au.group }) + pcall(vim.api.nvim_del_augroup_by_id, au.group) + end + end + for name, cmd in pairs(vim.api.nvim_get_commands({ builtin = false })) do + if cmd.definition:find(needle, 1, true) then + vim.api.nvim_del_user_command(name) + end + end + end + -- pcall: sourcing throws if init.lua started a host before lazy.setup ("already running") + local ok, err = pcall(vim.cmd.source, vim.fn.fnameescape(opts.manifest)) + if not ok then + vim.notify("nvimx: " .. err, vim.log.levels.WARN) + end + pcall(vim.api.nvim_del_autocmd, pre) + return true -- delete this autocmd + end, + }) +end diff --git a/nix/home-manager/default.nix b/nix/home-manager/default.nix index 3b61228..c3d0ac5 100644 --- a/nix/home-manager/default.nix +++ b/nix/home-manager/default.nix @@ -236,9 +236,14 @@ in you set there still wins. With false that does not bring the provider back by itself: init.lua has to unset vim.g.loaded_python3_provider as well. - nvimx does not generate a remote-plugin manifest. Run :UpdateRemotePlugins yourself, and - again after a plugin update: the manifest records the plugin's path, which is a Nix store - path that changes with the lock. + With true, nvimx also registers the Python remote plugins of the locked plugins at build + time, so there is nothing to run after a switch or a plugin update. Every plugin in the + lock is registered, whatever its lazy / cond / enabled settings, as with programs.neovim: + a cond = false plugin is still locked and in the farm, so its functions and commands + exist even though lazy never loads the plugin's Lua/Vimscript side. Plugins in + devPlugins (and the spec's dev/dir plugins) are left to :UpdateRemotePlugins. A plugin + whose registration fails (for example a package missing from extraPython3Packages) is + left out, with the error in the build log. See the README. ''; }; @@ -259,6 +264,8 @@ in The list is still evaluated when withPython3 is not true -- that is how the warning is produced -- so a package that fails to evaluate is an error either way. + + A package a remote plugin imports at registration time is needed at build time too. ''; }; @@ -279,6 +286,9 @@ in a ruby you already have stays in front), which is what :checkhealth looks for. Unlike nixpkgs' wrapper, nvimx does not set GEM_HOME: the host runs without it, and it would be exported to every process neovim starts. Add it with extraWrapperArgs if you need it. + + With true, Ruby remote plugins in the lock are registered at build time, as for + withPython3. ''; }; @@ -299,6 +309,9 @@ in there. As with programs.neovim, appending it also makes `node` available to plugins that run it themselves. The host therefore runs under the first node on PATH -- yours, if you have one; put pkgs.nodejs in extraPackages to pin it. + + With true, JavaScript remote plugins in the lock are registered at build time, as for + withPython3. ''; }; diff --git a/nix/lib/make-env.nix b/nix/lib/make-env.nix index 821416f..6bac0d5 100644 --- a/nix/lib/make-env.nix +++ b/nix/lib/make-env.nix @@ -20,6 +20,8 @@ # arguments rather than a `providers = { ... }` attrset, for the reason devPlugins / devPath # give below: they map 1:1 onto the module's options, which are flat because # programs.neovim's are. Threaded straight to the wrapper, which hands them to providers.nix. + # With true, providers.nix also registers the locked plugins' remote plugins for that host at + # build time (#85); the farm is added to that hand-over below. withPython3 ? null, withRuby ? null, withNodeJs ? null, @@ -183,9 +185,13 @@ let withRuby withNodeJs extraPython3Packages + farm vimAlias viAlias ; + # Dev plugins (devPlugins and localPlugins) are left to :UpdateRemotePlugins: their working-tree + # paths are not store paths, so the user's own registrations of them must keep winning (#85). + rpluginExclude = builtins.attrNames devDirs; }; in { diff --git a/nix/lib/providers.nix b/nix/lib/providers.nix index 6749e01..ad34c74 100644 --- a/nix/lib/providers.nix +++ b/nix/lib/providers.nix @@ -1,6 +1,7 @@ # Remote-plugin provider hosts (python3 / ruby / node) for the wrapped neovim (#73). # Returns what nix/lib/wrapper.nix needs and nothing else: `rc`, a Lua file to run with --cmd -# (null when there is nothing to say), and `wrapperArgs`, a flat list of makeWrapper arguments. +# (null when there is nothing to say), `wrapperArgs`, a flat list of makeWrapper arguments, and +# `manifest`, the build-time remote-plugin manifest (#85; null unless a host is true, see below). # # Each with* argument has three states, and the third is why this file writes its own lines # instead of reusing wrapNeovimUnstable's providerLuaRc, which only knows two: @@ -11,6 +12,9 @@ # false -> vim.g.loaded__provider = 0: the provider is off, and neovim skips the search. # true and false mean what they mean in home-manager's programs.neovim, so a value carried over # from there keeps its meaning; null is the state programs.neovim does not have. +# +# true additionally registers the remote plugins of that host at build time (nix/lib/rplugin-manifest.nix) +# and has the rc load the result. null and false change nothing, so those wrappers stay as they were. { pkgs }: { package, @@ -18,6 +22,10 @@ withRuby ? null, withNodeJs ? null, extraPython3Packages ? (_: [ ]), + # For the remote-plugin manifest (#85): the plugin farm to register from, and the farm entries + # left to :UpdateRemotePlugins (dev plugins). Without a farm there is no manifest. + farm ? null, + rpluginExclude ? [ ], }: let inherit (pkgs) lib; @@ -84,13 +92,43 @@ let # python3 needs nothing: g:python3_host_prog is executed directly. runtimeDeps = lib.optional (withRuby == true) rubyEnv ++ lib.optional (withNodeJs == true) pkgs.nodejs; + + # The hosts that are on, i.e. the ones whose remote plugins get registered at build time (#85). + # null and false register nothing and leave the rc and the wrapper exactly as they were. + manifestHosts = lib.concatLists [ + (lib.optional (withPython3 == true) "python3") + (lib.optional (withRuby == true) "ruby") + (lib.optional (withNodeJs == true) "node") + ]; + # Today's rc: the vim.g.*_host_prog lines. Also what the manifest derivation runs with --cmd. + hostRc = pkgs.writeText "nvimx-providers.lua" (lib.concatStringsSep "\n" lines + "\n"); + manifest = + if farm != null && manifestHosts != [ ] then + import ./rplugin-manifest.nix { inherit pkgs; } { + inherit package farm hostRc; + hosts = manifestHosts; + path = runtimeDeps; + exclude = rpluginExclude; + } + else + null; + loadLine = "dofile(${lib.generators.toLua { } "${../../lua/nvimx/rplugin-load.lua}"})(${ + lib.generators.toLua { } { + manifest = "${manifest}"; + hosts = manifestHosts; + store = builtins.storeDir + "/"; + } + })"; in { + inherit manifest; rc = if lines == [ ] then null + else if manifest == null then + hostRc else - pkgs.writeText "nvimx-providers.lua" (lib.concatStringsSep "\n" lines + "\n"); + pkgs.writeText "nvimx-providers.lua" (lib.concatStringsSep "\n" (lines ++ [ loadLine ]) + "\n"); wrapperArgs = lib.optionals (runtimeDeps != [ ]) [ "--suffix" "PATH" diff --git a/nix/lib/rplugin-manifest.nix b/nix/lib/rplugin-manifest.nix new file mode 100644 index 0000000..d694417 --- /dev/null +++ b/nix/lib/rplugin-manifest.nix @@ -0,0 +1,63 @@ +# Build-time remote-plugin manifest (#85): what :UpdateRemotePlugins would write for the plugins +# in the farm, produced while nvimx builds so that nothing has to run after a switch or an update. +# See lua/nvimx/rplugin-gen.lua for what runs inside neovim, and lua/nvimx/rplugin-load.lua for how +# the result is loaded at run time. +# package the neovim that generates (not the final wrapper: the wrapper's rc depends on this +# derivation, so using it here would be circular) +# farm the plugin farm; every entry is a candidate +# hosts host names to register for ("python3" / "ruby" / "node"); only these are staged, so +# a host the user did not turn on is never searched for or started +# hostRc today's provider rc (the vim.g.*_host_prog lines), run with --cmd +# path what the hosts need on PATH while generating (node must be there) +# exclude farm entries to leave alone (dev plugins stay with :UpdateRemotePlugins) +{ pkgs }: +{ + package, + farm, + hosts, + hostRc, + path ? [ ], + exclude ? [ ], +}: +let + inherit (pkgs) lib; +in +pkgs.runCommand "nvimx-rplugin-manifest.vim" { } '' + export HOME=$TMPDIR XDG_CONFIG_HOME=$TMPDIR/config XDG_DATA_HOME=$TMPDIR/data \ + XDG_STATE_HOME=$TMPDIR/state XDG_CACHE_HOME=$TMPDIR/cache + ${lib.optionalString (path != [ ]) "export PATH=${lib.makeBinPath path}:$PATH"} + + # Which plugins have something to register is decided here, at build time: reading the farm at + # evaluation time would be IFD. Each plugin is staged as a directory holding only the rplugin/ + # dirs of hosts that are on, symlinked from the farm. neovim resolves symlinks when it records a + # path, so the manifest names the plugin's own store path, the same string a user's own + # :UpdateRemotePlugins records. + mkdir stage + for dir in ${farm}/*; do + name=$(basename "$dir") + skip= + for ex in lazy.nvim ${lib.escapeShellArgs exclude}; do + if [ "$name" = "$ex" ]; then skip=1; fi + done + if [ -n "$skip" ]; then continue; fi + for host in ${lib.escapeShellArgs hosts}; do + if [ -d "$dir/rplugin/$host" ]; then + mkdir -p "stage/$name/rplugin" + ln -s "$dir/rplugin/$host" "stage/$name/rplugin/$host" + fi + done + done + + echo '" Generated by nvimx at build time (#85) for: ${lib.concatStringsSep " " hosts}' > $out + # Nothing staged: no neovim run at all, and the manifest is the header only. + if [ -n "$(ls -A stage)" ]; then + # Upstream's variable, read when runtime/plugin/rplugin.vim is sourced; the raw manifest + # lands here and rplugin-gen.lua copies it to stdout after checking it. + export NVIM_RPLUGIN_MANIFEST=$TMPDIR/raw.vim + # -u NORC, not -u NONE: with NONE no plugin is loaded and :UpdateRemotePlugins is not defined. + # timeout: a host that hangs would otherwise hang the build; a failing plugin is not fatal + # (the plugin is left out and the error stays in the log), but a hang or a crash is. + timeout 600 ${package}/bin/nvim -u NORC -i NONE -n --cmd "luafile ${hostRc}" \ + -l ${../../lua/nvimx/rplugin-gen.lua} "$PWD/stage" "$TMPDIR/raw.vim" >> $out + fi +'' diff --git a/nix/lib/wrapper.nix b/nix/lib/wrapper.nix index 5ae1db3..dae430c 100644 --- a/nix/lib/wrapper.nix +++ b/nix/lib/wrapper.nix @@ -10,6 +10,9 @@ withRuby ? null, withNodeJs ? null, extraPython3Packages ? (_: [ ]), + # The plugin farm and the entries of it that :UpdateRemotePlugins keeps (#85), for providers.nix + farm ? null, + rpluginExclude ? [ ], vimAlias ? false, viAlias ? false, }: @@ -63,6 +66,8 @@ let withRuby withNodeJs extraPython3Packages + farm + rpluginExclude ; }; providerWrapperArgs = lib.escapeShellArgs ( @@ -100,6 +105,9 @@ in pkgs.symlinkJoin { name = "nvimx-neovim"; paths = [ package ]; + # For checks to inspect. passthru is not part of the derivation, so the byte-identity argument in + # the note on trailingWrapperArgs still holds. + passthru.rpluginManifest = providers.manifest; nativeBuildInputs = [ pkgs.makeWrapper ]; postBuild = '' wrapProgram $out/bin/nvim \ diff --git a/tests/checks/default.nix b/tests/checks/default.nix index f35d69a..1359041 100644 --- a/tests/checks/default.nix +++ b/tests/checks/default.nix @@ -24,6 +24,7 @@ pkgs.lib.genAttrs [ "extra-lua-packages" "extra-wrapper-args" "providers" + "rplugin-manifest" "build-shell" "plugin-drv-phases" "build-network-detect" diff --git a/tests/checks/rplugin-manifest.nix b/tests/checks/rplugin-manifest.nix new file mode 100644 index 0000000..effecd6 --- /dev/null +++ b/tests/checks/rplugin-manifest.nix @@ -0,0 +1,149 @@ +# The build-time remote-plugin manifest (#85). The promise is a remote plugin that answers with no +# :UpdateRemotePlugins -- on a fresh profile, right after a plugin update, and next to whatever +# manifest the user already has -- on nixpkgs' patched neovim and on an upstream-shaped one. +# Scenarios (the script has them in order): +# A fresh HOME. +# B a user manifest left over from the previous plugin version, plus a dev plugin registered +# from a working tree. Without the loader's removal step the old module wins pynvim's +# basename-keyed import and answers with the old version, and the old autocmd spec has no +# handler. +# C a user manifest naming the same paths (the user ran :UpdateRemotePlugins this generation). +# Upstream's remote#host#RegisterPlugin throws on that; nixpkgs' patch returns instead. +# E --clean loads nothing, as for runtime's own manifest. +# F g:loaded_remote_plugins set before plugins load: runtime's rplugin.vim finishes early, but +# SourcePost still fires, so the loader must not switch rplugin back on. +# G ruby and node hosts at run time. +# `upstream` strips nixpkgs' system_rplugin_manifest.patch from the runtime, for generation and run +# time alike, which covers a neovim-nightly-overlay package without depending on one. +# The hosts and fetches are those of checks.providers. Generation is non-fatal for a plugin that +# fails to register (it is left out, as with programs.neovim), so the manifest assertions here are +# the only thing that goes red when node's PATH does not reach generation. +{ + pkgs, + nvimxLib, + self, + home-manager, + ... +}: +let + inherit (pkgs) lib; + fixture = ../fixtures/rplugin-plugin; + mkPlugin = + version: + pkgs.runCommand "nvimx-rplugin-${version}" { } '' + cp -r ${fixture} $out + chmod -R u+w $out + substituteInPlace $out/rplugin/python3/nvimx_rplugin_test.py --replace-fail @version@ ${version} + ''; + # The basic-config lock (only tokyonight.nvim), the plugin replaced outright as in + # checks.plugins-overrides. + mkEnv = + package: version: extra: + nvimxLib.makeEnv ( + { + inherit package; + lockDir = ../fixtures/basic-config/nvimx-lock; + withPython3 = true; + plugins.overrides."tokyonight.nvim" = _: mkPlugin version; + } + // extra + ); + + # An upstream-shaped neovim without rebuilding it: the same binary on a runtime with the + # nixpkgs patch reverted. If nixpkgs drops the patch, the runtime is copied unchanged, which + # then already is upstream's. + upstreamRuntime = pkgs.runCommand "nvimx-upstream-runtime" { } '' + cp -r ${pkgs.neovim-unwrapped}/share/nvim/runtime $out + chmod -R u+w $out + ${lib.concatMapStringsSep "\n" + (p: '' + patch -R -p2 -d $out < ${p} + '') + ( + lib.filter ( + p: lib.hasSuffix "system_rplugin_manifest.patch" (toString p) + ) pkgs.neovim-unwrapped.patches + ) + } + # controls: without these the upstream runs below could pass vacuously + grep -q "is already registered" $out/autoload/remote/host.vim + ! grep -q NVIM_SYSTEM_RPLUGIN_MANIFEST $out/plugin/rplugin.vim + ''; + upstreamNeovim = pkgs.symlinkJoin { + name = "neovim-upstream-runtime"; + # the first path wins bin/nvim + paths = [ + (pkgs.writeShellScriptBin "nvim" ''VIMRUNTIME=${upstreamRuntime} exec ${pkgs.neovim-unwrapped}/bin/nvim "$@"'') + pkgs.neovim-unwrapped + ]; + }; + + v1 = mkEnv pkgs.neovim-unwrapped "v1" { }; + v2 = mkEnv pkgs.neovim-unwrapped "v2" { }; + v2u = mkEnv upstreamNeovim "v2" { }; + # devPlugins: left to :UpdateRemotePlugins, so the manifest registers nothing + dev = mkEnv pkgs.neovim-unwrapped "v2" { devPlugins = [ "tokyonight.nvim" ]; }; + multi = mkEnv pkgs.neovim-unwrapped "v2" { + withNodeJs = true; + withRuby = true; + }; + untouched = mkEnv pkgs.neovim-unwrapped "v2" { withPython3 = null; }; + off = mkEnv pkgs.neovim-unwrapped "v2" { + withPython3 = false; + withRuby = false; + withNodeJs = false; + }; + + moduleEval = home-manager.lib.homeManagerConfiguration { + inherit pkgs; + modules = [ + self.homeModules.nvimx + { + home.username = "nvimx-test"; + home.homeDirectory = "/home/nvimx-test"; + home.stateVersion = "25.05"; + programs.nvimx = { + enable = true; + configDir = ../fixtures/basic-config; + lockDir = ../fixtures/basic-config/no-such-lock; + withPython3 = true; + }; + } + ]; + }; + + failures = + lib.optional ( + untouched.wrapped.rpluginManifest != null + ) "withPython3 = null must not build a manifest" + ++ lib.optional (off.wrapped.rpluginManifest != null) "every host false must not build a manifest" + ++ lib.optional ( + moduleEval.config.programs.nvimx.env.wrapped.rpluginManifest == null + ) "the hm module's withPython3 = true must reach the manifest"; +in +pkgs.runCommand "rplugin-manifest" + { + nativeBuildInputs = [ pkgs.gnugrep ]; + v1Manifest = v1.wrapped.rpluginManifest; + v1Plugin = v1.pluginDrvs."tokyonight.nvim"; + v2Manifest = v2.wrapped.rpluginManifest; + v2Plugin = v2.pluginDrvs."tokyonight.nvim"; + devManifest = dev.wrapped.rpluginManifest; + multiManifest = multi.wrapped.rpluginManifest; + v2Wrapped = v2.wrapped; + v2uWrapped = v2u.wrapped; + multiWrapped = multi.wrapped; + untouchedWrapped = untouched.wrapped; + offWrapped = off.wrapped; + inherit upstreamNeovim upstreamRuntime; + initLua = ../fixtures/rplugin-config/init.lua; + driver = ../rplugin-manifest-test.lua; + failures = lib.concatStringsSep "\n" failures; + } + '' + if [ -n "$failures" ]; then + echo "$failures" >&2 + exit 1 + fi + source ${./scripts/rplugin-manifest.sh} + '' diff --git a/tests/checks/scripts/rplugin-manifest.sh b/tests/checks/scripts/rplugin-manifest.sh new file mode 100644 index 0000000..418e4a7 --- /dev/null +++ b/tests/checks/scripts/rplugin-manifest.sh @@ -0,0 +1,124 @@ +# shellcheck shell=bash disable=SC2154 +# Body of checks.rplugin-manifest, sourced by the runCommand in ../rplugin-manifest.nix. +# The *Manifest / *Plugin / *Wrapped variables come from that derivation's env. +export HOME=$TMPDIR + +# --- the manifest's content --- +# The plugin's resolved store path, not /: that is what a user's own +# :UpdateRemotePlugins records too. +py="$v2Plugin/rplugin/python3/nvimx_rplugin_test.py" +grep -qF "RegisterPlugin('python3', '$py'" "$v2Manifest" +for spec in NvimxRpluginTest NvimxRpluginCmd NvimxRpluginPing; do + grep -q "$spec" "$v2Manifest" +done +# A plugin that fails to register is left out and named, and the build still went green. +if grep -v '^"' "$v2Manifest" | grep -q nvimx_rplugin_broken; then + echo "a plugin that cannot load was registered" >&2 + exit 1 +fi +grep -q '^" not registered: .*nvimx_rplugin_broken.py' "$v2Manifest" +# Hosts that are not on never see their plugins. +if grep -q 'rplugin/node\|rplugin/ruby' "$v2Manifest"; then + echo "a python3-only manifest names a node or ruby plugin" >&2 + exit 1 +fi +# devPlugins are left to :UpdateRemotePlugins. +if grep -q RegisterPlugin "$devManifest"; then + echo "a dev plugin was registered by the build" >&2 + exit 1 +fi +# Both directions, because a failing plugin does not fail the build: only this turns red when +# node's PATH (runtimeDeps) stops reaching generation. +grep -q "RegisterPlugin('node', '.*/rplugin/node/nvimx_rplugin_node.js'" "$multiManifest" +grep -q NvimxRpluginNode "$multiManifest" +grep -q "RegisterPlugin('ruby', '.*/rplugin/ruby/nvimx_rplugin_ruby.rb'" "$multiManifest" +grep -q NvimxRpluginRuby "$multiManifest" +if grep '^" not registered' "$multiManifest" | grep -q 'rplugin/node\|rplugin/ruby'; then + echo "a node or ruby plugin did not register" >&2 + exit 1 +fi + +# null and false: nothing of this is in the wrapper (test -f first, so grep -q cannot pass on a +# missing file). +for w in "$untouchedWrapped" "$offWrapped"; do + test -f "$w/bin/nvim" + if grep -q rplugin "$w/bin/nvim"; then + echo "$w mentions rplugin with no provider turned on" >&2 + exit 1 + fi +done +rc=$(grep -o '/nix/store/[^ ]*nvimx-providers.lua' "$offWrapped/bin/nvim" | head -n1) +test -f "$rc" +if grep -q rplugin "$rc"; then + echo "the providers rc mentions rplugin with every host false" >&2 + exit 1 +fi + +# --- the controls for the upstream runs --- +grep -q "$upstreamRuntime" "$upstreamNeovim/bin/nvim" +# B needs the old plugin to still exist: a manifest naming it is not enough for the collision. +test -d "$v1Plugin/rplugin" + +# A dev plugin as a user's manifest records it, working tree outside the store. +mkdir -p dev/rplugin/python3 +cat >dev/rplugin/python3/nvimx_rplugin_dev.py <<'PY' +import pynvim + + +@pynvim.plugin +class NvimxRpluginDev(object): + def __init__(self, nvim): + self.nvim = nvim + + @pynvim.function("NvimxRpluginDev", sync=True) + def dev(self, args): + return "dev" +PY + +fresh_home() { + HOME=$(mktemp -d) + export HOME + mkdir -p "$HOME/.local/share/nvim" +} +run() { # + local w=$1 + shift + timeout 300 "$w/bin/nvim" "$@" +} + +for w in "$v2Wrapped" "$v2uWrapped"; do + # A: fresh + fresh_home + run "$w" -u "$initLua" -l "$driver" run v2 + + # B: after a plugin update, with the previous version's manifest and a dev plugin in it + fresh_home + cp "$v1Manifest" "$HOME/.local/share/nvim/rplugin.vim" + chmod u+w "$HOME/.local/share/nvim/rplugin.vim" + cat >>"$HOME/.local/share/nvim/rplugin.vim" < { + plugin.registerFunction("NvimxRpluginNode", () => "node", { sync: true }); +}; diff --git a/tests/fixtures/rplugin-plugin/rplugin/python3/nvimx_rplugin_broken.py b/tests/fixtures/rplugin-plugin/rplugin/python3/nvimx_rplugin_broken.py new file mode 100644 index 0000000..b59597f --- /dev/null +++ b/tests/fixtures/rplugin-plugin/rplugin/python3/nvimx_rplugin_broken.py @@ -0,0 +1 @@ +import nvimx_no_such_module # noqa: F401 diff --git a/tests/fixtures/rplugin-plugin/rplugin/python3/nvimx_rplugin_test.py b/tests/fixtures/rplugin-plugin/rplugin/python3/nvimx_rplugin_test.py new file mode 100644 index 0000000..4d487e1 --- /dev/null +++ b/tests/fixtures/rplugin-plugin/rplugin/python3/nvimx_rplugin_test.py @@ -0,0 +1,20 @@ +import pynvim + + +@pynvim.plugin +class NvimxRpluginTest(object): + def __init__(self, nvim): + self.nvim = nvim + + @pynvim.function("NvimxRpluginTest", sync=True) + def test(self, args): + return "@version@" + + @pynvim.command("NvimxRpluginCmd", sync=True) + def cmd(self): + self.nvim.vars["nvimx_rplugin_cmd"] = "@version@" + + @pynvim.autocmd("User", pattern="NvimxRpluginPing", sync=True) + def ping(self): + pings = self.nvim.vars.get("nvimx_rplugin_ping", []) + self.nvim.vars["nvimx_rplugin_ping"] = list(pings) + ["@version@"] diff --git a/tests/fixtures/rplugin-plugin/rplugin/ruby/nvimx_rplugin_ruby.rb b/tests/fixtures/rplugin-plugin/rplugin/ruby/nvimx_rplugin_ruby.rb new file mode 100644 index 0000000..e66e8db --- /dev/null +++ b/tests/fixtures/rplugin-plugin/rplugin/ruby/nvimx_rplugin_ruby.rb @@ -0,0 +1,3 @@ +Neovim.plugin do |plug| + plug.function(:NvimxRpluginRuby, sync: true) { |nvim| "ruby" } +end diff --git a/tests/rplugin-manifest-test.lua b/tests/rplugin-manifest-test.lua new file mode 100644 index 0000000..1906749 --- /dev/null +++ b/tests/rplugin-manifest-test.lua @@ -0,0 +1,32 @@ +-- Driver for checks.rplugin-manifest, run as `nvim -u -l this.lua [args]`. +-- A failed assert makes nvim exit non-zero. +local mode = arg[1] + +if mode == "absent" then + -- --clean, or rplugin disabled: nothing may have been registered + assert(vim.fn.exists("#FuncUndefined#NvimxRpluginTest") == 0, "the manifest was loaded when it must not be") + return +end + +if mode == "hosts" then + assert(vim.fn.NvimxRpluginNode() == "node", "the node remote plugin did not answer") + assert(vim.fn.NvimxRpluginRuby() == "ruby", "the ruby remote plugin did not answer") + return +end + +assert(mode == "run", "unknown mode " .. tostring(mode)) +local want, dev = arg[2], arg[3] +assert(vim.fn.NvimxRpluginTest() == want, "NvimxRpluginTest() answered the wrong version") +vim.cmd("NvimxRpluginCmd") +assert(vim.g.nvimx_rplugin_cmd == want, "NvimxRpluginCmd set " .. vim.inspect(vim.g.nvimx_rplugin_cmd)) +vim.cmd("doautocmd User NvimxRpluginPing") +-- exactly once: a stale registration would fire a second (or the wrong) handler +assert(vim.deep_equal(vim.g.nvimx_rplugin_ping, { want }), "ping handlers: " .. vim.inspect(vim.g.nvimx_rplugin_ping)) +-- the plugin was never put on the rtp by lazy: the manifest alone made it work +local plugin = require("lazy.core.config").plugins["tokyonight.nvim"] +assert(plugin._.loaded == nil, "tokyonight.nvim was loaded; this proves nothing about the manifest") +local messages = vim.fn.execute("messages") +assert(not messages:find("nvimx:", 1, true), "the loader warned: " .. messages) +if dev then + assert(vim.fn[dev]() == "dev", "the dev plugin registered by the user's manifest stopped answering") +end