Modular Neovim with Nix
Expanding your Neovim configuration on per-project basis using Nix and devenv.
This blog post explains how I modularized my Neovim configuration on a per-project using Nix and devenv. The initial motivation was to reduce the size of my Neovim installation. The idea was to have a smaller core installation with essential settings and packages. This would allow adding Neovim to all my servers and development machines without pulling in a lot of dependencies.
The expansions would work using Nix integration. In each project, say
in Rust, nodejs, or BQN, I would define project-specific additional
packages and extensions in the project’s flake.nix
file.
This keeps my core installation small and tidy while allowing Neovim to
expand “just in time” when needed. Thanks to direnv
, the
project-specific tools unload from my path when I leave the project
folder.
The key approach to this was the nixvim project. It allows Neovim to be configured using Nix, by packaging a custom version of Neovim. Doing this requires advanced familiarity with Nix, an excellent way to introduce these concepts.
The shell environment
In the Nix ecosystem, various projects leverage per-folder environments to pin dependencies. Examples of this are direnv and devenv. The motivation is to establish something akin to Fig, which recently published that it was being acquired by AWS.
The extrapolation behind the rationale of the acquisition works somewhat as follows: when AWS can hook into the developer’s shell environment, they can provide additional value by making it easy to consume and conjure new AWS services on the go (… and occasionally forget about them). In the shell environment, AWS has additional context of what the developer is working on, which allows pinpointing new cloud subscriptions directly onto that project by essentially using the folder path as an identifier.
In the Nix world, we do the same thing using direnv
and
devenv
by leveraging Nix abstractions. The basic idea is
that you have a configuration file in the project root folder to define
packages
that are fetched using Nix from the package repository. This
file integrates with direnv’s .envrc
file, which your shell
scans on each cd
command. Nix will install those packages
transiently into your path if the file is present, using a version
defined in a flake.lock
lock file. This way, Nix Flakes
allows different versions of the same package to coexist within an
organization’s repositories. This aids in the reproducibility of the
developer environment as the lock file determines which version of some
software is needed – avoiding the “works on my machine” tantrum.
Modularizing Neovim with NixVim
So, now that the .envrc
file hooks into the shell and
loads the packages defined with devenv
using Nix Flakes’
flakes.lock
lock file to determine the correct version, how
do we continue with Neovim?
Here, the NixVim project allows us to define the required packages as such:
{
programs.nixvim = {
enable = true;
colorschemes.gruvbox.enable = true;
plugins.lightline.enable = true;
};
}
This will effectively generate the required Lua and VimScript files to the user’s configuration folder, install Neovim, and add it to your shell path. However, to extend this file dynamically, we have to be able to read the declaration that was used to create them.
The solution to extend the configuration is called
nixosModules
, one of the interfaces Nix Flakes produces.
Nix Flakes is an interface that produces various methods in the
so-called output schema.
These methods have various additional tooling hooked up to them. For
example, if you declare a new package using the
packages."<system>"."<name>" = derivation;
method, where "<system>"
is your system architecture
like aarch64-darwin
and "<name>"
is the
package name like neovim
, then you can use a command
nix build .#neovim
to produce the output of the packaging
script. The build command semantics also hide an abstraction: the
.
part is the location of the Nix Flakes interface to read.
When it is .
, it means the current directory, but it could
also be nixpkgs
, meaning the canonical package repository,
or alternatively any local or remote git path. For example, running
nix build nixpkgs#cowsay
would build the
cowsay
program.
In this sense, the Nix commands are a view of some Nix
Flakes repository. Nix Flakes also allows any arbitrary entry points to
be defined. For example, if I would declare a new method called
user
and define it as user = "juuso"
, I could
evaluate this value by running
nix eval --raw github:jhvst/nix-config#user
to get the
output juuso
to my shell.
For options such as the Neovim configuration, it is more idiomatic to
use the nixosModules
method. This is because here, programs
can assume it can find the corresponding schema under it, which is of
the form { config }: { options = {}; config = {}; };
.
Luckily, the NixVim authors have thought about this
composability, for which they have implemented a method called
makeNixvimWithModule
. This method will consume a
nixosModule
as its configuration. We can thus do the
following:
{system}.makeNixvimWithModule {
packages.neovim = nixvim.legacyPackages.$inherit pkgs;
module = {
imports = [
inputs.juuso.outputs.nixosModules.neovim];
plugins.lightline.enable = true;
};
};
Which will build us a Neovim configuration by fetching the view
nixosModules.neovim
from our input
github:jhvst/nix-config
. Because this is now a package
declaration, we can run nix run .#neovim
, which will fetch
our declaration from git, create a lock file, and then add the
lightline
plugin.
This works with some caveats.
Filling the gaps with overlays
The approach above is fine if the configuration does not
have user-defined packages or modifications compared to the main nixpkgs
repository. However, I use the himalaya-vim
package, which
has a build error on macOS. I have patched the package to include
macOS-specific tools but must convey the patched version downstream onto
the view.
The nixosModules
view has no direct way to be aware of
these overwrites, so we need overlays to propagate changes. Overlays
allow us to add or rewrite packages to nixpkgs without upstreaming the
changes. Adding this to integrate with nixosModules
requires redefining the pkgs
repository to be a view of
nixpkgs defined by our repository.
This is where it becomes a tad complicated. First, we have to rewrite the nixpkgs as follows:
import inputs.nixpkgs {
_module.args.pkgs = inherit system;
overlays = [
inputs.juuso.overlays.default];
config = { };
};
The overlay is now pointing to our repository, where we have to produce an overlay:
{
overlayAttrs = inherit (config.packages) himalaya;
};
The expression says to create an overlay of our packages and include
the himalaya
package. Now, in our packages definition, we
can do the following:
{
packages = "himalaya" = pkgs.himalaya.overrideAttrs (oldAttrs: {
buildInputs = oldAttrs.buildInputs ++ lib.optionals pkgs.stdenv.hostPlatform.isDarwin [ pkgs.darwin.Security ];
});
};
This means we take the himalaya
package from the
canonical nixpkgs repository but then add
pkgs.darwin.Security
to the darwin
platform.
In this case, it fixes the build build problem which exists
upstream.
And this works! We can now fetch our base Neovim configuration while preserving any derivations of the upstream by providing our view of the canonical package repository.
Putting it to together
Now, we can try it out. I used my project called barbell
for starters. This is a good candidate because it is written in a
language called BQN,
which does not have its editor packages in the main nixpkgs
repository.
What happens behind the scenes is that when we cd
into
the barbell
repository, my shell fish
finds
the .envrc
file and then uses devenv
to find
the packages that have to be installed. Nix builds those packages and
adds them to our path. The devenv packages can also be configured in the
flakes.nix
file.
The definition with all the required changes looks as so:
"sourcehut:~detegr/bqnlsp";
bqnlsp.url = "github:cachix/devenv";
devenv.url = "github:jhvst/nix-config";
juuso.url =
...
[
imports =
inputs.devenv.flakeModule];
...
import inputs.nixpkgs {
_module.args.pkgs = inherit system;
overlays = [
inputs.juuso.overlays.default];
config = { };
};
...
{system}.makeNixvimWithModule {
packages.neovim = nixvim.legacyPackages.$inherit pkgs;
module = {
imports = [
inputs.juuso.outputs.nixosModules.neovim];
extraPackages = with pkgs; [
# bqnlsp assumes cbqn in path
cbqn ];
extraConfigVim = ''
au BufRead,BufNewFile *.bqn setf bqn
au BufRead,BufNewFile * if getline(1) =~ '^#!.*bqn$' | setf bqn | endif
'';
plugins.lsp = {
enable = true;
preConfig = ''
local configs = require('lspconfig.configs')
local util = require('lspconfig.util')
if not configs.bqnlsp then
configs.bqnlsp = {
default_config = {
cmd = { 'bqnlsp' },
cmd_env = {},
filetypes = { 'bqn' },
root_dir = util.find_git_ancestor,
single_file_support = false,
},
docs = {
description = [[ BQN Language Server ]],
default_config = {
root_dir = [[util.find_git_ancestor]],
},
},
}
end
'';
};
extraPlugins = [
{system}.lsp
inputs.bqnlsp.packages.$(pkgs.vimUtils.buildVimPluginFrom2Nix {
pname = "bqn-vim";
version = pkgs.mbqn.version;
src = pkgs.mbqn.src;
sourceRoot = "source/editors/vim";
meta.homepage = "https://github.com/mlochbaum/BQN/editors/vim";
})
(pkgs.vimUtils.buildVimPluginFrom2Nix {
pname = "nvim-bqn";
version = "unstable";
src = builtins.fetchGit {
url = "https://git.sr.ht/~detegr/nvim-bqn";
rev = "bbe1a8d93f490d79e55dd0ddf22dc1c43e710eb3";
};
meta.homepage = "https://git.sr.ht/~detegr/nvim-bqn/";
})
];
};
};
...
{
devenv.shells.default = packages = [
config.packages.neovim];
};
- Nix pulls the remotes from git for LSP of BQN, devenv, and my configuration.
- It makes a lock file for each independently.
- devenv hooks with Nix.
- we define our overlay to get the additional packages our configuration requires.
- we define to build a new version of Neovim using our base config.
- we add specific packages and configurations to BQN.
- we define that the patched derivation of Neovim should be added to
the shell path using
devenv
.
We can now run nix run .#neovim -- example.bqn
, which
will open example.bqn
in Neovim (arguments to the program
fetched via nix run
have to be separated with double
dashes) with all project-specific addons installed!
This works even without cloning the repository:
nix run github:jhvst/barbell#neovim -- example.bqn
.
Nifty!
One more thing
Now, we have a transient version of Neovim that uses our base configuration in our projects. What about installing the base configuration as our default one?
In my nix-config
repository, I can define the base
configuration of Neovim to be added to my user path using
home-manager:
-manager.users.juuso.programs.nixvim = let neovim = (import ../../../nixosModules/neovim) { inherit config pkgs; }; in with neovim.config; {
homeinherit colorschemes extraConfigVim extraConfigLua extraPackages plugins extraPlugins;
enable = true;
viAlias = true;
vimAlias = true;
};
This will read the local nixosModule while inheriting (bringing variables into the scope) the necessary fields into our local NixVim configuration block.
However, we still need help adding overlays to our local configuration. This is arguably a bit simpler: wherever we define the Nix configuration for our computer, we also have to add a nixpkgs overlays definition:
[
nixpkgs.overlays =
outputs.overlays.default];
This now rewrites the packages even in our own configuration to use
the overlays we defined for himalaya
.
Done!
Where is the source?
The code for our nixosModule
can be found here.
The definition for the overlays is here.
Our package overlay is here.
The BQN barbell configuration in its completeness is here.
The takeaway
As a result, we have successfully modularized our Neovim
configuration on a per-project basis. This keeps our base configuration
slim while allowing it to be extended declaratively. We can update our
configuration at will, but because every remote project uses its own
lock file, those are pinned to the moment of definition. If we need to
update the remote definitions in barbell
to reflect changes
in our upstream, we can run nix flake update
.
Now, anyone with Nix can copy and launch my Neovim configuration
using nix run github:jhvst/nix-config#neovim
. This is
possible because Nix Flakes works as a view to a composition of
git-managed resources. For example, programming language communities
could provide their own derivations of editors. This could help new
users with the learning curve.
How long did it take to get working? One day. I have used Nix in
production at my company for over a year now, though (see: homestaking-infra.
Nevertheless, this opens new ways to think about developer environments.
Reflecting on Fig, we could also expand the devenv
integration with SSH servers, CI, or environment variables. But with
Nix, we are not tied to a single service provider like AWS for those
resources and integrations.
To give this a try, make sure you have Nix installed and run
nix run github:jhvst/barbell#neovim -- example.bqn
.