Home

Awesome

:white_check_mark: :x: :moon: DOs and DON'Ts for modern Neovim Lua plugin development

[!IMPORTANT]

The code snippets in this document use the Neovim 0.10.0 API.

[!NOTE]

:safety_vest: Type safety

Lua, as a dynamically typed language, is great for configuration. It provides virtually immediate feedback.

:x: DON'T

...make your plugin susceptible to unexpected bugs at the wrong time.

:white_check_mark: DO

...leverage LuaCATS annotations, along with lua-language-server to catch potential bugs in your CI before your plugin's users do.

:hammer_and_wrench: Tools

For Nix users:

:books: Further reading

:speaking_head: User Commands

:x: DON'T

...pollute the command namespace with a command for each action.

Example:

This can quickly become overwhelming when users rely on command completion.

:white_check_mark: DO

...gather subcommands under scoped commands and implement completions for each subcommand.

Example:

<details> <summary> <b>Screenshots</b> </summary>

Subcommand completions:

Argument completions:

</details>

Here's an example of how to implement completions. In this example, we want to

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
})

:books: Further reading

:keyboard: Keymaps

:x: DON'T

...create any keymaps automatically (unless they are not controversial). This can easily lead to conflicts.

:x: DON'T

...define a fancy DSL for enabling keymaps via a setup function.

:white_check_mark: DO

...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, { noremap = true })

In the user's config:

vim.keymap.set("n", "<leader>h", "<Plug>(MyPluginAction)")

[!TIP]

Some benefits of <Plug> mappings over exposing a lua function:

For example, in your plugin:

vim.keymap.set("n", "<Plug>(SayHello)", function() 
    print("Hello from normal mode") 
end, { noremap = true })

vim.keymap.set("v", "<Plug>(SayHello)", function() 
    print("Hello from visual mode") 
end, { noremap = true })

In the user's config:

vim.keymap.set({"n", "v"}, "<leader>h", "<Plug>(SayHello)")

:white_check_mark: DO

...just expose a Lua API that people can use to define keymaps, if

Another alternative is just to expose user commands.

:zap: Initialization

:x: DON'T

...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.

These are the rare cases in which a setup function for initialization could be useful:

:white_check_mark: DO

:sleeping_bed: Lazy loading

:x: DON'T

...rely on plugin managers to take care of lazy loading for you.

:white_check_mark: DO

...think carefully about when which parts of your plugin need to be loaded.

Is there any functionality that is specific to a 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, { noremap = true, buffer = bufnr, })

Is your plugin not filetype-specific, but it likely won't be needed every single time a user opens a Neovim session?

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, {
  -- ...
})

[!TIP]

For a Vimscript equivalent to require, see :h autoload.

[!NOTE]

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.

:wrench: Configuration

:white_check_mark: DO

...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

[!IMPORTANT]

This does have some downsides:

Since this provides increased type safety for both the plugin and the user's config, I believe it is well worth the slight inconvenience.

:white_check_mark: DO

...validate configs.

Once you have merged the default configuration with the user's config, you should validate configs.

Validations could include:

[!WARNING]

vim.validate will error if 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.

:stethoscope: Troubleshooting

:white_check_mark: DO

...provide health checks in lua/{plugin}/health.lua.

Some things to validate:

:books: Further reading

:hash: Versioning and releases

:x: DON'T

...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.

:white_check_mark: DO

...use SemVer to properly communicate bug fixes, new features, and breaking changes.

:books: Further reading

:white_check_mark: DO

...automate versioning and releases, and publish to luarocks.org.

:books: Further reading

:hammer_and_wrench: Tools

:notebook: Documentation

:white_check_mark: DO

...provide vimdoc, so that users can read your plugin's documentation in Neovim, by entering :h {plugin}.

:x: DON'T

...simply dump generated references in your doc directory.

:hammer_and_wrench: Tools

:books: Further reading

:test_tube: Testing

:white_check_mark: DO

...automate testing as much as you can.

:x: DON'T

...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.

:white_check_mark: DO

...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.

:page_facing_up: Template

:books: Further reading

:hammer_and_wrench: Tools

:electric_plug: Integrating with other plugins

:white_check_mark: DO

...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.