Using Nix to configure Neovim

Over the past week I've been experimenting with using Nix to configure NeoVim, and its been going really well. I've been playing with using Nix as a package manager since the beginning of the new year when I bought a new machine, and its been an absolute breeze with installing new command line tools. However, by installing NeoVim via Nix, it wasn't then picking up my rc files. Couple that with the fact that I've been putting off migrating to NeoVim's built-in package manager to manage all my packages because it would drastically increase the number of steps required to set up a new machine, and I thought it would be worth investigating whether I could using Nix to configure NeoVim entirely. I use quite a few different tools and so this became quite a complex process, but I've got something I'm pretty happy with and picking it all up on my work machine was a simple case of pulling my dotfiles repo[1] and running `nix-shell` -- simply brilliant.

Custom package

Now, to do this, I had to learn how to override packages as up until now I'd just installed everything simply as is. All I'd been doing is writing something like the following in a shell.nix file and then, as mentioned, starting up nix-shell:

with (import <nixpkgs> {});
mkShell {
        buildInputs = [
                pkgs.foo
                pkgs.bar
        ];
}    

Worked simple enough. However to be able to configure NeoVim you have to do something more like this

with (import <nixpkgs> {});
let
  my-neovim = neovim.override {
    configure = {
      customRC = ''
      '';
      packages.myVimPackage = with pkgs.vimPlugins; {
        start = [
        ];
      };
    };
  };
in
  mkShell {
    buildInputs = [
      my-neovim
    ];
  }    

Packages go in the start list and contents of .vimrc goes in the customRC string. So straight away we have a string of Vimscript inside of a nix file. That's level one.

Easy parts of the migration

Migrating my packages over proved very simple: just had to search for what the nix package name for each and include it. Likewise, migrating over my vimscript code and other config commands from my .vimrc was also pretty simple. Including lua code just involves including a lua heredoc on the customRC string; something like this

      customRC = ''
        lua << EOF
vim.opt.tabstop = 2
vim.opt.expandtab = true
vim.opt.shiftwidth = 2
vim.opt.softtabstop = 2
vim.opt.autoindent = true
EOF
        ''    

So that means we have lua code inside vim code inside nix code: two levels deep.

That then means that luasnips -- a new package that I've discovered this week -- can be configured similarly:

        lua << EOF
local ls = require("luasnip")
local s = ls.snippet;
local t = ls.text_node;
ls.add_snippets("all", {
  s("hw", {
    t("Hello World")
  })
})
EOF    

Language server protocols, once installed separately, are much the same; just a case of copying the lua code over.

Treesitter

Treesitter is where things proved a little more tricky. First, the various grammars need to be declared in the nix file so that they can be installed correctly. Thankfully this isn't all too tricky by including them in the treesitter package's declaration

with (import <nixpkgs> {});
let
  my-neovim = neovim.override {
    configure = {
      customRC = ''
        ...
      '';
      packages.myVimPackage = with pkgs.vimPlugins; {
        start = [
          ...
          (nvim-treesitter.withPlugins (plugins: with plugins; [
            tree-sitter-haskell
            tree-sitter-html
            tree-sitter-javascript
            tree-sitter-markdown
            tree-sitter-nix
            tree-sitter-tsx
            tree-sitter-typescript
            tree-sitter-vim
            tree-sitter-lua
          ]))
          ...
        ];
      };
    };
  };
in
  mkShell {
    buildInputs = [
      ...
      my-neovim
      ...
    ];
  }    

Secondly, it expects the custom queries to live in a specific file in a specific directory. So instead, I employ the use of the `set_query` lua function.

        lua << EOF
require'vim.treesitter.query'.set_query("tsx", "textobjects", [[
  (import_statement) @import
]])
require'nvim-treesitter.configs'.setup {
  highlight = {
    enable = true,
    additional_vim_regex_highlighting = false,
  },
  textobjects = {
    move = {
      enable = true,
      set_jumps = true,
      goto_previous_start = {
      },
      goto_next_start = {
      },
      goto_previous_end = {
        ["[i"] = "@import",
      },
    },
  },
  playground = {
    enable = true,
    keybindings = {
      toggle_query_editor = 'o',
      toggle_injected_languages = 't'
    }
  }
}
EOF    

Yes, that's three levels deep with the scheme inside the lua inside the Vimscript inside the nix code. A bit bonkers, I know.

Treesitter is honestly the killer app of NeoVim over regular Vim. Being able to edit the document via the AST just feels like the modern incarnation of the action-object grammas of the vi family of editors. That there is a keybinding to go to the last import statement in the buffer. Previously, I used regex which never worked properly whenever the imports were formatted across multiple lines or with blank lines for grouping. Querying the AST of the code directly just results in much more reliable automation.

Finally, as the icing on the cake, being able to syntax highlight the lua code inside the Vimscript code inside the nix code, I put together this treesitter injection snippet:

require'vim.treesitter.query'.set_query("nix", "injections", [[
  (indented_string_expression (string_fragment) @vim)
]]);
require'vim.treesitter.query'.set_query("vim", "injections", [[
  (lua_statement (script (body) @lua))
]]);    

It will need a bit more work if my nix config becomes more complex because currently it selects all strings -- currently I just have the customRC one so it's fine. Also, for some reason, I can't get the inner-most lisp to have syntax highlighting, but oh well, just a minor annoyance.

Summary

Overall, pretty nice I think. Everything is in one place, declared in a manner that can be trivially ported to a new machine.

~~~

Last Updated: 2023-03-18

[1] :: My dotfiles repo
shell.nix as of 2023-03-18