Skip to content

Commit 1f7780f

Browse files
committed
feat(lua-toolkit): add lua writing skill
Trying to get more into lua development with modern neovim standards. Curate a skillset to remind me of the modern / proper way to do things nowadays.
1 parent 4a7140b commit 1f7780f

8 files changed

Lines changed: 794 additions & 0 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
---
2+
name: lua-toolkit
3+
description: Neovim Lua plugin development playbooks following mrcjkb / nvim-best-practices conventions — project architecture, self lazy-loading, vim.g configuration, scoped commands and <Plug> keymaps, health checks, type-safe tooling, busted testing, and LuaRocks distribution. Use when writing or refactoring a Neovim Lua plugin, designing vim.g/setup config, deciding command/keymap APIs, adding checkhealth, configuring lua-ls/luacheck/stylua/LuaCATS, or setting up busted/nlua tests and SemVer LuaRocks releases.
4+
---
5+
6+
# Lua Toolkit
7+
8+
Use this skill for authoring and refactoring Neovim Lua plugins targeting
9+
LuaJIT/Lua 5.1. It covers architecture, loading strategy, configuration design,
10+
public API surface, tooling, testing, and distribution.
11+
12+
Guidance follows the mrcjkb / nvim-best-practices conventions (LuaRocks-first,
13+
type-safe, self-lazy-loading, no forced `setup()`). This skill is Neovim-plugin
14+
specific. For general Lua embedded elsewhere, only the tooling and runtime
15+
guardrails transfer.
16+
17+
## How I choose what to do (progressive disclosure)
18+
19+
When invoked, route to one mode and load only that reference unless the work
20+
crosses boundaries:
21+
22+
1. **plugin-architecture** — directory layout, `plugin/` vs `lua/` separation,
23+
deferred loading, modular refactoring, and LuaJIT runtime guardrails.
24+
2. **configuration**`vim.g.<plugin>` config tables (not forced `setup()`),
25+
`vim.tbl_deep_extend` merge semantics, `vim.validate` (0.11+ signature), and
26+
`health.lua` checks.
27+
3. **loading-and-install** — self lazy-loading (don't rely on the plugin
28+
manager), minimal install specs, native-API-over-deps, and `build` steps.
29+
4. **apis-and-keymaps** — single scoped command with subcommand completion,
30+
`<Plug>` mappings with no default keymaps, buffer-local state, and
31+
`ftplugin`/`after/ftplugin` filetype logic.
32+
5. **tooling-and-quality** — LuaCATS + lua-ls type safety, `.luarc.json`,
33+
`.luacheckrc`, and `.stylua.toml`.
34+
6. **testing-and-distribution**`busted` + `nlua` tests, CI matrices, vimCATS
35+
Vimdoc generation, and SemVer LuaRocks publishing.
36+
37+
If intent is unclear, ask for the mode before applying changes.
38+
39+
## Playbooks
40+
41+
- Read [plugin-architecture.md](references/plugin-architecture.md) for the
42+
runtimepath directory model, no-runtime-entrypoint plugins, the
43+
deferred-`require` loading pattern, the `util.lua` refactoring imperative,
44+
global-pollution rules, and the `if jit then ... else` fallback pattern.
45+
- Read [configuration.md](references/configuration.md) for the `vim.g.<plugin>`
46+
(table-or-function) config pattern, partial LuaCATS config classes,
47+
dictionary-vs-list `tbl_deep_extend` behavior, the modern `vim.validate`
48+
signature, and `lua/<plugin>/health.lua` checks.
49+
- Read [loading-and-install.md](references/loading-and-install.md) for
50+
self-lazy-loading via `plugin/`+`ftplugin/`, minimal lazy.nvim install specs,
51+
preferring native APIs over `plenary`, `lazy = true` deps, and coroutine
52+
`build` constraints.
53+
- Read [apis-and-keymaps.md](references/apis-and-keymaps.md) for the scoped
54+
subcommand + `complete` pattern, `<Plug>` mappings with no default keymaps,
55+
buffer filetype assignment timing, and `ftplugin` vs `after/ftplugin`.
56+
- Read [tooling-and-quality.md](references/tooling-and-quality.md) for LuaCATS +
57+
lua-ls as the primary correctness tool, `.luarc.json` (`"Lua 5.1"`), the
58+
`lua51` luacheck config, and the stylua baseline.
59+
- Read [testing-and-distribution.md](references/testing-and-distribution.md) for
60+
`busted`+`nlua` (and `neorocksTest` for Nix), the stable/nightly CI matrix,
61+
vimCATS/panvimdoc generation, and SemVer `.rockspec`/`luarocks-tag-release`.
62+
63+
## Core Rules (apply in every mode)
64+
65+
- Target Lua 5.1 / LuaJIT 2.1. Do not use Lua 5.2+ dialect features (new `goto`
66+
scoping, integer division, native bitwise operators); use `require("bit")` for
67+
bit ops — Neovim guarantees it even on non-JIT builds.
68+
- Never declare globals. All state, caches, and functions live in `local`
69+
bindings or the module table returned by the file.
70+
- Separate configuration from initialization. Do not force `setup()`; prefer a
71+
`vim.g.<plugin>` config table read lazily.
72+
- Self-lazy-load: keep startup work out of `lua/`, use `plugin/`+`ftplugin/`,
73+
and defer `require()` into command/keymap closures. Don't rely on the plugin
74+
manager to lazy-load for you.
75+
- Expose one scoped command with subcommand completion, not many commands.
76+
- Expose user-bindable actions as `<Plug>` mappings and ship no default keymaps.
77+
- Prioritize type safety: LuaCATS annotations + lua-ls, gated in CI.
78+
- Distribute on LuaRocks with SemVer; never `0ver`.
79+
80+
## Reference Implementations
81+
82+
Concrete examples in this skill are drawn from these repos — consult them for
83+
full, working context:
84+
85+
- `mrcjkb/nvim-best-practices` — the prose rules this skill follows.
86+
- `mrcjkb/nvim-lua-nix-plugin-template` — Nix CI: `.busted`, `neorocksTest`
87+
stable/nightly checks, `lemmy-help` docgen, `luarocks-tag-release`,
88+
release-please. Closest match for this (Nix) repo.
89+
- `mrcjkb/rustaceanvim``vim.g.<plugin>` config (table-or-function), scoped
90+
`:RustLsp` command with completion, `ftplugin`-based loading (no `plugin/`),
91+
`health.lua`, `.luarc.json`.
92+
- `mrcjkb/neotest-haskell` — adapter/library with no runtime entry points;
93+
one-module-per-responsibility `lua/` tree and tree-sitter `queries/`.
94+
95+
## Cross-Skill Boundaries
96+
97+
- Use `writing-nix` before editing any Nix that packages or wires the plugin
98+
(e.g. Nixvim/`vimUtils` derivations).
99+
- Use `git-toolkit` for commit strategy and local history surgery.
100+
- Use `github-toolkit` for PR review comments and CI check triage.
101+
- Prefer one-off tooling via `nix shell`/`,` (stylua, luacheck, neovim nightly)
102+
instead of adding persistent dependencies just to lint or test.
103+
104+
## Reporting Rules
105+
106+
- Show exact commands, file paths, and minimal snippets.
107+
- Label snippets as one of: executed, syntax checked, or template only.
108+
- Separate measured facts (startup time, test output) from design guidance.
109+
- Name the Neovim version when behavior is version-gated (e.g. `vim.validate`
110+
signature deprecated in 0.11, removed in 1.0).
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
interface:
2+
display_name: "Lua Toolkit"
3+
short_description: "Neovim Lua plugin architecture, config, lazy.nvim, tooling, tests, and distribution"
4+
default_prompt: "Help architect, configure, integrate, lint, test, or distribute a Neovim Lua plugin."
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
# Public APIs, Commands, Keymaps, and Filetypes
2+
3+
Use this playbook for exposing user-facing entry points and buffer/filetype
4+
behavior. These follow the mrcjkb / nvim-best-practices conventions.
5+
6+
## One Scoped Command with Subcommands
7+
8+
Do **not** create a separate command per action (`:MyPluginInstall`,
9+
`:MyPluginPrune`, ...). Expose a single scoped command that dispatches
10+
subcommands, with completion for each: `:MyPlugin install`, `:MyPlugin update`.
11+
12+
Register it with `nargs = "+"`, parse `opts.fargs`, and supply a `complete`
13+
function that filters subcommands by the current prefix.
14+
15+
```lua
16+
-- File: plugin/my_plugin.lua (template only)
17+
local subcommands = {
18+
install = function(args) require("my_plugin.core").install(args) end,
19+
update = function(args) require("my_plugin.core").update(args) end,
20+
}
21+
22+
vim.api.nvim_create_user_command("MyPlugin", function(opts)
23+
local name = table.remove(opts.fargs, 1)
24+
local sub = subcommands[name]
25+
if not sub then
26+
return vim.notify("MyPlugin: unknown subcommand " .. tostring(name), vim.log.levels.ERROR)
27+
end
28+
sub(opts.fargs) -- defer require() into the handler
29+
end, {
30+
nargs = "+",
31+
desc = "my_plugin commands",
32+
complete = function(arg_lead, cmdline)
33+
-- only complete the first token as a subcommand name
34+
if cmdline:match("^%s*MyPlugin%s+%S*$") then
35+
return vim.tbl_filter(function(k)
36+
return k:find(arg_lead, 1, true) == 1
37+
end, vim.tbl_keys(subcommands))
38+
end
39+
return {}
40+
end,
41+
})
42+
```
43+
44+
This is the pattern behind `:Rocks <subcommand>` and `mrcjkb/rustaceanvim`'s
45+
`:RustLsp <subcommand>` (which also sets `range = true` so subcommands can act on
46+
a visual selection via `opts.range`/`opts.line1`/`opts.line2`). For complex
47+
argument parsing, a library such as `mega.cmdparse` reduces boilerplate.
48+
49+
## Keymaps: Provide `<Plug>`, Set No Defaults
50+
51+
Do **not** create automatic/default keymaps (they conflict with user configs)
52+
and do **not** invent a keymap-setup DSL. Expose `<Plug>` mappings and let users
53+
bind their own keys.
54+
55+
`<Plug>` mappings: one-line user config, safe when the plugin is absent, support
56+
`expr = true`, and route by mode via Neovim's C-level mode handling — no
57+
hand-rolled mode checks in the payload.
58+
59+
```lua
60+
-- File: plugin/my_plugin.lua (template only)
61+
vim.keymap.set("n", "<Plug>(MyPluginAction)", function()
62+
require("my_plugin.core").execute_action({ mode = "normal" })
63+
end, { desc = "my_plugin: action (normal)" })
64+
65+
vim.keymap.set("x", "<Plug>(MyPluginAction)", function()
66+
require("my_plugin.core").execute_action({ mode = "visual" })
67+
end, { desc = "my_plugin: action (visual)" })
68+
```
69+
70+
Users bind it themselves; Neovim routes by active mode:
71+
72+
```lua
73+
vim.keymap.set({ "n", "x" }, "<leader>ma", "<Plug>(MyPluginAction)")
74+
```
75+
76+
If you must ship a default binding, gate it on
77+
`vim.fn.hasmapto("<Plug>(...)") == 0` so you never clobber a user mapping — but
78+
prefer shipping no default at all.
79+
80+
## Buffer-Local State and Filetypes
81+
82+
Plugins that present a UI buffer (file explorers, dashboards, floating
83+
diagnostics) should assign a custom `filetype` so users get granular control.
84+
85+
- Set the custom filetype **as late as possible** in buffer init, so the
86+
`FileType` event fires only after data structures are ready. This lets users'
87+
own autocommands and `ftplugin/` scripts run against a fully realized buffer.
88+
89+
For language-specific behavior on existing filetypes, put logic in
90+
`ftplugin/<lang>.lua`, not a monolithic autocommand block in core init. To
91+
override `$VIMRUNTIME` defaults, use `after/ftplugin/<lang>.lua`. Guard against
92+
re-sourcing with a buffer-local variable.
93+
94+
```lua
95+
-- File: ftplugin/rust.lua (template only)
96+
if vim.b.did_my_plugin_rust_setup then
97+
return
98+
end
99+
vim.b.did_my_plugin_rust_setup = true
100+
101+
local bufnr = vim.api.nvim_get_current_buf()
102+
103+
vim.keymap.set("n", "<Plug>(MyPluginRustAnalyzer)", function()
104+
require("my_plugin.rust_tools").run_analyzer()
105+
end, { buffer = bufnr, desc = "my_plugin: run analyzer" })
106+
```
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
# Configuration UX
2+
3+
Use this playbook for designing the user-facing configuration interface and
4+
runtime health checks. These follow the mrcjkb / nvim-best-practices
5+
conventions.
6+
7+
## Separate Configuration from Initialization
8+
9+
Two distinct concerns:
10+
11+
- **Configuration** — the user's chosen options. Should be declarable with no
12+
side effects and no mandatory function call.
13+
- **Initialization** — wiring commands, autocommands, and state. Should happen
14+
automatically and lazily (see plugin-architecture), not be forced on the user.
15+
16+
## `setup()` Is Not a Gate — Prefer `vim.g.<plugin>`
17+
18+
Do not force users to call `setup()` to use the plugin. The preferred interface
19+
is a `vim.g.<plugin>` (or `vim.b.<plugin>` for buffer-local) namespace table: it
20+
is Vimscript-compatible, requires no initialization call, and can be read lazily
21+
so users may set it before _or_ after the plugin loads.
22+
23+
Allow the value to be a table or a function returning a table (rustaceanvim
24+
style), so users can compute config lazily:
25+
26+
```lua
27+
-- User config (no setup() needed):
28+
---@type my_plugin.Opts
29+
vim.g.my_plugin = {
30+
strategy = "incremental",
31+
ui = { border = "rounded" },
32+
}
33+
-- or a function, evaluated when the plugin reads it:
34+
vim.g.my_plugin = function()
35+
return { strategy = "incremental" }
36+
end
37+
```
38+
39+
This is the `vim.g.<plugin>` interface documented by `mrcjkb/rustaceanvim`
40+
(`vim.g.rustaceanvim`, a `rustaceanvim.Opts` table or function).
41+
42+
Read and merge it lazily inside the plugin, with type annotations marking the
43+
user-facing table as partial and the internal config as fully-defaulted:
44+
45+
```lua
46+
-- File: lua/my_plugin/config.lua (template only)
47+
local M = {}
48+
49+
---@class my_plugin.Config -- internal: every field non-nil
50+
local default_config = {
51+
strategy = "incremental",
52+
ui = {
53+
border = "rounded",
54+
icons = true,
55+
colors = { error = "#ff0000", warn = "#ffff00" }, -- dict: deep-merged
56+
},
57+
capabilities = { "formatting", "diagnostics" }, -- list: overwritten
58+
}
59+
60+
--- Resolve user config (table or function) and merge over defaults.
61+
---@return my_plugin.Config
62+
function M.get()
63+
---@type my_plugin.Opts | fun():my_plugin.Opts | nil
64+
local user = vim.g.my_plugin
65+
if type(user) == "function" then
66+
user = user()
67+
end
68+
local merged = vim.tbl_deep_extend("force", default_config, user or {})
69+
M.validate(merged)
70+
return merged
71+
end
72+
73+
return M
74+
```
75+
76+
And the user-facing class, declared partial so all fields are optional:
77+
78+
```lua
79+
---@class (partial) my_plugin.Opts: my_plugin.Config
80+
```
81+
82+
Keep a `setup()` only as an optional convenience for users who prefer it; it
83+
must not be the sole entry point.
84+
85+
## Merge Semantics: `vim.tbl_deep_extend`
86+
87+
`vim.tbl_deep_extend(behavior, ...)` (usually `"force"`) merges tables but
88+
treats the two table shapes differently:
89+
90+
- **Dictionary-like** (string keys): merged recursively.
91+
- **List-like** (sequential integer keys): opaque, **overwritten** wholesale,
92+
not concatenated.
93+
94+
Intentional: lists usually represent atomic, ordered collections (tool order,
95+
flags). If the default is `capabilities = { "formatting", "diagnostics" }` and
96+
the user passes `capabilities = { "formatting" }`, the result is exactly the
97+
user's single-item list — no merge, no dedup.
98+
99+
## Validation: `vim.validate`
100+
101+
Validate the merged table to fail fast with a clear path to the bad field.
102+
103+
The table-spec signature `vim.validate({ name = { value, type } })` is
104+
**deprecated in Neovim 0.11** (removal in 1.0): it allocates transient tables
105+
and is slower. Use the per-argument signature
106+
`vim.validate(name, value, validator, optional_or_msg)`:
107+
108+
```lua
109+
-- File: lua/my_plugin/config.lua (continued) — template only
110+
function M.validate(cfg)
111+
vim.validate("strategy", cfg.strategy, "string")
112+
vim.validate("ui_border", cfg.ui.border, "string", true) -- optional
113+
vim.validate("icons", cfg.ui.icons, "boolean")
114+
vim.validate("capabilities", cfg.capabilities, "table")
115+
end
116+
```
117+
118+
## Health Checks: `lua/<plugin>/health.lua`
119+
120+
Provide a `:checkhealth` report via `lua/<plugin>/health.lua` using `vim.health`
121+
(`:h vim.health`, `:h health-dev`). This is the right home for the expensive
122+
checks you should _not_ run synchronously in init, including unknown-field /
123+
typo detection. Validate:
124+
125+
- user configuration correctness (types, unknown keys),
126+
- that the plugin initialized as expected,
127+
- presence of required Lua dependencies,
128+
- presence of external (binary) dependencies.
129+
130+
```lua
131+
-- File: lua/my_plugin/health.lua (template only)
132+
local M = {}
133+
134+
function M.check()
135+
vim.health.start("my_plugin")
136+
if vim.fn.executable("some-cli") == 1 then
137+
vim.health.ok("`some-cli` found")
138+
else
139+
vim.health.error("`some-cli` not found", { "install some-cli and re-run" })
140+
end
141+
end
142+
143+
return M
144+
```

0 commit comments

Comments
 (0)