Skip to content

nvim-neorocks/nvim-best-practices

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

57 Commits
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

βœ… ❌ πŸŒ” 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

🦺 Type safety

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

❌ DON'T

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

βœ… DO

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

πŸ› οΈ Tools

For Nix users:

πŸ“š Further reading

πŸ—£οΈ User Commands

❌ DON'T

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

Example:

  • :RocksInstall {arg}
  • :RocksPrune {arg}
  • :RocksUpdate
  • :RocksSync

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

βœ… DO

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

Example:

  • :Rocks install {arg}
  • :Rocks prune {arg}
  • :Rocks update
  • :Rocks sync
Screenshots

Subcommand completions:

Argument completions:

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

  • provide subcommand completions if the user has typed :Rocks ...
  • argument completions if they have typed :Rocks {subcommand} ...

First, define a type for each subcommand, which has:

  • An implementation, a function which is called when executing the subcommand.
  • Optional completions for the subcommand's arguments.
---@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
})

πŸ“š Further reading

  • :h lua-guide-commands-create

⌨️ Keymaps

❌ DON'T

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

❌ DON'T

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

  • You will have to implement and document it yourself.
  • What if someone else comes up with a slightly different DSL? This will lead to inconsistencies and confusion. Here are 3 differing implementations:
  • If a user has to call a setup function to set keymaps, it will cause an error if your plugin is not installed or disabled.
  • Neovim already has a built-in API for this.

βœ… DO

...provide :h <Plug> mappings to allow users to define their own keymaps.

  • It requires one line of code in user configs.
  • Even if your plugin is not installed or disabled, creating the keymap won't lead to an error.

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:

  • You can enforce options like expr = true.
  • Expose functionality only or specific modes (:h map-modes).
  • Expose different behaviour for different modes with a single <Plug> mapping.

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

βœ… DO

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

  • You have a function that takes a large options table, and it would require lots of <Plug> mappings to expose all of its uses (You could still create some for the most common ones).
  • You don't care about people who prefer Vimscript for configuration.

Another alternative is just to expose user commands.

⚑ Initialization

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

  • 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:

  • You want your plugin to be compatible with Neovim <= 0.6.
  • And your plugin is actually multiple plugins in a monorepo.
  • Or you're integrating with another plugin that forces you to do so.

βœ… DO

  • Cleanly separate configuration and initialization.
  • Automatically initialize your plugin (smartly), with minimal impact on startup time (see the next section).

πŸ›Œ Lazy loading

❌ DON'T

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

  • Making sure a plugin doesn't unnecessarily impact startup time should be the responsibility of plugin authors, not users.
  • A plugin's functionality may evolve over time, potentially leading to breakage if it's the user who has to worry about lazy loading.
  • A plugin that implements its own lazy initialization properly will likely have less overhead than the mechanisms used by a plugin manager or user to load that plugin lazily.

βœ… DO

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

Is there any functionality that is specific to a filetype?

  • Put your initialization logic in a ftplugin/{filetype}.lua script.
  • See :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, { 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

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

πŸ”§ Configuration

βœ… 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:

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

βœ… DO

...validate configs.

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

Validations could include:

  • Correct types, see :h vim.validate
  • Unknown fields in the user config (e.g. due to typos). This can be tricky to implement, and may be better suited for a health check, to reduce overhead.

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.

🩺 Troubleshooting

βœ… DO

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

Some things to validate:

  • User configuration
  • Proper initialization
  • Presence of lua dependencies
  • Presence of external dependencies

πŸ“š Further reading

#️⃣ Versioning and releases

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

βœ… DO

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

πŸ“š Further reading

βœ… DO

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

πŸ“š Further reading

πŸ› οΈ Tools

πŸ““ Documentation

βœ… DO

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

❌ DON'T

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

πŸ› οΈ Tools

πŸ“š Further reading

πŸ§ͺ Testing

βœ… DO

...automate testing as much as you can.

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

βœ… 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:

  • Familiarity: It is the de facto tried and tested standard in the Lua community, with a familiar API modeled after rspec.
  • Consistency: Having a consistent API makes life easier for
    • Contributors to your project.
    • Package managers, who run test suites to ensure they don't deliver a broken version of your plugin.
  • Better reproducibility: By using luarocks to manage your test dependencies, you can easily pin them. Checking out git repositories is prone to flakes in CI and "it works on my machine" issues.
  • Dependency management: With luarocks, you have the whole ecosystem of Lua modules at your fingertips, making it easy to manage and integrate dependencies for your project.

Tip

For combining busted with other test frameworks, check out our busted interop examples.

πŸ“„ Template

πŸ“š Further reading

πŸ› οΈ Tools

πŸ”Œ Integrating with other plugins

βœ… 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.

Releases

No releases published

Packages

No packages published