# Motivation
This post was borne out of my frustrations of repeatedly switching between developing on different servers with various versions of linux installed under the constraints of not having root permissions. Setting up neovim with all the tools I’m used to was quite tedious. My workflow was as follows:
- Clone Neovim nightly (it consistently has really cool features!), install its deps, build it, and stick the result in
$HOME/usr/bin
and add$HOME/usr/bin
to my$PATH
. - Intall system dependencies such as ripgrep or fd.
- Install Vim Plug, wget a gist with my vimrc config in it, and finally,
:PlugInstall
. - Install all the language servers I needed. This was the hard part, as that process differed for each language server. Some I grabbed off a github release, others I built manually from source as to get the most up-to-date version.
This might take me 1-2 hours overall, and is both frustrating and tedious to set up and maintain on a large scale. Each computer is slightly different. My “solution” using nix
turns this into 20 seconds on any linux machine with 3 commands. Fast and easy:
git clone https://github.com/DieracDelta/vimconf_talk.git
cd vimconf_talk && bash setup.sh
source $HOME/.bashrc && nix run .
Please note that the work I’m describing here is not original. I’m (per usual) trying to make something that is reasonably complex understandable to a wider audience. Special thanks to:
- Zach Coyle’s Neovitality Nix distribution was my inspiration for this post. I present a very simple version of what’s possible with Nix. Neovitality reaches for the stars and shows just how much is possible.
- Shadow’s neovim configuration was a great starting point to figure out “how” to configure neovim with Lua.
- Gytis for all the effort he poured into making the vim “nix” experience much better. I’m really enthused to see where his vim2nix project goes.
# Expected Background
This is meant to have a low barrier for entry. I intend the readers to be strangers to the Nix ecosystem (Nix may not even be installed!) but are familiar with configuring Neovim with lua, neovim plugins, and linux.
# Getting Started: obtaining Nix
One can either do an install of the nix onto their distribution of linux or use DavHau’s nix-portable
project. I opt for the latter approach, since it’s easier to set up and less commitment overall (no nix users need to be made and no read only file system is mounted). I wrote some wrapper scripts to ease the setup workflow that I’ll explain here:
#!/usr/bin/env bash
NIX_PORTABLE_LOC="$PWD"
wget https://github.com/DavHau/nix-portable/releases/download/v008/nix-portable
chmod +x nix-portable
NP_LOCATION=$NIX_PORTABLE_LOC NP_RUNTIME='bwrap' $PWD/nix-portable nix
printf "\nsubstituters = https://cache.nixos.org https://jrestivo.cachix.org \ntrusted-public-keys = cache.nixos.org-1:6NCHdD59X431o0gWypbMrAURkbJ16ZPMQFGspcDShjY= jrestivo.cachix.org-1:+jSOsXAAOEjs+DLkybZGQEEIbPG7gsKW1hPwseu03OE=\n" >> $NIX_PORTABLE_LOC/.nix-portable/conf/nix.conf
printf "\nalias nix=\"NP_LOCATION=$NIX_PORTABLE_LOC NP_RUNTIME='bwrap' $PWD/nix-portable nix\"\n" >> $HOME/.bashrc
Nix portable is an awesome project that spins up an unprivileged container with nix
and flakes
installed. We first snag the nix-portable
executable (bash script) on line 2, then run it on line 5. We tell nix-portable
to use $PWD/.nix-portable
as its container root directory by setting NP_LOCATION
, and to use bubblewrap by setting NP_RUNTIME
to bubblewrap.
Line 6 sets up the binary cache. The idea is to have Github CI (we’ll get to this later) build neovim from source with our configuration and all our language servers/plugins and push it to a binary cache (using Cachix). We’ll then pull down this “built” artifact from the binary cache on the server we wish to run on.
We’ll be generating our vim configuration using nix
. As a result, there’s an edit.sh
script that will listen for writes to the flake.nix
file and regenerate our configuration based on its output. We’ll set autoload
in neovim to listen for the change to the generated lua config. The edit script:
#!/usr/bin/env bash
# remember to set autoread in vim to autorefresh the file in vim
NIX_PORTABLE_LOC="$PWD"
find ./*.nix | entr -r bash -c "NP_LOCATION=$NIX_PORTABLE_LOC NP_RUNTIME='bwrap' $PWD/nix-portable nix build .#neovimConfig -o init.nvim_store && cp -f $NIX_PORTABLE_LOC/.nix-portable/store/\$(basename \$(readlink $PWD/init.nvim_store)) $PWD/init.nvim"
entr
is used to listen for a change to nix files. Note that entr
may need to be installed (apt-get install -y entr
on Debian based distros). Upon change, we use nix-portable
to build the config (.#my_config
) and stick it in $PWD/init.nvim_store
. However, this is a broken symlink to the nix store (nix-portable specific problem), and needs to be replaced. Some bash wizardry is used to recreate the path to the nix store from its location in $PWD/.nix-portable/store
.
# Starting point
The repo I’ll be explaining is located here. Check out the 0_initial_flake
, as this will be our starting point. The idea is to (hopefully) follow along as I explain how to configure neovim in a portable manner. The hope is that by the end of this, the reader will be able to fork my repo as a template and use it to configure their own portable vim “distro”.
The flake.nix
is written in the Nix language and defines our vim configuration. Think a JSON like language with ML-style (MetaLanguage not machine learning) syntax and lambda functions. Let’s analyze the code. The top level looks like:
{
inputs = { ... };
outputs = inputs@{...}: {
}
}
This can be thought of as a “pure” function in a similar sense to other functional languages like Haskell. We input a set of pinned source code, and then the nix
compiler builds some set of output build artifacts. The key subtlety is that these outputs are always the same for the same set of inputs (this is a lie, but a useful one for abstraction purposes). For the inputs, I’ve added neovim nightly (which has its own nix flake!), some vim plugins that I want to run on master, my home-brewn “nix to lua” translator/helper functions, and rnix-lsp
–a language server for Nix written in Rust.
Now, let’s consider the outputs. The outputs are a function of the inputs: inputs
refers to the entire attribute set (analagous to JSON), and the {neovim, ...}
will destructure that attribute set one level. For example neovim
binds to inputs.neovim
.
Now I declare some variables:
neovimConfig = ...;-linux {
customNeovim = DSL.neovimBuilderWithDeps.legacyWrapper neovim.defaultPackage.x86_64extraRuntimeDeps = [];
withNodeJs = true;
configure.customRC = my_config;
configure.packages.myVimPackage.start = with pkgs.vimPlugins; [ ];
};
Conceptually:
neovimConfig
is where any plaintext config that would normally end up in ainit.lua
lives.customNeovim
is the final product. I use myDSL
’s legacyWrapper. Normally this would come fromnixpkgs
(monorepo for all nix packages), but then it is difficult to pass in runtime dependencies. I’ve added theextraRuntimeDeps
attribute to handle that in its own function exported from my DSL flake.withNodeJs
builds the nodejs runtime into neovim, andcustomRC
insertsmy_config
as our lua config file (albeit wrapped in ainit.nvim
).configure.packages.myVimPackage.start
specifies a list of vim packages to make available when Neovim starts.neovim.defaultPackage.x86_64-linux
builds our neovim off of the nightlyneovim
input.
The syntax might be intimidating at this point, but don’t sweat too much. The idea isn’t to question this template, as it should “just workTM”. This initial layer of abstraction will allow us to create a powerful and portable vim build.
We may use these variables we’ve defined to create outputs:
{
# The packages: our custom neovim and the config text file
packages = { inherit (pkgs) customNeovim neovimConfig; };
# The package built by `nix build .`
defaultPackage = pkgs.customNeovim;
# The app run by `nix run .`
defaultApp = {
type = "app";
program = "${pkgs.customNeovim}/bin/nvim";
};
};
Sidenote: experienced Nixers would expect defaultApp not to be required (nix run
should automatically work and does quite well on normal Nix installs. This is a nix-portable quirk).
We can inspect these outputs.
$ nix flake show
git+file:///home/jrestivo/Projects/vimtalk
├───defaultApp
│ └───x86_64-linux: app
├───defaultPackage
│ └───x86_64-linux: package 'neovim-master'
└───my_config: unknown
There are a few things we may do with this. First:
bash edit.sh
This will build our lua config on change to our flake. We can then open init.nvim
and have a look at the generated lua inside. To do this manually, we may run nix build .#my_config
.
Next, the defaultPackage.x86_64-linux
attribute will build our “customized” neovim and store the resulting build artifacts in a result
directory:
nix build .
Finally, the app
attribute will let us run neovim
without having the repo cloned. We may either
nix run github:DieracDelta/vimconf_talk
# OR
nix run .
This CLI is very convenient to use from a user perspective. What I typically do on servers is (1) install nix-portable (run ./setup.sh
) and then (2) alias vim to nix run github:DieracDelta/vimconf_talk
. Then I get all the NeoVim loveliness but setup is trivialized. This is the power of Nix.
Now that we understand the set up, all that remains is to fill out the rest of the config to make it useful.
# Configuring via DSL
Check out branch 1_dsl
to see the DSL
in action. Keybinds are just lua calls. They have a natural representation in Nix as a JSON-like attribute set. For this, we use Gytis’ nix2vim. For example:
"gj"; nnoremap.j =
This translates to: vim.api.nvim_set_keymap('n','j','gj',{ noremap = true})
.
Similarly vim.o
and vim.g
settings may be thought of the same way:
{
vim.g = mapleader = " ";
nofoldenable = true;
noshowmode = true;
completeopt = "menu,menuone,noselect";
};
{
vim.o = termguicolors = true;
...}
Sometimes we may also need to call lua functions. There isn’t an easy way to do this (yet) from nix. So, instead we call directly from the raw rc attribute.
''
configure.customRC = colorscheme dracula
luafile ${neovimConfig}
'';
which enables syntax highlighting in Neovim then loads the config file.
# Adding plugins in nixpkgs
Check out branch 2_plugins
. Most plugins have been already packaged and live in nixpkgs
. To see, we can run:
nix search nixpkgs $PLUGINNAME
Often times, the source is out of date. To tell, we can look at the revision:
nix edit nixpkgs#$PLUGINNAME
Now, I’ve gone through and done this for most of the plugins I use on a day-to-day basis. For the out of date ones, I add nix flake inputs and call overrideAttrs
. overrideAttrs
is a function that takes a function as an input of the form (oldattrs: {...})
. This function takes in one argument, oldattrs
, and returns an attribute set which is then merged with the old attribute set. It overwrites any attributes we specify. In this case, we just wish to override the source code (which we have done in multiple places). We write a wrapper around this called withSrc
.
withSrc = pkg: src: pkg.overrideAttrs (_: { inherit src; });
Don’t be thrown off by the src
. That essentially reads as: {src = src}
. Adding in our plugins with this extra wrapper function:
with prev.vimPlugins; [
configure.packages.myVimPackage.start = # Overwriting plugin sources with different version
(withSrc telescope-nvim inputs.telescope-src)
(withSrc cmp-buffer inputs.cmp-buffer)
(withSrc nvim-cmp inputs.nvim-cmp)
(withSrc cmp-nvim-lsp inputs.cmp-nvim-lsp)
# Plugins from nixpkgs
lsp_signature-nvim
lspkind-nvim
nerdcommenter
nvim-lspconfig
plenary-nvim
popup-nvim# Compile syntaxes into treesitter
(prev.vimPlugins.nvim-treesitter.withPlugins (plugins: with plugins; [ tree-sitter-nix tree-sitter-rust ]))
];
Treesitter is an odd case. For those unfamiliar, tree-sitter provides syntax highlighting among a series of other convenient features. There is some useful documentation here that we can start with. The available grammar list lives here.
## Treesitter expression
Let’s walk through how one might figure out how this nvim-treesitter
expression works. The specific expression I’m thinking of is:
(pkgs.vimPlugins.nvim-treesitter.withPlugins (
plugins: with plugins; [tree-sitter-nix tree-sitter-python tree-sitter-c tree-sitter-rust]
))
The first thing we must do is figure out what nvim-treesitter.withPlugins
is. We may do this by searching it in nixpkgs: nix edit nixpkgs#vimPlugins.nvim-treesitter.withPlugins
. This searches the nixpkgs
flake (which should be present in the registry when flakes are enabled. Think of the registry as a local cache of nixpkgs that exists exactly for this sort of thing). The resulting expression:
# Usage:
# pkgs.vimPlugins.nvim-treesitter.withPlugins (p: [ p.tree-sitter-c p.tree-sitter-java ... ])
# or for all grammars:
# pkgs.vimPlugins.nvim-treesitter.withPlugins (_: tree-sitter.allGrammars)
-treesitter = super.nvim-treesitter.overrideAttrs (old: {
nvimpassthru.withPlugins =
grammarFn: self.nvim-treesitter.overrideAttrs (_: {
postPatch =
let
grammars = tree-sitter.withPlugins grammarFn;
in
''
rm -r parser
ln -s ${grammars} parser
'';
});
});
Let’s proceed line by line. super.nvim-treesitter.overrideAttrs
is applying an overlay that effectively modifies the already defined nvim-treesitter
package by overriding something about the “way” it is built (this is called a derivation). As before, overrideAttrs
takes in a function that defines a set of attributes that “overrides” the pre-existing set of attributes and sets them to new values. Sort of like how with subtyping polymorphism applied to OOP, a child class “inherits” methods from its parent, but may (in some languages at least, notably Java) override them.
In this case, this function defines which attributes to override. old
is the old set of attributes. The derivation sets the passthru
attribute which (complexity aside) at a high level allows us to set the plugins list with the syntax above. For more info see here.
# Adding plugins outside nixpkgs
Check out 3_custom_plugin
. Sometimes plugins may not be added into nixpkgs
. One example is the theme I like, darcula
. Luckily, nix
makes it easy to build plugins:
(pkgs.vimUtils.buildVimPluginFrom2Nix { pname = "dracula-nvim"; version = "master"; src = dracula-nvim; })
This uses a builtin vim
plugin builder. Most of the time, we just need to name the plugin, specify its version and source (which is an input). Generally this should build whichever plugin without much extra configuration. Note that to see more documentation nix edit nixpkgs#vimUtils.buildVimPluginFrom2nix
works quite nicely.
# Adding LSP
Check out 4_lsp
. We’ve already added all the plugins we need. Now all we need to do is (1) add the LSPs (and other system dependencies) we might need and (2) include configuration as plaintext. The former is done by filling out the extraRuntimeDeps
attribute:
with pkgs; [ripgrep clang rust-analyzer inputs.rnix-lsp.defaultPackage.x86_64-linux]; extraRuntimeDeps =
Note that we need ripgrep for telescope, clang for tree-sitter, and we are building rnix-lsp
from source. All with very little effort!
I’ve just pasted what I would normally use to configure neovim
into neoVimConfig
. This lives in neoVimConfig, along with keybinds.
Note: The extraRuntimeDeps
is something I added. I ended up forking the underlying nixpkgs builder into my DSL
repo because I could not figure out how to pass runtime dependencies onto the path. Ideally, one wants to modify the PATH
variable to include the dependencies such as ripgrep. Sadly there does not seem to be an easy way to do this, so I added extraRuntimeDeps
as an argument to do this. extraRuntimeDeps
get passed to the wrapProgram
bash function, which allows an easy way for us to prepend (in this case, anyway) packages to our path.
# Adding CI and a Cache
Check out 5_ci
. The last thing to do is enable a cache so each machine we pull down to does not go under load. The way to do this is to use a github action to build neovim and push it to github. It is trivial to copy .github/workflows/nix.conf
. After copying, one must create their own cachix
account, obtain a key to their cache, and set the CACHIX_AUTH_TOKEN
github action secret to that key. Then, github actions will be able to push neovim build artifacts (notably language servers, ripgrep, and neovim itself) to this binary cache. Then client-side when we nix run $NEOVIM_CONFIG
, nix
will pull directly from this cache.