| name | neovim-plugin-development |
| description | Write Neovim plugins in Lua compatible with lazy.nvim; using vim.api, plugin layout, health checks, lazy-loading, busted tests, floating windows, async. Use when authoring a Neovim plugin or its lazy.nvim spec. Skip for editing personal nvim config (use neovim-config). |
Neovim Plugin Development
Write Neovim plugins in Lua, from internal APIs to shipping a lazy.nvim-compatible package.
When to Use This Skill
- Writing custom Neovim plugin logic (not just configuration)
- Structuring a plugin for distribution (lazy.nvim pkg, health checks, vimdoc)
- Working with vim.api, vim.fn, vim.opt directly
- Understanding how existing plugins work internally
- Creating buffer manipulation, window management, or custom UI
- Implementing autocommands, user commands, or highlight groups
- Debugging Lua code running inside Neovim
Core APIs
vim.api (Neovim API)
Primary interface for Neovim internals:
vim.api.nvim_get_current_buf()
vim.api.nvim_buf_get_lines(buf, start, end_, strict)
vim.api.nvim_buf_set_lines(buf, start, end_, strict, lines)
vim.api.nvim_buf_get_name(buf)
vim.api.nvim_buf_set_option(buf, name, value)
vim.api.nvim_buf_get_mark(buf, name)
vim.api.nvim_get_current_win()
vim.api.nvim_win_get_buf(win)
vim.api.nvim_win_set_cursor(win, {row, col})
vim.api.nvim_win_get_cursor(win)
vim.api.nvim_open_win(buf, enter, config)
vim.api.nvim_create_user_command(name, command, opts)
vim.api.nvim_create_autocmd(event, opts)
vim.api.nvim_set_keymap(mode, lhs, rhs, opts)
vim.api.nvim_create_namespace(name)
vim.api.nvim_buf_add_highlight(buf, ns, hl_group, line, col_start, col_end)
vim.api.nvim_buf_set_extmark(buf, ns, line, col, opts)
vim.fn (Vimscript Functions)
Access Vimscript functions from Lua:
vim.fn.expand("%:p")
vim.fn.fnamemodify(path, ":t")
vim.fn.filereadable(path)
vim.fn.glob(pattern)
vim.fn.system(cmd)
vim.fn.json_decode(str)
vim.fn.json_encode(table)
vim.fn.input("Prompt: ")
vim.fn.confirm("Question?", "&Yes\n&No")
vim.opt / vim.o / vim.bo / vim.wo
vim.opt.number = true
vim.o.number = true
vim.bo.filetype = "lua"
vim.bo[bufnr].modifiable = false
vim.wo.wrap = false
vim.wo[winnr].signcolumn = "yes"
vim.opt.wildignore:append({ "*.o", "*.a" })
vim.opt.listchars = { tab = ">> ", trail = "-" }
vim.keymap
vim.keymap.set("n", "<leader>x", function()
end, { desc = "Description", buffer = bufnr, silent = true })
vim.keymap.del("n", "<leader>x")
Plugin Structure
Minimal Plugin
local M = {}
M.setup = function(opts)
opts = opts or {}
end
return M
Full Plugin Structure
my-plugin.nvim/
├── lua/
│ └── my-plugin/
│ ├── init.lua -- Main entry, exports M.setup()
│ ├── config.lua -- Default config, merged with user opts
│ ├── health.lua -- :checkhealth my-plugin
│ ├── commands.lua -- User commands
│ └── util.lua -- Helper functions
├── plugin/
│ └── my-plugin.lua -- Auto-loaded, guard with vim.g.loaded_my_plugin
├── tests/
│ ├── run.lua -- Busted test runner (nvim -l tests/run.lua)
│ └── *_spec.lua -- Test files
├── doc/
│ └── my-plugin.txt -- Help documentation (:help my-plugin)
├── lazy.lua -- Pkg spec: tells lazy.nvim this plugin needs setup()
└── Makefile -- make test
Config Pattern
config.lua defines your plugin's defaults — the keys users override via opts in their lazy spec. The full flow:
- You define a local
defaults table in config.lua — your plugin's full configuration surface
- User writes
opts = { notify = false } in their lazy spec
- lazy.nvim calls
require("my-plugin").setup({ notify = false })
init.lua delegates to config.setup(opts), which merges user opts over defaults
Only define keys that control plugin behavior. Do NOT put lazy.nvim spec fields (event, cmd, keys, ft) in your defaults — those control when lazy.nvim loads the plugin, not how it behaves. They belong in the user's spec or the lazy.lua pkg spec.
local M = {}
local defaults = {
enabled = true,
notify = true,
some_dir = "~/.config/my-plugin",
}
local config = vim.deepcopy(defaults)
function M.setup(opts)
config = vim.tbl_deep_extend("force", {}, vim.deepcopy(defaults), opts or {})
vim.api.nvim_create_user_command("MyCommand", function(args)
require("my-plugin.commands").run(args)
end, { nargs = "?", desc = "My Plugin" })
end
setmetatable(M, {
__index = function(_, key)
return config[key]
end,
})
return M
local M = {}
function M.setup(opts)
require("my-plugin.config").setup(opts)
end
return M
This pattern comes from folke's plugins (flash.nvim, sidekick.nvim). init.lua is a thin entry point — config.lua owns defaults, merging, command registration, and exposes the merged config via __index.
Health Check
Implement lua/my-plugin/health.lua so users can run :checkhealth my-plugin. Use this to verify external dependencies, config validity, and runtime state:
local M = {}
M.check = function()
vim.health.start("my-plugin")
if vim.fn.executable("some-tool") == 1 then
vim.health.ok("`some-tool` found")
else
vim.health.error("`some-tool` not found", { "Install: https://..." })
end
local cfg = require("my-plugin.config").options
local dir = vim.fn.expand(cfg.some_dir)
if vim.fn.isdirectory(dir) == 1 then
vim.health.ok("directory exists: " .. dir)
else
vim.health.warn("directory not found: " .. dir, { "Create it or update config" })
end
end
return M
Health checks are especially important for plugins that shell out to external tools or depend on specific directory structures.
lazy.nvim Integration
Most Neovim users install plugins via lazy.nvim. Design plugins to work well with its conventions. See lazy.folke.io/developers for full reference.
Two specs, two roles
There are two places a lazy.nvim spec can live, and understanding the boundary between them is critical:
lazy.lua at the plugin repo root (the "pkg spec") — shipped by the plugin author. Declares the minimal spec needed for the plugin to work: opts = {} if setup() is required, cmd for lazy-load triggers, dependencies, build steps. This is the plugin's baseline.
- User's spec in their nvim config (e.g.
lua/plugins/my-plugin.lua) — written by the user. Adds their own opts, event, keys, etc. lazy.nvim deep-merges the user's spec on top of the pkg spec.
The plugin author controls what the plugin needs. The user controls when and how it loads. Keep these concerns separate.
The lazy.lua pkg spec
If your plugin exports M.setup(opts), ship a lazy.lua at the repo root so lazy.nvim knows to call it. lazy.nvim's pkg system auto-detects this file and merges it with the user's spec.
return {
"user/my-plugin.nvim",
opts = {},
}
The opts = {} is the key part — it tells lazy.nvim "call require('my-plugin').setup(opts) when this plugin loads." Without it, lazy.nvim loads the plugin files but never calls setup().
A more complete example with lazy-load triggers:
return {
"user/my-plugin.nvim",
cmd = { "MyCommand", "MyOtherCommand" },
opts = {},
}
What belongs in lazy.lua vs the user's spec:
| Concern | lazy.lua (plugin author) | User spec |
|---|
opts = {} (enable setup) | Yes | Override with their values |
cmd (command triggers) | Yes, if plugin creates commands | Can add more |
event / keys / ft | Rarely — user's choice | Yes |
dependencies | Only if required for function | Can add their own |
build | Yes, if plugin needs build steps | Can override |
Do not put event, keys, or ft in lazy.lua unless the plugin fundamentally requires loading on a specific event to function. Those are user preferences about load timing.
Do not put plugin-internal config keys (like custom options your plugin defines) in lazy.lua spec fields. Plugin config belongs in config.lua defaults, merged via opts. Spec fields like event, cmd, keys are lazy.nvim concepts — they control when lazy.nvim loads the plugin, not how the plugin behaves internally.
The opts convention
When a spec has opts (table or function), lazy.nvim calls require("my-plugin").setup(opts) automatically. Always prefer opts over config:
{ "user/my-plugin.nvim", opts = { option1 = false } }
{
"user/my-plugin.nvim",
config = function()
require("my-plugin").setup({ option1 = false })
end,
}
When multiple specs exist for the same plugin (e.g. pkg spec + user spec), opts tables are deep-merged automatically. Using config instead breaks this merging.
Dependencies
Only declare dependencies when a plugin must be installed AND loaded before yours. Lua libraries auto-load on require() — they don't need to be declared as dependencies:
{ "user/my-plugin.nvim", opts = {} },
{ "nvim-lua/plenary.nvim", lazy = true },
{
"user/my-plugin.nvim",
opts = {},
dependencies = { "nvim-lua/plenary.nvim" },
}
Local development with dev
To work on a plugin locally and have lazy.nvim load it from disk instead of cloning from GitHub, configure the dev table in your lazy.nvim setup (typically in lua/config/lazy.lua):
require("lazy").setup({
spec = { ... },
dev = {
path = "~/dev/nvim/",
patterns = { "your-handle" },
fallback = false,
},
})
lazy.nvim looks for the plugin's repo name as a subdirectory of path. So with path = "~/dev/nvim/" and a spec "your-handle/my-plugin.nvim", lazy.nvim loads from ~/dev/nvim/my-plugin.nvim/ instead of cloning. patterns filters which specs are eligible — typically your GitHub username so only your own forks/plugins resolve locally. Per-plugin override: add dev = true to a single spec to force local resolution regardless of patterns.
Build steps
The build property runs after install/update:
return {
"user/my-plugin.nvim",
build = ":TSUpdate",
}
If a build.lua exists at the plugin root, lazy.nvim auto-detects and runs it. Build functions run in parallel — never change the working directory.
Common Patterns
Autocommands
local group = vim.api.nvim_create_augroup("MyPlugin", { clear = true })
vim.api.nvim_create_autocmd("BufWritePre", {
group = group,
pattern = "*.lua",
callback = function(args)
end,
})
vim.api.nvim_create_autocmd("User", {
group = group,
pattern = "MyPluginEvent",
callback = function() ... end,
})
vim.api.nvim_exec_autocmds("User", { pattern = "MyPluginEvent" })
User Commands
vim.api.nvim_create_user_command("MyCommand", function(opts)
print(opts.args)
end, {
nargs = "*",
bang = true,
range = true,
complete = function(arglead, cmdline, cursorpos)
return { "option1", "option2" }
end,
})
Floating Windows
local buf = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(buf, 0, -1, false, { "Line 1", "Line 2" })
local win = vim.api.nvim_open_win(buf, true, {
relative = "editor",
width = 40,
height = 10,
row = 5,
col = 10,
style = "minimal",
border = "rounded",
})
vim.keymap.set("n", "q", function()
vim.api.nvim_win_close(win, true)
end, { buffer = buf })
Extmarks and Virtual Text
local ns = vim.api.nvim_create_namespace("my-plugin")
vim.api.nvim_buf_set_extmark(buf, ns, line, 0, {
virt_text = { { "virtual text", "Comment" } },
virt_text_pos = "eol",
})
vim.api.nvim_buf_clear_namespace(buf, ns, 0, -1)
Async with vim.schedule
vim.schedule(function()
vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines)
end)
local timer = vim.loop.new_timer()
local function debounce(fn, ms)
return function(...)
local args = { ... }
timer:stop()
timer:start(ms, 0, vim.schedule_wrap(function()
fn(unpack(args))
end))
end
end
Testing
Test Neovim plugins with busted, running inside headless Neovim via nvim -l. Busted must be installed for the LuaJIT 5.1 ABI — C modules compiled against other Lua versions won't load.
luarocks --local --lua-version=5.1 install busted
Test Runner
Create tests/run.lua to bootstrap rtp, luarocks paths, and invoke busted. Run with nvim -l tests/run.lua. For the full runner template and Neovim-specific testing patterns, see testing_with_busted.md.
lazy.minit (for tests needing other plugins)
lazy.nvim ships lazy.minit.busted() — bootstraps lazy.nvim, installs listed specs, then runs busted:
#!/usr/bin/env -S nvim -l
vim.env.LAZY_STDPATH = ".tests"
load(vim.fn.system("curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua"))()
require("lazy.minit").busted({
spec = {
"nvim-telescope/telescope.nvim",
},
})
Run with: nvim -l ./tests/busted.lua tests
Reproduction Scripts
Ship a repro.lua for users to reproduce issues in a clean environment:
vim.env.LAZY_STDPATH = ".repro"
load(vim.fn.system("curl -s https://raw.githubusercontent.com/folke/lazy.nvim/main/bootstrap.lua"))()
require("lazy.minit").repro({
spec = {
"user/my-plugin.nvim",
},
})
Run with: nvim -u repro.lua
Debugging
print(vim.inspect(table))
vim.print(table)
vim.notify("Message", vim.log.levels.INFO)
vim.notify("Error!", vim.log.levels.ERROR)
assert(condition, "Error message")
local f = io.open("/tmp/nvim-debug.log", "a")
f:write(vim.inspect(data) .. "\n")
f:close()
Guidelines
- Use
vim.schedule when modifying buffers from async callbacks
- Clear autocommand groups before recreating to avoid duplicates
- Use namespaces for highlights/extmarks to enable clean removal
- Prefer
vim.keymap.set over vim.api.nvim_set_keymap
- Use
vim.tbl_deep_extend for merging config tables
- Check
vim.fn.has("nvim-0.10") for version-specific features
- Test with
:luafile % or :source % during development
- Use
:messages and :checkhealth for debugging