Part of Learn Lua with NeoVim

Lua with Neovim: Writing a Neovim Plugin — Plugin Structure, Setup Pattern & Custom Commands | Ep 32

Sandy LaneSandy Lane

Video: Lua with Neovim: Writing a Neovim Plugin — Plugin Structure, Setup Pattern & Custom Commands | Ep 32 by Taught by Celeste AI - AI Coding Coach

Take the quiz on the full lesson page
Test what you've read · interactive walkthrough

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 via setup.
  • 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 opts table.
  • 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"b overrides a.
  • "keep"a keeps its values; only fills in missing keys from b.
  • "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 :checkhealth integration. :checkhealth wordcount runs a function in lua/wordcount/health.lua for diagnostics.
  • Ship help docs. A doc/wordcount.txt file plus :helptags makes :help wordcount work.
  • Have tests. Plenary's busted test runner is the de facto standard.
  • Pin a Neovim version. if vim.fn.has("nvim-0.9") == 0 then return end at 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_count helper — uses vim.notify if configured, else falls back to print.
  • A user-overridable keymap via setup.
  • desc on every command and keymap so they show up nicely in :map listings.

Plugin debugging tips

  • :lua =require("wordcount").config= is the Neovim shortcut for "print this value." Inspect runtime state.
  • :messages — see all print() and vim.notify output in one buffer.
  • :luafile % — re-source the current Lua file to test changes without restarting Neovim.
  • package.loaded["wordcount"] = nil then require("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 vim API, 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 bird clones 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.

Ready? Take the quiz on the full lesson page →
Test what you've learned. Watch the lesson and try the interactive quiz on the same page.