Nix: Where are my neovim plugins?

Published on February 24, 2024

I was recently trying to figure my home manager setup and wasn’t sure why a plugin wasn’t working.

The neovim part of this configuration looks something like this,

programs.neovim = {
    enable = true;
    plugins = with pkgs.vimPlugins; [
    extraLuaConfig = '' = true
      vim.opt.encoding = 'utf8'
      vim.opt.expandtab = true
      vim.opt.swapfile = false
      vim.opt.number = true
      vim.opt.shiftwidth = 4

Before things broke, this worked auto-magically for me and I didn’t think about it much. The plugins probably just exist in my neovim configuration, right?

$ ls ls ~/.config/nvim

Maybe they’re imported from the nix store from that file?

$ cat ~/.config/nvim/init.lua = true
vim.opt.encoding = 'utf8'
vim.opt.expandtab = true
vim.opt.swapfile = false
vim.opt.number = true
vim.opt.shiftwidth = 4

No, the file in this case is exactly the contents of extraLuaConfig. There’s no sign of any of the plugins, even the ones that are definitely loaded.

I dig closer I looked at the home-manager source. The the module for neovim lives in modules/programs/neovim.nix.

Whenever you’re reading a home manager module there’s two main parts you want to look at. First, options which defines the type of the configuration and has places to fill in documentation. Here’s the docs for vimPlugins,

plugins = mkOption {
  type = with types; listOf (either package pluginWithConfigType);
  default = [ ];
  example = literalExpression ''
    with pkgs.vimPlugins; [
      { plugin = vim-startify;
        config = "let g:startify_change_to_vcs_root = 0";
  description = ''
    List of vim plugins to install optionally associated with
    configuration to be placed in init.vim.

    This option is mutually exclusive with {var}`configure`.

This tells us we should provide a list of plugins, or plugins with configurtion attached, and gives an example. But I’m trying to figure out how modules are loaded, and this isn’t helping me.

The other part is config which defines the changes that get merged into the configuration. We can see where init.lua gets generated.

xdg.configFile =
  let hasLuaConfig = hasAttr "lua" config.programs.neovim.generatedConfigs;
  in mkMerge (
    # writes runtime
    (map (x: x.runtime) pluginsNormalized) ++ [{
      "nvim/init.lua" = let
        luaRcContent =
          lib.optionalString (neovimConfig.neovimRcContent != "")
          "vim.cmd [[source ${
            pkgs.writeText "nvim-init-home-manager.vim"
          }]]" + config.programs.neovim.extraLuaConfig
          + lib.optionalString hasLuaConfig
      in mkIf (luaRcContent != "") { text = luaRcContent; };

      "nvim/coc-settings.json" = mkIf cfg.coc.enable {
        source = jsonFormat.generate "coc-settings.json" cfg.coc.settings;

I find nix is often written in a way that’s difficult to read, but it’s easier if we remove the parts that aren’t relevant to us.

The (map (x: x.runtime) pluginsNormalized) part is only used when we pass plugins with a runtime option. Since I’m not doing this, this can be ignored.

The next part says create a file in the XDG config directory (usually ~/.config) called nvim/init.lua. This file is set to a string composed of three parts concatenated together. The first relies on neovimRcContent which we’re not using, so it’s empty. The next is the extraLuaConfig I defined in my config. The last part is also part of something I’m not using. So, the end result just has the extraLuaContent in it.

This explains why how init.lua is generated but still leaves how plugins are loaded a mystery. The plugins are used in another place to generate a variable neovimConfig, which is part of the generation of finalPackage.

neovimConfig = pkgs.neovimUtils.makeNeovimConfig {
  inherit (cfg) extraPython3Packages withPython3 withRuby viAlias vimAlias;
  withNodeJs = cfg.withNodeJs || cfg.coc.enable;
  plugins = map suppressNotVimlConfig pluginsNormalized;
  customRC = cfg.extraConfig;

# ...

programs.neovim.finalPackage = pkgs.wrapNeovimUnstable cfg.package
  (neovimConfig // {
    wrapperArgs = (lib.escapeShellArgs neovimConfig.wrapperArgs) + " "
      + extraMakeWrapperArgs + " " + extraMakeWrapperLuaCArgs + " "
      + extraMakeWrapperLuaArgs;
    wrapRc = false;

A reasonable assumption is finalPackage is the derivation that includes the neovim executable. Instead of going deeper through the code, I’ll check this assumption by looking at the content of that derivation. Are there files related to editorconfig in there?

$ realpath ~/.nix-profile/bin/nvim
$ find /nix/store/1haqzsv4n0v0jdpdc3ab3a2if6i79sk9-neovim-0.9.4 | grep editorconfig

It appears my hypothesis is correct. This derivation is the “wrapped” neovim which calls the “unwrapped” neovim from bin/nvim, which is really just a bash script. There’s an rplugin.vim file

An important note is that, plugins of different languages are loaded differently. If we look around more we see a couple files called rplugin.vim which is generated in the wrapper that defines the remote plugin manifest. This isn’t relevant to lua and viml plugins.

Ok, let’s keep going! Anytime in a nix file you see pkgs it likely refers to nixpkgs, so that’s where I’ll look to see where makeNeovimConfig and wrapNeovimUnstable are defined. Both are easy to find with ripgrep or other grep-like tool. The first is defined in pkgs/applications/editors/neovim/utils.nix and is just a helper to pre-process the config, and not too interesting.

The other is defined as the following, so the real code is in the referenced file, wrapper.nix.

wrapNeovimUnstable = callPackage ../applications/editors/neovim/wrapper.nix { };

I’ve read through this file a few times expecting at some point we’d be copying the plugins into the wrapper, but no such code appears to exist. What gives?

First, a confession: I had already found what I needed at this point in my search and fixed the problem. But when I came back to learn more I realized I got caught in a red herring. The plugins are not simply copied into the wrapped neovim directory. The code I was looking for doesn’t exist! If I had looked closer at bin/nvim I would’ve found,

exec -a "$0" "/nix/store/hssj9fyb8k5cnxi79dwgm91259gks6xl-neovim-unwrapped-0.9.4/bin/nvim" ... lots of junk ... --cmd "set packpath^=/nix/store/wrqqm1lj550r016hr165lxdd08brzay5-vim-pack-dir" --cmd "set rtp^=/nix/store/wrqqm1lj550r016hr165lxdd08brzay5-vim-pack-dir" "$@" 

It turns out packpath lets you provide a directory for neovim (and vim) to look for packages. This is how we let neovim know about editorconfig and treesitter. The treesitter and editorconfig files are ther because they also exist in the unwrapped neovim – they’re included with neovim by default.

The vim-pack-dir derivation is generated by packDir from a list of packages, which gets defined in pkgs/misc/vim-plugins/vim-utils.nix (since this is shared between vim and neovim, it goes in the vim directory). This gets added the bash wrapper in that wrapper.nix file.

commonWrapperArgs =
  # vim accepts a limited number of commands so we join them all
        "--add-flags" ''--cmd "lua ${providerLuaRc}"''
        # (lib.intersperse "|" hostProviderViml)
      ] ++ lib.optionals (packpathDirs.myNeovimPackages.start != [] || packpathDirs.myNeovimPackages.opt != []) [
        "--add-flags" ''--cmd "set packpath^=${vimUtils.packDir packpathDirs}"''
        "--add-flags" ''--cmd "set rtp^=${vimUtils.packDir packpathDirs}"''

Mystery solved!

In Summary

  1. The neovim module in home-manager accepts an array of plugins,
  2. which it uses to generate a neovim config with makeNeovimConfig,
  3. where plugins becomes pacpathDirs and modified slightly
  4. which it then passes to wrapNeovimUnstable,
  5. which takes pacpathDirs and passes to vimUtils.packDir
  6. which combines all the plugins into one derivation called vim-pack-dir (each plugin is symlinked within a specific directory structure)
  7. allowing wrapNeovimUnstable to append this to packpath in a version of neovim wrapped in bash
  8. that neovim interprets as a location to find packages