Anton Shuvalov

Packaging Neovim Configuration

Problem

I have a lot of installed vim plugins, and most of them have a setup configuration and keybindings for which-key. This configuration is spread into different spots:

  • plugins are listed in the packer.startup hook in the plugins.lua file.
  • keybindings are stored in keybindings.lua
  • Specific plugin configuration is either stored in the packer config callback or in separate files if there are too many lines to keep plugins.lua readable.

Idea

SpaceVim is organizing vim plugins into layers, which can be enabled depending on user needs, so I decided to implement something alike to mitigate 2 things:

  • Store all plugin-related logic in one package
  • Make it easy to enable/disable packages

Technical Design

The implementation contains 2 parts:

The first one is quite simple — just make hooks for each specific step:

  • pre_install — some code needs to be called before packer handles installation.
  • install — a callback to control packer installation for package plugins.
  • setup — a callback to handle package configuration.
  • post_setup — this guy is called after all plugins are configured.

If a package has an implementation of a specific hook, this hook gonna be called.

The second one is about handling the configuration, such as keybindings and specific package parameters:

  • For keybindings, there gonna be a global mutable keys table, which is extended via package keybindings, if it has it.
  • For configuration, there is a config.lua file containing tables with specific settings. This config either can be read directly in a package or injected via lookup (if you prefer to avoid coupling). I'm personally fine with the first approach so far.

Hooks Implementation

A has_prop function calls a callback against all packages has a specific prop:

-- lib/utils/has_prop.lua

local function has_prop(packages, prop, callback)
  for _,p in pairs(packages) do
    local package = require(p)

    if package[prop]~=nil then
      callback(package)
    end
  end
end


return {
  has_prop = has_prop,
}

The list of packages looks like this:

-- config.lua

local enabled_packages = {
  'packages/packer',
  'packages/which-key',
  'packages/theme',
  'packages/indent-blankline',
  'packages/git',
  'packages/lualine',
  'packages/fzf',
  'packages/editorconfig',
  'packages/comment',
  'packages/cmp-completion',
  'packages/lsp',
  'packages/tmux-navigation',
  'packages/nvim-session',
  'packages/quickfix-to-bottom',
  'packages/neo-tree',
  'packages/treesitter',
  'packages/null-ls',
  'packages/trouble',
  'packages/todo-comments'
}

Each package can be either a file packages/package-name.lua or a directory with init.lua if you need to split it into a few files (packages/package-name/init.lua, etc) looks like below:

-- ./packages/some-plugin/init.lua

-- which-keys bindings
local keys = {}


local function pre_install(use)
  -- pre-installation logic, if required
end


local function install(use)
  use 'some/plugin.nvim'
end

local function setup()
  -- Plugin setup
  require('plugin').setup({})
end


local function post_setup()
  -- Plugin setup
  require('plugin').setup({})
end

return {
  pre_install = pre_install,
  install = install,
  setup = setup,
  post_setup = post_setup
  keys = keys,
}

How packages and hooks are connected:

-- packages.lua

local packer = require('packer')
local has_prop = require('lib/utils/has_prop').has_prop
local enabled_packages = require('config').enabled_packages



-- Call pre_install hooks
has_prop(enabled_packages, 'pre_install', function(package)
  package.pre_install()
end)

packer.startup(function (use)
  -- Call install hooks
  has_prop(enabled_packages, 'install', function(package)
    package.install(use)
  end)

  -- Call setup hooks
  has_prop(enabled_packages, 'setup', function(package)
    package.setup()
  end)
end)

-- Call post_setup hooks
has_prop(enabled_packages, 'post_setup', function(package)
  package.post_setup()
end)


Configuration Implementation

There is an example of how to handle which-key keybindings. Let's create a global key table in config.lua:

-- config.lua

-- base which-key config
local keys = {}


return {
  -- ...
  keys = keys,
}

Then extend this table via package keybindings:

-- packages.lua

local table_merge = require('lib/utils/table_merge').table_merge
local keys = require('config').keys

-- ...

packer.startup(function (use)
  -- ...

    -- Get keybindings
  has_prop(enabled_packages, 'keys', function(package)
    table_merge(keys, package.keys)
  end)

end

table_merge implementation:

local function table_merge(t1, t2)
    for k,v in pairs(t2) do
        if type(v) == "table" then
            if type(t1[k] or false) == "table" then
                table_merge(t1[k] or {}, t2[k] or {})
            else
                t1[k] = v
            end
        else
            t1[k] = v
        end
    end
    return t1
end


return { table_merge = table_merge }

And let's handle configuring which-key in its package:

-- packages/which-key.lua

-- use global `keys`:
local keys = require('config').keys


local function install(use)
  use 'folke/which-key.nvim'
end


-- Register bindings after all packages have been initialized
-- and configure its keybindings.
local function post_setup()
  local wk = require("which-key")
  wk.register(keys, { prefix = "<leader>", mode = 'n' })
end


return {
  install = install,
  post_setup = post_setup
}

Now just enable package/which-keys package:

-- config.lua

local enabled_packages = {
  'packages/which-key',
  -- ...
}

Here we go, now each package can have a keys table, which is handled by which-keys.


Packer Example

A package handling packer installation and configuration:

-- packages/packer.lua


local fn = vim.fn
local execute = vim.api.nvim_command


local function pre_install()
  -- Auto-install packer if it hasn't been installed
  local install_path = fn.stdpath('data')..'/site/pack/packer/start/packer.nvim'

  if fn.empty(fn.glob(install_path)) > 0 then
    fn.system({'git', 'clone', 'https://github.com/wbthomason/packer.nvim', install_path})
    execute 'packadd packer.nvim'
  end
end

local function install(use)
  use 'wbthomason/packer.nvim'
end

return {
  pre_install = pre_install,
  install = install,
}


Packing Some Behavior Changes Example

Pack to a package some specific behavior, like saving a session:

local api = vim.api

-- Save / Load session
local function setup()
  api.nvim_command('au BufWinLeave,BufLeave,BufWritePost,BufHidden,QuitPre ?* nested silent! mkview!')
  api.nvim_command('autocmd BufWinEnter ?* silent! loadview')
end

return {
  setup = setup,
}

Trouble Example

Trouble package:

-- packages/trouble.lua

local keys = {
  T = {
    name = "Trouble",
    T = { '<cmd>TroubleToggle<cr>', 'Toggle' },
    D = { '<cmd>TroubleToggle workspace_diagnostics<cr>', 'Workspace Diagnostics' },
    d = { '<cmd>Trouble document_diagnostics<cr>', 'Document Diagnostics' },
    q = { '<cmd>Trouble quickfix<cr>', 'Quickfix' },
    l = { '<cmd>Trouble loclist<cr>', 'Loclist' },
    r = { '<cmd>Trouble lsp_references<cr>', 'References' },
  },
}

local function install(use)
  use {
    'folke/trouble.nvim',
    requires = 'kyazdani42/nvim-web-devicons'
  }
end


local function setup()
  require('trouble').setup({})
end


return {
  install = install,
  setup = setup,
  keys = keys,
}

Further Reading:

Created with obsidian-blog