Lua with Neovim: Writing a Neovim Plugin — Plugin Structure, Setup Pattern & Custom Commands | Ep 32
Video: Lua with Neovim: Writing a Neovim Plugin — Plugin Structure, Setup Pattern & Custom Commands | Ep 32 by Taught by Celeste AI - AI Coding Coach
Writing a Neovim Plugin: Structure, Setup Pattern, and Custom Commands
A plugin is a Lua module that exports a
setup(opts)function. Standard layout:lua/plugin-name/init.lua. Build a word-count plugin with two user commands.
This is the final episode. We take everything from the past 31 — modules, tables, metatables, the Neovim API — and build a working Neovim plugin.
The plugin: word counter
Two : commands:
:WordCount— total words in the current buffer.:WordCountLine— words on the current line.
A setup(opts) function lets users opt into notifications and override defaults.
File structure
~/.config/nvim/lua/wordcount/init.lua
Or for distribution:
wordcount.nvim/
├── lua/
│ └── wordcount/
│ └── init.lua
├── README.md
└── doc/
└── wordcount.txt
The lua/wordcount/init.lua path is what require("wordcount") resolves to. Neovim adds ~/.config/nvim/lua/ and every plugin's lua/ directory to package.path automatically.
The plugin code
-- lua/wordcount/init.lua
local M = {}
M.config = {
notify = true,
}
function M.count_words()
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local total = 0
for _, line in ipairs(lines) do
for _ in string.gmatch(line, "%S+") do
total = total + 1
end
end
return total
end
function M.count_words_line()
local line = vim.api.nvim_get_current_line()
local count = 0
for _ in string.gmatch(line, "%S+") do
count = count + 1
end
return count
end
function M.setup(opts)
opts = opts or {}
M.config = vim.tbl_deep_extend("force", M.config, opts)
vim.api.nvim_create_user_command("WordCount", function()
local count = M.count_words()
vim.notify("Word count: " .. count)
end, {})
vim.api.nvim_create_user_command("WordCountLine", function()
local count = M.count_words_line()
vim.notify("Words on this line: " .. count)
end, {})
end
return M
Four pieces:
M.config— default options. Users can override viasetup.M.count_words()— counts whitespace-separated tokens across all lines.M.count_words_line()— same logic on the current line.M.setup(opts)— merges user opts and registers the commands.
The standard module pattern from episode 24 — local M = {}, attach functions, return M.
Counting words with %S+
for _ in string.gmatch(line, "%S+") do
total = total + 1
end
%S+ matches "one or more non-whitespace characters" — basically a "word" for word-counting purposes (handles punctuation and hyphens better than %w+).
for _ in string.gmatch(...) discards the matched text — we only care that there was a match.
The setup pattern
function M.setup(opts)
opts = opts or {}
M.config = vim.tbl_deep_extend("force", M.config, opts)
-- ... register commands, autocmds, etc.
end
This is the universal "Neovim plugin entry point":
- Accept an
optstable. - Merge over defaults using
vim.tbl_deep_extend. - Wire up commands, keymaps, autocmds.
The user calls require("wordcount").setup({ notify = false }) once at startup, and the plugin's behaviour adapts.
vim.tbl_deep_extend("force", a, b) recursively merges b into a, with b's values winning on conflict. Three modes:
"force"—boverridesa."keep"—akeeps its values; only fills in missing keys fromb."error"— error on duplicate keys.
Using the plugin
In your init.lua:
require("wordcount").setup({ notify = true })
Then :WordCount shows "Word count: 287" or whatever.
Distributing via a plugin manager
Most users install plugins via a manager like lazy.nvim. The user adds:
{ "your-name/wordcount.nvim", config = function() require("wordcount").setup() end }
Lazy clones the plugin from GitHub, calls config after loading. The plugin manager handles the package.path so require("wordcount") works.
A real plugin would also:
- Lazy-load. Define lazy-load triggers —
cmd = { "WordCount", "WordCountLine" }— so the plugin only loads when the command is invoked. - Have a
:checkhealthintegration.:checkhealth wordcountruns a function inlua/wordcount/health.luafor diagnostics. - Ship help docs. A
doc/wordcount.txtfile plus:helptagsmakes:help wordcountwork. - Have tests. Plenary's
bustedtest runner is the de facto standard. - Pin a Neovim version.
if vim.fn.has("nvim-0.9") == 0 then return endat the top guards against old versions.
For a starter plugin, the small version we wrote is enough.
The full template
local M = {}
local default_config = {
notify = true,
keymaps = {
count = "<leader>wc",
},
}
M.config = default_config
local function show_count(count)
if M.config.notify then
vim.notify("Word count: " .. count)
else
print("Word count: " .. count)
end
end
function M.count_words()
local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false)
local total = 0
for _, line in ipairs(lines) do
for _ in string.gmatch(line, "%S+") do
total = total + 1
end
end
return total
end
function M.setup(opts)
M.config = vim.tbl_deep_extend("force", default_config, opts or {})
vim.api.nvim_create_user_command("WordCount", function()
show_count(M.count_words())
end, { desc = "Count words in buffer" })
if M.config.keymaps.count then
vim.keymap.set("n", M.config.keymaps.count, function()
show_count(M.count_words())
end, { desc = "Count words" })
end
end
return M
A bit more polish:
- A private
show_counthelper — usesvim.notifyif configured, else falls back toprint. - A user-overridable keymap via
setup. descon every command and keymap so they show up nicely in:maplistings.
Plugin debugging tips
:lua =require("wordcount").config—=is the Neovim shortcut for "print this value." Inspect runtime state.:messages— see allprint()andvim.notifyoutput in one buffer.:luafile %— re-source the current Lua file to test changes without restarting Neovim.package.loaded["wordcount"] = nilthenrequire("wordcount").setup()— force a fresh module load when re-iterating quickly.
The series ends here
Over 32 episodes we've covered:
- Lua basics: print, variables, arithmetic, strings, booleans, control flow.
- Functions: parameters, returns, locals, first-class values, closures.
- Tables: as arrays, as dicts, iteration, nesting, references vs copies.
- Stdlib: math, string, patterns, file I/O, error handling.
- Modules and OOP: require, metatables, inheritance.
- Neovim: the
vimAPI, options, keymaps, autocmds, plugins.
That's enough to write Neovim plugins, scripting tools, game logic, embedded scripts in C apps, Roblox/Defold games, or whatever else needs a small, fast scripting language.
Next steps:
- Read Programming in Lua (the book by Roberto Ierusalimschy, the language designer). Free for older editions; the current edition is paid but worth it.
- Browse popular Neovim plugins like
lualine.nvim,which-key.nvim,telescope.nvim— same module/setup pattern, more features. - Try LÖVE, a Lua game framework — small games like
flappy birdclones in 100 lines of code.
Common stumbles
Forgetting setup. A plugin file that just does vim.api.nvim_create_user_command(...) at the top runs every time it's loaded. Wrap in M.setup so the user controls when.
Hardcoding paths or shell commands. Use vim.fn.expand("$HOME"), vim.fn.stdpath("config"), etc.
Not handling opts = nil. If your setup doesn't accept missing args, calling require("wp").setup() (no args) crashes. Always opts = opts or {}.
Polluting globals. Anything not declared local becomes global — and globals from a plugin pollute every other plugin. local M = {} then attach to it.
Reading the buffer in autocmd before it's loaded. Some events fire before content is ready. Use BufReadPost or BufWinEnter rather than BufNew.
Recap
A Neovim plugin = a Lua module with a setup(opts) function. Layout: lua/<plugin-name>/init.lua. The user calls require("<plugin-name>").setup({ ... }); the plugin merges defaults with user opts (vim.tbl_deep_extend("force", ...)) and registers commands/keymaps/autocmds via vim.api. Words counted with string.gmatch(line, "%S+"). Lazy-loading and distribution via lazy.nvim. Test and re-source quickly with :luafile % and package.loaded[name] = nil.
Series complete. Build something.