Collection of DOs and DON'Ts for modern Neovim Lua plugin development
726
stars
25
commits
Jun 15, 2026
updated
[!IMPORTANT]
The code snippets in this document use the Neovim 0.10.0 API.
[!NOTE]
For a guide to using Lua in Neovim, please refer to
:h lua-intro.Parts of this guide have been upstreamed to Neovim. See
:h lua-pluginin Neovim (nightly).For an example based on this guide, check out @ColinKennedy's plugin template.
Lua, as a dynamically typed language, is great for configuration. It provides virtually immediate feedback. But for larger projects, this can be a double-edged sword.
...make your plugin susceptible to unexpected bugs at the wrong time.
...leverage LuaCATS annotations, along with lua-language-server to catch potential bugs in your CI before your plugin's users do.
lx check for static type checking using emmlylua-analyzer-rustlx lint for linting with luacheckFor Nix users:
...pollute the command namespace with a command for each action.
Example:
:RocksInstall {arg}:RocksPrune {arg}:RocksUpdate:RocksSyncThis can quickly become overwhelming when users rely on command completion.
...gather subcommands under scoped commands and implement completions for each subcommand.
Example:
:Rocks install {arg}:Rocks prune {arg}:Rocks update:Rocks syncSubcommand completions:
Argument completions:
[!TIP]
There exists a Lua library,
mega.cmdparse, which can take care of much of the boilerplate for you and comes with many features.
Here's a basic example of how to implement completions manually. In this example, we want to
:Rocks ...:Rocks {subcommand} ...First, define a type for each subcommand, which has:
---@class MyCmdSubcommand
---@field impl fun(args:string[], opts: table) The command implementation
---@field complete? fun(subcmd_arg_lead: string): string[] (optional) Command completions callback, taking the lead of the subcommand's arguments
Next, we define a table mapping subcommands to their implementations and completions:
---@type table<string, MyCmdSubcommand>
local subcommand_tbl = {
update = {
impl = function(args, opts)
-- Implementation (args is a list of strings)
end,
-- This subcommand has no completions
},
install = {
impl = function(args, opts)
-- Implementation
end,
complete = function(subcmd_arg_lead)
-- Simplified example
local install_args = {
"neorg",
"rest.nvim",
"rustaceanvim",
}
return vim.iter(install_args)
:filter(function(install_arg)
-- If the user has typed `:Rocks install ne`,
-- this will match 'neorg'
return install_arg:find(subcmd_arg_lead) ~= nil
end)
:totable()
end,
-- ...
},
}
Then, create a lua function to implement the main command:
---@param opts table :h lua-guide-commands-create
local function my_cmd(opts)
local fargs = opts.fargs
local subcommand_key = fargs[1]
-- Get the subcommand's arguments, if any
local args = #fargs > 1 and vim.list_slice(fargs, 2, #fargs) or {}
local subcommand = subcommand_tbl[subcommand_key]
if not subcommand then
vim.notify("Rocks: Unknown command: " .. subcommand_key, vim.log.levels.ERROR)
return
end
-- Invoke the subcommand
subcommand.impl(args, opts)
end
Finally, we register our command, along with the completions:
-- NOTE: the options will vary, based on your use case.
vim.api.nvim_create_user_command("Rocks", my_cmd, {
nargs = "+",
desc = "My awesome command with subcommand completions",
complete = function(arg_lead, cmdline, _)
-- Get the subcommand.
local subcmd_key, subcmd_arg_lead = cmdline:match("^['<,'>]*Rocks[!]*%s(%S+)%s(.*)$")
if subcmd_key
and subcmd_arg_lead
and subcommand_tbl[subcmd_key]
and subcommand_tbl[subcmd_key].complete
then
-- The subcommand has completions. Return them.
return subcommand_tbl[subcmd_key].complete(subcmd_arg_lead)
end
-- Check if cmdline is a subcommand
if cmdline:match("^['<,'>]*Rocks[!]*%s+%w*$") then
-- Filter subcommands that match
local subcommand_keys = vim.tbl_keys(subcommand_tbl)
return vim.iter(subcommand_keys)
:filter(function(key)
return key:find(arg_lead) ~= nil
end)
:totable()
end
end,
bang = true, -- If you want to support ! modifiers
})
:h lua-guide-commands-create...create excessive keymaps automatically. Doing so can conflict with user mappings.
[!NOTE]
An example for uncontroversial keymaps are buffer-local mappings for specific file types or floating windows, or
<Plug>mappings.
...define a fancy DSL for enabling keymaps via a setup function.
setup function to set keymaps,
it will cause an error if your plugin is not installed or disabled....provide :h <Plug> mappings to allow users to define their own keymaps.
Example:
In your plugin:
vim.keymap.set("n", "<Plug>(MyPluginAction)", function() print("Hello") end)
In the user's config:
vim.keymap.set("n", "<leader>h", "<Plug>(MyPluginAction)")
[!TIP]
Some benefits of
<Plug>mappings over exposing a lua function:
- You can enforce options like
expr = true.- Use
vim.keymap's built-in mode handling to expose functionality only for specific modes (:h map-modes).- Handle different map-modes differently with a single mapping, without adding mode checks to the underlying implementation.
- Detect user-defined mappings through
hasmapto()before creating defaults.
For example, in your plugin:
vim.keymap.set("n", "<Plug>(SayHello)", function()
print("Hello from normal mode")
end)
vim.keymap.set("v", "<Plug>(SayHello)", function()
print("Hello from visual mode")
end)
In the user's config:
vim.keymap.set({"n", "v"}, "<leader>h", "<Plug>(SayHello)")
...just expose a Lua API that people can use to define keymaps, if
<Plug> mappings to expose all of its uses
(You could still create some for the most common ones).Another alternative is just to expose user commands.
...force users to call a setup function
in order to be able to use your plugin.
[!WARNING]
This one often sparks heated debates. I have written in detail about the various reasons why this is an anti pattern here.
- If you still disagree, feel free to open an issue.
These are the rare cases in which a setup function
for initialization could be useful:
Common approaches to a strictly separated configuration are:
setup(opts) or configure(opts), which only overrides the
default configuration and does not contain any initialization logic.[!TIP]
You can support both, by providing a function that sets a
vim.gvariable.
Typically, automatic initialization logic is done in a plugin or ftplugin
script. See also :h runtimepath.
...rely on plugin managers to take care of lazy loading for you.
...think carefully about when which parts of your plugin need to be loaded.
Neovim has a mechanism for every plugin to do its own implicit
lazy-loading via scripts in the autoload/ (Vimscript) and lua/ (Lua)
directories.
Plugin authors can provide "lazy loading" by
providing a plugin/<name>.lua file which defines their commands and
keymappings. This file should be small, and should not eagerly require the
rest of your plugin. Commands and mappings should do the require.
ftplugin/{filetype}.lua script.:h filetype.Example:
-- ftplugin/rust.lua
if not vim.g.loaded_my_rust_plugin then
-- Initialise
end
-- NOTE: Using vim.g.loaded_ prevents the plugin from initializing twice
-- and allows users to prevent plugins from loading (in both Lua and Vimscript).
vim.g.loaded_my_rust_plugin = true
local bufnr = vim.api.nvim_get_current_buf()
-- do something specific to this buffer, e.g. add a <Plug> mapping or create a command
vim.keymap.set("n", "<Plug>(MyPluginBufferAction)", function()
print("Hello")
end, { buffer = bufnr, })
Don't eagerly require your lua modules.
Example:
Instead of:
local foo = require("foo")
vim.api.nvim_create_user_command("MyCommand", function()
foo.do_something()
end, {
-- ...
})
...which will eagerly load the foo module,
and any modules it eagerly imports, you can lazy load it
by moving the require into the command's implementation.
vim.api.nvim_create_user_command("MyCommand", function()
local foo = require("foo")
foo.do_something()
end, {
-- ...
})
Likewise, if a plugin uses a Lua module as an entrypoint, it should
defer require calls too.
[!TIP]
For a Vimscript equivalent to
require, see:h autoload.
[!NOTE]
- What about eagerly creating user commands at startup?
- Wouldn't it be better to rely on a plugin manager to lazy load my plugin via a user command and/or autocommand?
No! To be able to lazy load your plugin with a user command, a plugin manager has to itself create a user command. This helps for plugins that don't implement proper lazy loading, but it just adds overhead for those that do. The same applies to autocommands, keymaps, etc.
...use LuaCATS annotations to make your API play nicely with lua-language-server, while providing type safety.
One of the largest foot guns in Lua is nil.
You should avoid it in your internal configuration.
On the other hand, users don't want to have to set every possible field.
It is convenient for them to provide a default configuration and merge it
with an override table.
This is a common practice:
---@class myplugin.Config
---@field do_something_cool boolean
---@field strategy "random" | "periodic"
---@type myplugin.Config
local default_config = {
do_something_cool = true,
strategy = "random",
}
-- could also be passed in via a function. But there's no real downside to using `vim.g` or `vim.b`.
local user_config = ...
local config = vim.tbl_deep_extend("force", default_config, user_config or {})
return config
In this example, a user can override only individual configuration fields:
{
strategy = "periodic"
}
...leaving the unset fields as their default. However, if they have lua-language-server configured to pick up your plugin (for example, using neodev.nvim), it will show them a warning like this:
{ -- ⚠ Missing required fields in type `myplugin.Config`: `do_something_cool`
strategy = "periodic"
}
To mitigate this, you can split configuration option declarations and internal configuration values.
This is how I like to do it:
-- config/meta.lua
---@class myplugin.Config
---@field do_something_cool? boolean (optional) Notice the `?`
---@field strategy? "random" | "periodic" (optional)
-- Side note: I prefer to use `vim.g` or `vim.b` tables (:h lua-vim-variables).
-- You can also use a lua function but there's no real downside to using `vim.g` or `vim.b`
-- and it doesn't throw an error if your plugin is not installed.
-- This annotation says that`vim.g.my_plugin` can either be a `myplugin.Config` table, or
-- a function that returns one, or `nil` (union type).
---@type myplugin.Config | fun():myplugin.Config | nil
vim.g.my_plugin = vim.g.my_plugin
--------------------------------------------------------------
-- config/internal.lua
---@class myplugin.InternalConfig
local default_config = {
---@type boolean
do_something_cool = true,
---@type "random" | "periodic"
strategy = "random",
}
local user_config = type(vim.g.my_plugin) == "function" and vim.g.my_plugin() or vim.g.my_plugin or {}
---@type myplugin.InternalConfig
local config = -- ...merge configs
[!NOTE]
This does have some downsides:
- You have to maintain two configuration types.
- As this is fairly uncommon, first time contributors will often overlook one of the configuration types.
Since this provides increased type safety for both the plugin and the user's config, I believe it is well worth the slight inconvenience.
Alternatively, you can use (partial) classes to reduce boilerplate:
-- config/meta.lua
-- the `(partial)` attribute below makes all fields nullable
---@class (partial) myplugin.Opts: myplugin.Config
---@type myplugin.Opts | fun():myplugin.Opts | nil
vim.g.my_plugin = vim.g.my_plugin
--------------------------------------------------------------
-- config/internal.lua
---@class myplugin.Config
local default_config = {
---@type boolean
do_something_cool = true,
---@type "random" | "periodic"
strategy = "random",
}
local user_config = type(vim.g.my_plugin) == "function" and vim.g.my_plugin() or vim.g.my_plugin or {}
---@type myplugin.Config
local config = -- ...merge configs
[!NOTE]
Caveats of using
(partial)classes:
- Vimdoc generator tools may not yet have good support for generating documentation from
(partial)classes.- Using
(partial)classes exposes the internal class as part of your public API, which may not be worth the reduced boilerplate in some cases.On the other hand, this approach can mean less effort in keeping classes synchronised.
...validate configs.
Once you have merged the default configuration with the user's config, you should validate configs.
Validations could include:
:h vim.validate[!WARNING]
vim.validatewillerrorif it fails a validation.
Because of this, I like to wrap it with pcall,
and add the path to the field in the config
table to the error message:
---@param path string The path to the field being validated
---@param tbl table The table to validate
---@see vim.validate
---@return boolean is_valid
---@return string|nil error_message
local function validate_path(path, tbl)
local ok, err = pcall(vim.validate, tbl)
return ok, err and path .. "." .. err
end
The function can be called like this:
---@param cfg myplugin.InternalConfig
---@return boolean is_valid
---@return string|nil error_message
function validate(cfg)
return validate_path("vim.g.my_plugin", {
do_something_cool = { cfg.do_something_cool, "boolean" },
strategy = { cfg.strategy, "string" },
})
end
And invalid config will result in an error message like
"vim.g.my_plugin.strategy: expected string, got number".
By doing this, you can use the validation with both
:h vim.notify and :h vim.health.
...provide health checks in lua/{plugin}/health.lua to report status checks
to users. See :h health-dev.
Some things to validate:
It can be useful to provide a template for a minimal configuration, along with a guide on how to use it to reproduce issues.
...use 0ver or omit versioning completely, e.g. because you believe doing so is a commitment to stability.
[!TIP]
Doing this won't make people any happier about breaking changes.
vim.deprecate() or a ---@deprecate LuaCATS annotation
when you need to communicate a future breaking change or discouraged practise.
Note that vim.deprecate() will fire a deprecation warning based on the Neovim
version, not your plugin's version....automate versioning and releases, and publish to luarocks.org.
...provide vimdoc, so that users can read your plugin's documentation in Neovim,
by entering :h {plugin}.
...simply dump generated references in your doc directory.
...automate testing as much as you can.
...use plenary.nvim for testing.
Historically, plenary.test has been very popular for testing,
because there was no convenient way for using Neovim as a lua interpreter.
That has changed with the introduction of nvim -l in Neovim 0.9.
While plenary.nvim is still being maintained, much of its functionality is gradually being upstreamed into Neovim or moved into other libraries.
...use busted for testing, which is a lot more powerful.
[!NOTE]
plenary.nvim bundles a limited subset of luassert.
We advocate for using luarocks + busted for testing, primarily for the following reasons:
[!TIP]
For combining busted with other test frameworks, check out our busted interop examples.
nvim-busted-actionnluaneorocksTest (for Nix users)...use LuaJIT extensions without explicitly stating that your plugins requires Neovim built with LuaJIT.
LuaJIT adds several extension modules to the Lua 5.1 API. It can be tempting to use them, as Neovim is typically bundled with a LuaJIT script engine. However, Neovim officially only supports the Lua 5.1 API and on some distributions may be shipped with Lua 5.1 instead of LuaJIT. If you use LuaJIT extension modules1, your plugin will not be compatible with these Neovim distributions.
...use the Lua 5.1 API if you can, so that your plugin is compatible with all Neovim builds.
goto) to use the Lua 5.1 API.ffi, consider a statically linked Lua wrapper written in C
or consider using mlua for native Lua
bindings to Rust libraries.[!TIP]
You can add
"runtime.version": "Lua 5.1",to your.luarc.jsonto tell your language server/type checker to emit warnings when using features that are not supported by Lua 5.1.
[!TIP]
If you need LuaJIT extensions for certain features, you should gate them behind a
if jit then ...check.
...consider integrating with other plugins.
For example, it might be useful to add a telescope.nvim extension or a lualine component.
[!TIP]
If you don't want to commit to maintaining compatibility with another plugin's API, you can expose your own API for others to hook into.
An exception is the bit extension, for which Neovim provides a fallback implementation. ↩
Collection of DOs and DON'Ts for modern Neovim Lua plugin development
726
stars
25
commits
Jun 15, 2026
updated
[!IMPORTANT]
The code snippets in this document use the Neovim 0.10.0 API.
[!NOTE]
For a guide to using Lua in Neovim, please refer to
:h lua-intro.Parts of this guide have been upstreamed to Neovim. See
:h lua-pluginin Neovim (nightly).For an example based on this guide, check out @ColinKennedy's plugin template.
Lua, as a dynamically typed language, is great for configuration. It provides virtually immediate feedback. But for larger projects, this can be a double-edged sword.
...make your plugin susceptible to unexpected bugs at the wrong time.
...leverage LuaCATS annotations, along with lua-language-server to catch potential bugs in your CI before your plugin's users do.
lx check for static type checking using emmlylua-analyzer-rustlx lint for linting with luacheckFor Nix users:
...pollute the command namespace with a command for each action.
Example:
:RocksInstall {arg}:RocksPrune {arg}:RocksUpdate:RocksSyncThis can quickly become overwhelming when users rely on command completion.
...gather subcommands under scoped commands and implement completions for each subcommand.
Example:
:Rocks install {arg}:Rocks prune {arg}:Rocks update:Rocks syncSubcommand completions:
Argument completions:
[!TIP]
There exists a Lua library,
mega.cmdparse, which can take care of much of the boilerplate for you and comes with many features.
Here's a basic example of how to implement completions manually. In this example, we want to
:Rocks ...:Rocks {subcommand} ...First, define a type for each subcommand, which has:
---@class MyCmdSubcommand
---@field impl fun(args:string[], opts: table) The command implementation
---@field complete? fun(subcmd_arg_lead: string): string[] (optional) Command completions callback, taking the lead of the subcommand's arguments
Next, we define a table mapping subcommands to their implementations and completions:
---@type table<string, MyCmdSubcommand>
local subcommand_tbl = {
update = {
impl = function(args, opts)
-- Implementation (args is a list of strings)
end,
-- This subcommand has no completions
},
install = {
impl = function(args, opts)
-- Implementation
end,
complete = function(subcmd_arg_lead)
-- Simplified example
local install_args = {
"neorg",
"rest.nvim",
"rustaceanvim",
}
return vim.iter(install_args)
:filter(function(install_arg)
-- If the user has typed `:Rocks install ne`,
-- this will match 'neorg'
return install_arg:find(subcmd_arg_lead) ~= nil
end)
:totable()
end,
-- ...
},
}
Then, create a lua function to implement the main command:
---@param opts table :h lua-guide-commands-create
local function my_cmd(opts)
local fargs = opts.fargs
local subcommand_key = fargs[1]
-- Get the subcommand's arguments, if any
local args = #fargs > 1 and vim.list_slice(fargs, 2, #fargs) or {}
local subcommand = subcommand_tbl[subcommand_key]
if not subcommand then
vim.notify("Rocks: Unknown command: " .. subcommand_key, vim.log.levels.ERROR)
return
end
-- Invoke the subcommand
subcommand.impl(args, opts)
end
Finally, we register our command, along with the completions:
-- NOTE: the options will vary, based on your use case.
vim.api.nvim_create_user_command("Rocks", my_cmd, {
nargs = "+",
desc = "My awesome command with subcommand completions",
complete = function(arg_lead, cmdline, _)
-- Get the subcommand.
local subcmd_key, subcmd_arg_lead = cmdline:match("^['<,'>]*Rocks[!]*%s(%S+)%s(.*)$")
if subcmd_key
and subcmd_arg_lead
and subcommand_tbl[subcmd_key]
and subcommand_tbl[subcmd_key].complete
then
-- The subcommand has completions. Return them.
return subcommand_tbl[subcmd_key].complete(subcmd_arg_lead)
end
-- Check if cmdline is a subcommand
if cmdline:match("^['<,'>]*Rocks[!]*%s+%w*$") then
-- Filter subcommands that match
local subcommand_keys = vim.tbl_keys(subcommand_tbl)
return vim.iter(subcommand_keys)
:filter(function(key)
return key:find(arg_lead) ~= nil
end)
:totable()
end
end,
bang = true, -- If you want to support ! modifiers
})
:h lua-guide-commands-create...create excessive keymaps automatically. Doing so can conflict with user mappings.
[!NOTE]
An example for uncontroversial keymaps are buffer-local mappings for specific file types or floating windows, or
<Plug>mappings.
...define a fancy DSL for enabling keymaps via a setup function.
setup function to set keymaps,
it will cause an error if your plugin is not installed or disabled....provide :h <Plug> mappings to allow users to define their own keymaps.
Example:
In your plugin:
vim.keymap.set("n", "<Plug>(MyPluginAction)", function() print("Hello") end)
In the user's config:
vim.keymap.set("n", "<leader>h", "<Plug>(MyPluginAction)")
[!TIP]
Some benefits of
<Plug>mappings over exposing a lua function:
- You can enforce options like
expr = true.- Use
vim.keymap's built-in mode handling to expose functionality only for specific modes (:h map-modes).- Handle different map-modes differently with a single mapping, without adding mode checks to the underlying implementation.
- Detect user-defined mappings through
hasmapto()before creating defaults.
For example, in your plugin:
vim.keymap.set("n", "<Plug>(SayHello)", function()
print("Hello from normal mode")
end)
vim.keymap.set("v", "<Plug>(SayHello)", function()
print("Hello from visual mode")
end)
In the user's config:
vim.keymap.set({"n", "v"}, "<leader>h", "<Plug>(SayHello)")
...just expose a Lua API that people can use to define keymaps, if
<Plug> mappings to expose all of its uses
(You could still create some for the most common ones).Another alternative is just to expose user commands.
...force users to call a setup function
in order to be able to use your plugin.
[!WARNING]
This one often sparks heated debates. I have written in detail about the various reasons why this is an anti pattern here.
- If you still disagree, feel free to open an issue.
These are the rare cases in which a setup function
for initialization could be useful:
Common approaches to a strictly separated configuration are:
setup(opts) or configure(opts), which only overrides the
default configuration and does not contain any initialization logic.[!TIP]
You can support both, by providing a function that sets a
vim.gvariable.
Typically, automatic initialization logic is done in a plugin or ftplugin
script. See also :h runtimepath.
...rely on plugin managers to take care of lazy loading for you.
...think carefully about when which parts of your plugin need to be loaded.
Neovim has a mechanism for every plugin to do its own implicit
lazy-loading via scripts in the autoload/ (Vimscript) and lua/ (Lua)
directories.
Plugin authors can provide "lazy loading" by
providing a plugin/<name>.lua file which defines their commands and
keymappings. This file should be small, and should not eagerly require the
rest of your plugin. Commands and mappings should do the require.
ftplugin/{filetype}.lua script.:h filetype.Example:
-- ftplugin/rust.lua
if not vim.g.loaded_my_rust_plugin then
-- Initialise
end
-- NOTE: Using vim.g.loaded_ prevents the plugin from initializing twice
-- and allows users to prevent plugins from loading (in both Lua and Vimscript).
vim.g.loaded_my_rust_plugin = true
local bufnr = vim.api.nvim_get_current_buf()
-- do something specific to this buffer, e.g. add a <Plug> mapping or create a command
vim.keymap.set("n", "<Plug>(MyPluginBufferAction)", function()
print("Hello")
end, { buffer = bufnr, })
Don't eagerly require your lua modules.
Example:
Instead of:
local foo = require("foo")
vim.api.nvim_create_user_command("MyCommand", function()
foo.do_something()
end, {
-- ...
})
...which will eagerly load the foo module,
and any modules it eagerly imports, you can lazy load it
by moving the require into the command's implementation.
vim.api.nvim_create_user_command("MyCommand", function()
local foo = require("foo")
foo.do_something()
end, {
-- ...
})
Likewise, if a plugin uses a Lua module as an entrypoint, it should
defer require calls too.
[!TIP]
For a Vimscript equivalent to
require, see:h autoload.
[!NOTE]
- What about eagerly creating user commands at startup?
- Wouldn't it be better to rely on a plugin manager to lazy load my plugin via a user command and/or autocommand?
No! To be able to lazy load your plugin with a user command, a plugin manager has to itself create a user command. This helps for plugins that don't implement proper lazy loading, but it just adds overhead for those that do. The same applies to autocommands, keymaps, etc.
...use LuaCATS annotations to make your API play nicely with lua-language-server, while providing type safety.
One of the largest foot guns in Lua is nil.
You should avoid it in your internal configuration.
On the other hand, users don't want to have to set every possible field.
It is convenient for them to provide a default configuration and merge it
with an override table.
This is a common practice:
---@class myplugin.Config
---@field do_something_cool boolean
---@field strategy "random" | "periodic"
---@type myplugin.Config
local default_config = {
do_something_cool = true,
strategy = "random",
}
-- could also be passed in via a function. But there's no real downside to using `vim.g` or `vim.b`.
local user_config = ...
local config = vim.tbl_deep_extend("force", default_config, user_config or {})
return config
In this example, a user can override only individual configuration fields:
{
strategy = "periodic"
}
...leaving the unset fields as their default. However, if they have lua-language-server configured to pick up your plugin (for example, using neodev.nvim), it will show them a warning like this:
{ -- ⚠ Missing required fields in type `myplugin.Config`: `do_something_cool`
strategy = "periodic"
}
To mitigate this, you can split configuration option declarations and internal configuration values.
This is how I like to do it:
-- config/meta.lua
---@class myplugin.Config
---@field do_something_cool? boolean (optional) Notice the `?`
---@field strategy? "random" | "periodic" (optional)
-- Side note: I prefer to use `vim.g` or `vim.b` tables (:h lua-vim-variables).
-- You can also use a lua function but there's no real downside to using `vim.g` or `vim.b`
-- and it doesn't throw an error if your plugin is not installed.
-- This annotation says that`vim.g.my_plugin` can either be a `myplugin.Config` table, or
-- a function that returns one, or `nil` (union type).
---@type myplugin.Config | fun():myplugin.Config | nil
vim.g.my_plugin = vim.g.my_plugin
--------------------------------------------------------------
-- config/internal.lua
---@class myplugin.InternalConfig
local default_config = {
---@type boolean
do_something_cool = true,
---@type "random" | "periodic"
strategy = "random",
}
local user_config = type(vim.g.my_plugin) == "function" and vim.g.my_plugin() or vim.g.my_plugin or {}
---@type myplugin.InternalConfig
local config = -- ...merge configs
[!NOTE]
This does have some downsides:
- You have to maintain two configuration types.
- As this is fairly uncommon, first time contributors will often overlook one of the configuration types.
Since this provides increased type safety for both the plugin and the user's config, I believe it is well worth the slight inconvenience.
Alternatively, you can use (partial) classes to reduce boilerplate:
-- config/meta.lua
-- the `(partial)` attribute below makes all fields nullable
---@class (partial) myplugin.Opts: myplugin.Config
---@type myplugin.Opts | fun():myplugin.Opts | nil
vim.g.my_plugin = vim.g.my_plugin
--------------------------------------------------------------
-- config/internal.lua
---@class myplugin.Config
local default_config = {
---@type boolean
do_something_cool = true,
---@type "random" | "periodic"
strategy = "random",
}
local user_config = type(vim.g.my_plugin) == "function" and vim.g.my_plugin() or vim.g.my_plugin or {}
---@type myplugin.Config
local config = -- ...merge configs
[!NOTE]
Caveats of using
(partial)classes:
- Vimdoc generator tools may not yet have good support for generating documentation from
(partial)classes.- Using
(partial)classes exposes the internal class as part of your public API, which may not be worth the reduced boilerplate in some cases.On the other hand, this approach can mean less effort in keeping classes synchronised.
...validate configs.
Once you have merged the default configuration with the user's config, you should validate configs.
Validations could include:
:h vim.validate[!WARNING]
vim.validatewillerrorif it fails a validation.
Because of this, I like to wrap it with pcall,
and add the path to the field in the config
table to the error message:
---@param path string The path to the field being validated
---@param tbl table The table to validate
---@see vim.validate
---@return boolean is_valid
---@return string|nil error_message
local function validate_path(path, tbl)
local ok, err = pcall(vim.validate, tbl)
return ok, err and path .. "." .. err
end
The function can be called like this:
---@param cfg myplugin.InternalConfig
---@return boolean is_valid
---@return string|nil error_message
function validate(cfg)
return validate_path("vim.g.my_plugin", {
do_something_cool = { cfg.do_something_cool, "boolean" },
strategy = { cfg.strategy, "string" },
})
end
And invalid config will result in an error message like
"vim.g.my_plugin.strategy: expected string, got number".
By doing this, you can use the validation with both
:h vim.notify and :h vim.health.
...provide health checks in lua/{plugin}/health.lua to report status checks
to users. See :h health-dev.
Some things to validate:
It can be useful to provide a template for a minimal configuration, along with a guide on how to use it to reproduce issues.
...use 0ver or omit versioning completely, e.g. because you believe doing so is a commitment to stability.
[!TIP]
Doing this won't make people any happier about breaking changes.
vim.deprecate() or a ---@deprecate LuaCATS annotation
when you need to communicate a future breaking change or discouraged practise.
Note that vim.deprecate() will fire a deprecation warning based on the Neovim
version, not your plugin's version....automate versioning and releases, and publish to luarocks.org.
...provide vimdoc, so that users can read your plugin's documentation in Neovim,
by entering :h {plugin}.
...simply dump generated references in your doc directory.
...automate testing as much as you can.
...use plenary.nvim for testing.
Historically, plenary.test has been very popular for testing,
because there was no convenient way for using Neovim as a lua interpreter.
That has changed with the introduction of nvim -l in Neovim 0.9.
While plenary.nvim is still being maintained, much of its functionality is gradually being upstreamed into Neovim or moved into other libraries.
...use busted for testing, which is a lot more powerful.
[!NOTE]
plenary.nvim bundles a limited subset of luassert.
We advocate for using luarocks + busted for testing, primarily for the following reasons:
[!TIP]
For combining busted with other test frameworks, check out our busted interop examples.
nvim-busted-actionnluaneorocksTest (for Nix users)...use LuaJIT extensions without explicitly stating that your plugins requires Neovim built with LuaJIT.
LuaJIT adds several extension modules to the Lua 5.1 API. It can be tempting to use them, as Neovim is typically bundled with a LuaJIT script engine. However, Neovim officially only supports the Lua 5.1 API and on some distributions may be shipped with Lua 5.1 instead of LuaJIT. If you use LuaJIT extension modules1, your plugin will not be compatible with these Neovim distributions.
...use the Lua 5.1 API if you can, so that your plugin is compatible with all Neovim builds.
goto) to use the Lua 5.1 API.ffi, consider a statically linked Lua wrapper written in C
or consider using mlua for native Lua
bindings to Rust libraries.[!TIP]
You can add
"runtime.version": "Lua 5.1",to your.luarc.jsonto tell your language server/type checker to emit warnings when using features that are not supported by Lua 5.1.
[!TIP]
If you need LuaJIT extensions for certain features, you should gate them behind a
if jit then ...check.
...consider integrating with other plugins.
For example, it might be useful to add a telescope.nvim extension or a lualine component.
[!TIP]
If you don't want to commit to maintaining compatibility with another plugin's API, you can expose your own API for others to hook into.
An exception is the bit extension, for which Neovim provides a fallback implementation. ↩