Structuring Neovim Lua plugins
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 anyplugin/
folder within a location listed inruntimepath
, 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 inruntimepath
.
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 callrequire('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
orinit.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
infoobar/init.lua
you need to return it infoobar/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 autocmd
s 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