Neovim wrapper with Nix from scratch
26 September 2024
·
11 min read
What if told you, that you can have a portable Neovim configuration, that runs
on any system that has Nix? And that you only need a single nix run
command to
execute it, without having to clone your .config/neovim
and install your
plugins?
In this tutorial, I want to guide you how to write your own Neovim wrapper from
scratch, in the same spirit as
Linux for Scratch (I haven’t actually read it).
Wrapping Neovim, means that we create a new “fake” nvim
executable, which
calls the original one, with different flags or environment variables. Neovim
has different flags that we can use for configuration, for example -u /path/to/init.lua
to a different place. If we create our wrapper with Nix, we
can use a complete suite of software (plugins, extra apps) that wrap Neovim
itself, making it self-contained in the /nix/store
.
Then, you can take your wrapped Neovim to any machine, and simply nix run
or
nix shell
will give you your whole installation.
There are already multiple abstractions that wrap Neovim:
In face of so many alternatives, my answer: I don’t think they are good. The fundamental issue with all of them is that you completely miss how the plugins are handled. Which is a big deal, because it’s not something fundamentally hard. I believe that by writing the wrapper by yourself, you will be more confident in the abstractions that hold up your Neovim configuration.
So, instead of proposing yet another Neovim wrapper: this is Neovim wrapper from scratch (NWFS if you wish), a guide yo writing your own Neovim wrapper with Nix.
TIP
This guide assumes some knowledge of Nix and Neovim.
The big idea
Traditionally, you configure Neovim by editing
~/.config/neovim/init.{vim,lua}
. Instead, we will create a new nvim
executable that passes the path to the configuration, from the /nix/store
.
This will make it self-contained and resilient to changes in your home
directory. And easily shareable.
The easiest way to create a wrapper script (with Nix) is to use a bash script, like the following:
#! /nix/store/.../bin/bash
exec -a "$0" /nix/store/.../bin/nvim -u /nix/store/.../init.lua "$@"
exec
is used to that bash exits when loading Neovim, instead of becoming its parent.-a "$0"
is an argument toexec
which can be used to changeargv[0]
, which is the name of the program. For example, if you executels -la
, it receivesargv = { "ls", "-la" }
. It can be useful to pass-through the originalargv[0]
."$@"
is used to pass-through the rest ofargv
-u init.lua
to make Neovim read our custom startup config.
This script should be named nvim
, while the original nvim
executable will
not be added to PATH
, but only directly addressed by its store path.
Native plugin loading
Neovim has 2 similar concepts that we must understand: packages and plugin (Neovim manual).
- Packages contain plugins inside them. The plugins are loaded either at
startup (
start
plugins) or manually (opt
plugins, with:packadd
). - Plugins modify Neovim’s behavior. These are the repos you find on the internet, usually written in Lua.
A package has the following structure:
<package>
├── start
│ ├── <plugin-a>
│ └── <plugin-b>
└── opt
├── <plugin-x>
└── <plugin-z>
For a regular Neovim installation, you can create your own packages under the
directory: ~/.local/share/nvim/site/pack/<package>
. This is essentially what
plugin managers do: they create this folder, and download plugins that you
declare in your init.lua
into
~/.local/share/nvim/site/pack/<package>/start/<plugin-a>
, etc.
Plugins that are under start
are loaded automatically when Neovim starts
up. In the contrary, a plugin in opt
is only loaded when you run :packadd
.
What does loading mean? For a plugin <plugin>
that Neovim loads, it will try
to execute <plugin>/plugin/init.lua
, register the auto-loading for
fileytypes from <plugin>/after/ftplugin/<language>.lua
, etc. You can read
about the loading mechanism in the manual.
To tie things up, we can control where Neovim looks for packages (remember, a
package is a collection of plugins) by controlling the option packpath
.
We can set packpath
from the commandline, with the generic interface that
--cmd
offers, so --cmd 'set packpath^=/nix/store/...'
.
symlinkJoin
To begin with, we will setup some Nix files to use as base. Following the convention of other Nix projects, we will use 2 files:
- A
neovim.nix
file that can becallPackage
’d - A
default.nix
that we can callnix build
on. You might want to directly call yourneovim.nix
from your flake or NixOS configuration though.
# default.nix
let
pkgs = import <nixpkgs> {};
in
pkgs.callPackage ./neovim.nix {};
# neovim.nix
{
symlinkJoin,
neovim-unwrapped,
}:
symlinkJoin {
name = "neovim-custom";
paths = [neovim-unwrapped];
}
symlinkJoin
is a high-level abstraction of mkDerivation
, that takes some
paths
, and create a symlink for every path in the target derivation. This
allows us to create a “clone” of the original application, that saves space by
using symlinks instead of copying files. We will use it as the base to create
our wrapper.
We can build this package with nix build -f ./default.nix
— or with
nix-build
for the v2 CLI.
wrapProgram
wrapProgram
is a bash function that creates a wrapper automatically. I usually
check its source code to know the arguments it accepts.
You can use the bash function wrapProgram
by adding the derivation
makeWrapper
to your nativeBuildInputs
. Then, we can use it to pass -u
to
nvim
:
# neovim.nix
{
symlinkJoin,
neovim-unwrapped,
makeWrapper,
}:
symlinkJoin {
name = "neovim-custom";
paths = [neovim-unwrapped];
nativeBuildInputs = [makeWrapper];
postBuild = ''
wrapProgram $out/bin/nvim \
--add-flags '-u' \
--add-flags '${}'
'';
}
Hooray!
Loading plugins with a package
As we know, the primitive to load plugins in Neovim is a package, which is a
container for different plugins, which can be start
(loaded automatically) or
opt
(loaded manually with :packadd
). We can set the packpath
option, which
is a folder that contains different packages. Let’s start by creating our
package path, which will contain a single package, that will contain itself all
out plugins:
# neovim.nix
{
symlinkJoin,
neovim-unwrapped,
makeWrapper,
runCommandLocal,
vimPlugins,
lib,
}: let
packageName = "mypackage";
startPlugins = [
vimPlugins.plenary-nvim
vimPlugins.telescope-nvim
];
packpath = runCommandLocal "packpath" {} ''
mkdir -p $out/pack/${}/{start,opt}
${
}
'';
in
symlinkJoin {
name = "neovim-custom";
paths = [neovim-unwrapped];
nativeBuildInputs = [makeWrapper];
postBuild = ''
wrapProgram $out/bin/nvim \
--add-flags '-u' \
--add-flags '${}'
'';
passthru = {
inherit packpath;
};
}
In this code snippet, we create a packpatch
that follows the directory
structure that Neovim requires. We can pass it through with passhtru
, to be
able to build it directly:
$ nix build -f ./default.nix packpath
$ eza --tree result/
result
└── pack
└── mypackage
├── opt
└── start
└── telescope.nvim -> /nix/store/...-vimplugin-lua5.1-telescope.nvim-scm-1-unstable-2024-08-02
Setting packpath and NVIM_APPNAME
Finally, we can pass --cmd
to set our packpath
and runtimepath
. You can
also set the environment variable NVIM_APPNAME=<something>
, which will cause
Neovim to read different paths, potentially ignoring leftovers of other Neovim
configuration. This is nice, because it will make our configuration more
“independent” of the host system.
TIP
Remember to also set runtimepath
# neovim.nix
{
symlinkJoin,
neovim-unwrapped,
makeWrapper,
runCommandLocal,
vimPlugins,
lib,
}: let
packageName = "mypackage";
startPlugins = [
vimPlugins.plenary-nvim
vimPlugins.telescope-nvim
];
packpath = runCommandLocal "packpath" {} ''
mkdir -p $out/pack/${}/{start,opt}
${
}
'';
in
symlinkJoin {
name = "neovim-custom";
paths = [neovim-unwrapped];
nativeBuildInputs = [makeWrapper];
postBuild = ''
wrapProgram $out/bin/nvim \
--add-flags '-u' \
--add-flags '${}' \
--add-flags '--cmd' \
--add-flags "'set packpath^=${} | set runtimepath^=${}'" \
--set-default NVIM_APPNAME nvim-custom
'';
passthru = {
inherit packpath;
};
}
NOTE
Be careful with the quoting around --add-flags "'set ...'"
. I had to add
these so that wrapProgram
doesn’t split it into multiple strings. You can
check the resulting wrapper to check if everythin is OK:
$ cat result/bin/nvim
#! /nix/store/izpf49b74i15pcr9708s3xdwyqs4jxwl-bash-5.2p32/bin/bash -e
export NVIM_APPNAME=${NVIM_APPNAME-'nvim-custom'}
exec -a "$0" "/nix/store/jvqc319jklmnqfbfw6c058fr262dlr2w-neovim-custom/bin/.nvim-wrapped" -u /nix/store/ri095naj7cpgqahq50arfq0nn2j9kmsr-init.lua --cmd 'set packpath^=/nix/store/ji9z2g9m2gg5isp213v45vig1x8q0x3c-packpath | set runtimepath^=/nix/store/ji9z2g9m2gg5isp213v45vig1x8q0x3c-packpath' "$@"
tree-sitter and dependencies
The plugins from nixpkgs, pkgs.vimPlugins
, have an extra field called
dependencies
to mark other plugins that they depend on. Namely, the
tree-sitter plugin uses this to pull the grammars as dependencies.
Speaking of tree-sitter, there are multiple components to it:
nvim-treesitter
, the Lua plugin itself- Grammars, that usually are installed with
:TSInstall
The grammars are packaged in Nix, which will be a better option since we won’t
need to run :TSInstall
, which requires a C compiler on the target machine (and
sometimes nodejs, it’s a mess). To bring the
grammar as dependencies, there are 2 interfaces for that, which are documented
in the nixpkgs manual:
pkgs.vimPlugins.nvim-tresitter.withAllGrammars
— recommended, grammars are very cheap in terms of size.pkgs.vimPlugins.nvim-treesitter.withPlugins
To collect our list of dependencies, we can do some recursive function calls
with builtins.foldl'
, and filter the results with lib.unique
.
# neovim.nix
{
symlinkJoin,
neovim-unwrapped,
makeWrapper,
runCommandLocal,
vimPlugins,
lib,
}: let
packageName = "mypackage";
startPlugins = [
vimPlugins.telescope-nvim
# vimPlugins.plenary-nvim # not needed, since it will be pulled automatically as a dependency
vimPlugins.nvim-treesitter.withAllGrammars
];
foldPlugins = builtins.foldl' (
acc: next:
acc
++ [
next
]
++ (foldPlugins (next.dependencies or []))
) [];
startPluginsWithDeps = lib.unique (foldPlugins startPlugins);
packpath = runCommandLocal "packpath" {} ''
mkdir -p $out/pack/${}/{start,opt}
${
}
'';
in
symlinkJoin {
name = "neovim-custom";
paths = [neovim-unwrapped];
nativeBuildInputs = [makeWrapper];
postBuild = ''
wrapProgram $out/bin/nvim \
--add-flags '-u' \
--add-flags '${}' \
--add-flags '--cmd' \
--add-flags "'set packpath^=${} | set runtimepath^=${}'" \
--set-default NVIM_APPNAME nvim-custom
'';
passthru = {
inherit packpath;
};
}
Finale
That’s it! Creating a wrapper for Neovim is not a difficult task, and I think this Do-It-Yourself approach is simpler to maintain that relying on some Nix framework. Now that your neovim configuration is self-contained with Nix, these are some things that you can do:
- Use it directly on a foreign machine with
nix run github:user/repo#neovim
- Try other people’s config — mine is
nix run github:viperML/dotfiles#neovim
- Investigate
nix bundle
ornix copy
to bring it to machines that don’t use Nix.
Optionals
Before leaving, I want to mention some optional topics that you might want to do too.
Configuration as a plugin
Instead of having a huge init.lua
, you could make your own configuration a
plugin itself. The structure of a plugin is the following:
<plugin>
├── plugin
│ └── init.lua
└── lua
└── <lua module>
├── something.lua
└── init.lua
At first, we create a directory for the plugin, and move our init.lua
into
<plugin>/plugin/init.lua
. Then, we can factor out configuration into its own
Lua module. For example, if we name our Lua module something like myconfig
,
then within our plugin/init.lua
we can require("myconfig")
and
require("myconfig.something")
. This can be helpful to split a large
configuration into different units.
When we create our own plugin, we will use -u NORC
instead of passing our
init.lua
, and also add it to our package-creating derivation:
packpath = runCommandLocal "packpath" {} ''
mkdir -p $out/pack/${}/{start,opt}
ln -vsfT ${} $out/pack/${}/start/myplugin
${
}
'';
Lazy loading with lz.n
One of the things that people dislike about the Nix wrappers is that they don’t
use lazy.nvim. That package manager is
capable of setting hooks automatically, so that plugins are loaded at specic
events. For example, you can configure telescope to only load when you
actually run the command :Telescope
.
This is possible to do manually. If lazy.nvim does it, there must be a way, in the end. But the convenience of abstraction is the useful part.
So, instead, you can use lz.n, a plugin
that can take care of “lazy loading” (quite of an overloaded term). You must
place it as a start
plugin, while plugins that you want to be lazy-loadable
must be opt
plugins. lz.n
doesn’t install plugins itself, but rather provide
an interface for lazy-loading. After adding the plugins to your wrapper, refer to
upstream documentation for configuration.
Plugins not in nixpkgs
As you may have noticed, we bring plugins from pkgs.vimPlugins.<>
. These are
plugins that are pre-packaged in nixpkgs. This is convenient, but you might want
to bring plugins that are not packaged, or use some specific commit or version
for some plugin.
As you know, a Neovim plugin is simply a cloned repository. You can use
pkgs.fetchFromGitHub
and pass it directly to our plugins
list. There is also
pkgs.vimUtils.buildVimPlugin
, which also sets a plugin’s name, so that when we
use lib.getName plugin
in our build script, it gets linked with the proper
name.
plugins = [
(vimUtils.buildVimPlugin {
name = "telescope.nvim"
src = fetchFromGitHub {
owner = "nvim-telescope";
repo = "telescope.nvim";
rev = "cb3f98d935842836cc115e8c9e4b38c1380fbb6b";
hash = ""; # fill with correct hash
};
})
vimPlugins.etc
# ...
];
Another option to using plugins outside of pkgs.vimPlugins
is nvfetcher,
a little program that can generate all the fetchFromGitHub
for you from a
simple TOML specification. I do it for
my Neovim wrapper
, and while passing
around the results from nvfetcher is not trivial, it is definitively doable.
Extra Lua packages
Nixpkgs hosts Lua packages that are mirrored from luarocsk, that you might want to load into your Neovim configuration. To be able to load them, there are 2 options:
- Changing the
LUA_PATH
andLUA_CPATH
environment variables. - Changing the
package.path
andpackage.cpath
Lua globals.
While both work, I don’t like using environment variables. They “leak” into
child processes, which might be undesired — for example, you are editing a Lua
configuration, so you don’t want the LUA_PATH
that is specific to Neovim to
leak into the suprocess of the language server, or something else.
One way to modify the package
global, while having access to the packages from
Nix, is to write a plugin in-place with pkgs.runCommand
:
luaEnv = neovim-unwrapped.lua.withPackages (luaPackages: [
luaPackages.luassert
luaPackages.lua-cjson
]);
inherit (neovim-unwrapped.lua.pkgs.luaLib) genLuaPathAbsStr genLuaCPathAbsStr;
plugins = [
(pkgs.runCommandLocal "init-plugin" {} ''
mkdir -pv $out/plugin
tee $out/plugin/init.lua <<EOF
package.path = "${};" .. package.path
package.cpath = "${};" .. package.cpath
EOF
'')
];
Remote plugins
Remote plugins are plugins that are not written in Lua or Vimscript, but rather in any other language, and are executed as subprocesses of Neovim. Personally I have never encountered one of these, as there is always a Lua-written alternative. The wrapper doesn’t take remote plugins into account.