Structuring Neovim Lua plugins

  Sunday, November 6, 2022 » Neovim

Table of content


This is an introduction to the various ways you can structure a Neovim plugin and their trade-offs.

What’s a vim plugin

Traditionally, a plugin is a vim script file that’s loaded when vim starts. You install a plugin by dropping it into your plugin directory.

Vim has two types of plugins:

Vim has another concept called packages, Packages are also directories containing plugins, see :h packages. They give you even more locations where you can place your plugins.

What’s a Lua plugin

Neovim added support for Lua plugins, which is basically the same as a vim plugin, except the file extension is .lua instead of .vim and the file contains Lua code instead of vimscript. Neovim executes the logic when it starts in the case of a global plugin, or when a FileType event triggers in case of a filetype plugin.

Lazy loading Lua code

You may not always want to run code whenever Neovim starts or when a FileType event triggers. Instead you may want to run logic on-demand, either by calling a function directly or via keymap.

The simplest way to do that, is to place a single file in a lua/ folder. This folder can be in any of the runtimepath or packages locations. For example, in lua/foobar.lua you would write:

-- Using `M` is a common Lua convention, `M` stand for module
-- It's used for a table that contains all exported functions and properties
-- (Exported because it's returned at the end of the file)
local M = {}

function M.do_something()
  print('Hello world')
end

return M

A user would then load it on-demand using :lua require('foobar').do_something().

require is a Lua function to search a module, load and execute it. The first require call caches the module. This means a second require('foobar') call won’t run all the logic. Consider the following example:

local M = {}

function M.do_something()
  print('This is printed whenever you call do_something()')
end

print('This is printed only once')

return M

The first require call will print “This is printed only once”. The second wont. You can manually unload a module by setting package.loaded[moduleName] to nil. For example: :lua package.loaded['foobar'] = nil.

To sum up: If your plugin needs to setup custom user commands or run other logic whenever the user starts a Neovim process, you’d place that logic in a plugin/foobar.lua file. The main bulk of the logic should go into a lua/foobar.lua file, which is lazy loaded when the user first uses it.

This approach works well for smaller plugins which are a couple hundred lines or less. An example is nvim-fzy

Advantages

Disadvantages

Many modules in a folder

At a certain size it makes sense to start splitting up a plugin into separate files.

For example, you might have a structure like this:

/plugin/foobar.lua
/lua/foobar/init.lua
/lua/foobar/session.lua
/lua/foobar/rpc.lua

Here require('foobar') will load foobar/init.lua, which is the same as:

/plugin/foobar.lua
/lua/foobar.lua
/lua/foobar/session.lua
/lua/foobar/rpc.lua

Tip: Try to avoid creating a foobar/util.lua, it often ends up as garbage dumpster.

Advantages

Disadvantages

Plugin configuration

A setup method

With the rise of Lua plugins it became popular to have a single setup function to configure plugins. The plugin exports a setup function which users can call to configure it.

On the plugin side it looks like this:

local M = {}

local _config = {}

function M.setup(config)
  -- Simple variant; could also be more complex with validation, etc.
  _config = config
end


function M.do_something()
   local option_x = _config.option_x or 'some_default_value'
   -- ...
end

return M

And from the user perspective it looks like this:

require('foobar').setup({
  option_x = true,
  option_y = 'bar'
})

This approach is useful if the plugin performs expensive initialisation or if what it initializes depends on the configuration. It gives the user full control over when the initialization happens and it’s explicit which options are used.

An example use-case is if your plugin creates autocmds based on configuration values.

On the other hand, if the plugin doesn’t require initialization and has good defaults, a setup method may be the wrong choice. It has the disadvantage that it forces users to require the plugin to configure it. This means if the user wants to lazy load the plugin’s modules they’d have to defer the setup call into a keymap or similar.

If they don’t do that and eagerly require the plugin to call setup in their init.lua they may unnecessarily increase their startup time. This can become a problem if a plugin has many modules and it’s main module eagerly imports all its other modules. See internal lazy loading for a way to avoid that.

A global setting variable

Consider an alternative approach: Using a single global configuration table. From the plugin side:

local M = {}

function M.do_something()
   local settings = vim.g.foobar_settings or {}
   local option_x = settings.option_x or 'some_default_value'
   -- ...
end

return M

From the user perspective, changing settings would look like this:

vim.g.foobar_settings = {
  option_x = true,
  option_y = 'bar'
}

The advantage of this approach is that users don’t need to load the plugin using require up-front. They can configure the plugin in their init.lua without loading the plugin.

If your plugin uses many modules, each module will be able to access this variable. With the setup approach, plugins often end up re-creating an effective global in a more complex way:

foobar/config.lua
foobar/init.lua
foobar/xy.lua

Where the communication between modules looks like this:

  ┌───────────┐
  │ init.lua  ├──────────────────────────────┐
  ├───────────┤        ┌────────────┐     ┌──┴─────┐
  │ M.setup() ├───────►│ config.lua │◄────│ xy.lua │
  └───────────┘        └────────────┘     └────────┘

The global settings variable approach has its downsides:

This - or using flat settings - used to be the default way to configure plugins written in vimscript.

Require performance

require is a Lua function to load and run modules. require traverses parts of the filesystem to find a module, and loads it. It caches the module, which means only the first call is expensive and later calls are cheaper.

To see where it looks for modules, you can try loading a module that doesn’t exist. If you run :lua require('does-not-exist') you’ll get an error telling you that the does-not-exist module wasn’t found. The message will include a list of paths.

The system traversal and initial caching adds some cost. That means splitting functionality over many modules will introduce overhead compared to putting the same functionality into a single module.

Does that matter? Maybe, maybe not. On my main system:

Internal lazy loading

One way to decrease the cost of a require('foobar') if using many modules is to ensure that the foobar/init.lua only loads other modules when needed.

Instead of having something like this at the start of your foobar/init.lua:

local session = require('foobar.session')
local rpc = require('foobar.rpc')

You would call them on-demand:

function M.do_something()
  local session = require('foobar.session')
  -- ...
end

If you wanted to reduce the amount of repetition, you could use a metatable:

local lazy = setmetatable({}, {
  __index = function(_, key)
    return require('foobar.' .. key)
  end
})

This would then allow you to refer to other modules like this:

lazy.session.do_something()

Wrap up

I hope this gave you a bit of an overview of how you can structure your plugin. All that’s left is to draw the remainder of the owl 🦉

Update 2022-11-08: Some comments to this article gave me the impression that people interpreted this as “Never use .setup”. That’s far from what I tried to say. Instead my intention was to illustrate that plugin authors have different choices, and which choice is best depends on their concrete use-case. I wanted to help them make informed decisions instead of copying others, without understanding the reasons.

I amended the “A setup method” section to include examples where .setup is a decent choice to hopefully get this point across a bit better