Writing nxvim plugins
A plugin is pure Lua over the nx.* API — no Rust, no Vimscript. The server
owns every UI surface (windows, floats, the completion menu, the statusline); a
plugin supplies data and behavior and reaches the screen through nx.*.
Our prime directive is every feature that can be a plugin is one, so we exercise
our own APIs.
If you’ve written a neovim plugin, the shape is familiar — a lua/<name>/init.lua
module exposing setup(opts) — but the API you call is nx.*, not vim.*. (A
closed whitelist of vim.* muscle-memory aliases exists for config ergonomics; new
plugin code should target nx.* directly.)
Anatomy of a plugin
A plugin is a directory laid out the way neovim plugins are, so it resolves on the runtimepath:
my-plugin/
├── lua/
│ └── my-plugin/
│ └── init.lua # the module: returns { setup = … }
├── plugin/ # optional: *.lua here is auto-sourced at load
│ └── my-plugin.lua
├── after/plugin/ # optional: sourced after plugin/
├── colors/ # optional: colors/<name>.lua for a colorscheme
└── test/ # optional: *_spec.lua (see Testing)
└── my-plugin_spec.lua
The conventional entry point is a module that exposes setup:
-- lua/my-plugin/init.lua
local M = {}
function M.setup(opts)
opts = opts or {}
nx.keymap.set("n", "<leader>x", function()
nx.notify("hello from my-plugin", "info")
end, { desc = "my-plugin: do the thing" })
end
return M
setup should be idempotent (a user may call it more than once) and side-effect
light at module load — do the wiring in setup, not at require time, so load order
and lazy-loading stay predictable.
Installing & loading
Two paths, both runtimepath-based:
The built-in manager (nx.plugins). Declare plugins in init.lua; the manager
clones, runtimepaths, and loads them (with optional lazy triggers):
nx.plugins({
-- eager:
{ "davidrios/nxvim-keys-helper",
config = function() require("nxvim-keys-helper").setup({}) end },
-- lazy by key / command / event / filetype:
{ "owner/repo", keys = { "<leader>ff" }, cmd = "Find",
config = function() require("repo").setup({}) end },
-- pinned, or a local checkout for development:
{ "owner/repo", tag = "v1.0.0" },
{ name = "my-plugin", dir = "/path/to/my-plugin",
config = function() require("my-plugin").setup({}) end },
})
Then :PluginSync (clone missing + update), :PluginInstall, :PluginUpdate,
:PluginClean, :PluginList, or the :Plugins dashboard. Cloned plugins live under
stdpath("data")/plugins/<name>. A spec with cmd/event/ft/keys (or
lazy = true) loads on first use; config runs after the plugin is on the
runtimepath, init runs at startup regardless.
By hand. Drop the plugin under <config>/pack/*/start/* and require it from
init.lua — the runtimepath picks it up with no manager involved.
The nx.* surfaces you’ll use
A plugin composes these (each has runnable examples under examples/
and a deeper treatment in the API design):
- Keymaps —
nx.keymap.set(mode, lhs, rhs, opts)/nx.keymap.del; introspect withnx.keymap.get. Always pass adesc— it surfaces in completion and which-key. - User commands —
nx.command(name, fn, { desc = …, usage = …, complete = … });fnreceives{ args, fargs, bang, … }.usageis the argument signature in vim help notation (usage = "[file]","{name}") — it heads the command’s:-completion docs pane as:Name <usage>, exactly like a built-in.complete("file"/"dir"/ afn(args)) drives<Tab>completion of the argument. - Autocmds / events —
nx.on(event, { pattern = … }, fn)for editor lifecycle events (BufReadPost,FileType, …). - Options & vars — read/write
nx.o(global),nx.bo(buffer),nx.wo(window), andnx.g(globals). Edge docks have their own scope too:nx.dock.opt(side)(e.g.nx.dock.opt("left").size = 32), alongsidenx.bo/nx.wo— per-dockshowtabline,laststatus,size,title,winhighlight, andautohide. - Highlights —
nx.hl.define(ns, name, spec),nx.hl.get,nx.hl.exists. Define your groups as fallbacks (nx.hl.existsguard) so a colorscheme that already styles them wins. - Messages —
nx.notify(msg, level). - Async — the editor is single-threaded and tick-based, so anything that waits
is promise-based:
nx.async/nx.await,nx.promise,nx.run/nx.run_stream(subprocesses),nx.fs(filesystem),nx.timer,nx.utils.debounce, and the scheduling primitivesnx.schedule(end of the current tick) /nx.on_next_tick/nx.wait_for(pred, opts)(across ticks). Reach foron_next_tick/wait_for— never anx.schedulere-arm — when waiting on state that only refreshes between ticks (e.g. a freshly-mounted window id). Full guide: Async & promises. - UI — the floating-widget layer
nx.ui.input/select/confirm/float(promise-based, never steals focus forfloat), andnx.component(reactive state + a pure render + lifecycle) for live popups. Bigger server-owned surfaces:nx.picker(fuzzy finder),nx.complete(completion sources),nx.snippet,nx.statusline(composable segments),nx.decor(viewport decorations / extmarks),nx.dock(edge docks), andnx.view(a read-only mountable content surface — what a plugin ui is built on).
When something genuinely useful is missing, the convention is to add it to nx.*
for everyone, so let us know if you find anything missing.
A worked example
nxvim-keys-helper — the
first-party which-key — is a compact, real-world plugin: it subscribes to the
pending-key oracle (nx.on_key_pending), debounces with nx.utils.debounce, and
renders the continuations on a non-focus nx.component{ surface = "float" }. It is
packaged exactly as above (lua/nxvim-keys-helper/init.lua with setup/add) and
carries its own test suite.
The examples/ directory has ~40 self-contained configs — one per
feature — that double as plugin-authoring references.
Testing
Plugins are pure Lua, so their tests are too. nxvim ships a native test framework
(nx.test) and a headless runner — write test/*_spec.lua, then:
nxvim --test-plugin .
The suite drives a real editor (feed keys, assert on buffer / cursor / UI) and
exits 0/1 for CI. See the full guide: Testing plugins.
See also
- Native plugin API design — the model and six worked API examples.
- ADR 0002 — native plugin system — why
nx.*, and the exactvim.*alias whitelist. - Testing nxvim plugins —
nx.test+nxvim --test-plugin. - Architecture — the crate layout, tick model, and Lua bridge.