Structuring Neovim Lua plugins

  Sunday, November 6, 2022 » Neovim

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:

  • A global plugin, used for all kinds of files. The directory for a global plugin is $VIMRUNTIME/plugin, or any plugin/ folder within a location listed in runtimepath, see :h runtimepath.
  • A filetype plugin, used for specific file types. The directory for a filetype plugin is ftplugin/<filetype>.vim, within $VIMRUNTIME or within a location listed in runtimepath.

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

  • Small startup footprint. Neovim sources plugin/foobar.lua on startup, but that’s fairly cheap as long as the plugin doesn’t run some expensive logic.
  • Remaining functionality is lazy loaded by default. Neovim doesn’t load lua/foobar.lua until you call require('foobar').
  • Runtime of a single require is low. See require performance for more details.

Disadvantages

  • Can get hard to navigate ones the file becomes large

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

  • Can improve readability if you separate components in a meaningful way - e.g. by their responsibilities.
  • Gives more flexibility to how you structure the code

Disadvantages

  • Can lead to a higher startup footprint, especially if the main entry point (foobar.lua or init.lua) eagerly loads all other modules. In require performance and internal lazy loading further down I explain this in more detail.
  • Creating lots of tiny modules can reduce readability due to the increased surface and indirection. See Avoid shallow functions.
  • You can’t have “plugin private” functions across modules. To use a function of foobar/session.lua in foobar/init.lua you need to return it in foobar/session.lua. That exposes it to users too. A common convention is to prefix private functions with a underscore to indicate it’s considered private. Some plugins consider any undocumented functions as private.

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:

  • It’s harder to do validation of the settings
  • A chance for name conflicts, but this is about the same as a name clash for the plugin and module names

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:

  • Cached require call on single module, on average: 0.000662ms
  • Uncached require call on a single module, on average: 0.030045ms
  • Loading a module that implicitly loads 3 other modules: 0.090935ms

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