Writing an extra
Extras are the right place for any capability that doesn’t belong in core — language support, optional plugins, alternative providers, opinionated tweaks. The contract is small and the reward is automatic: enable / disable from :BlakExtras works for free.
When something should be an extra
Section titled “When something should be an extra”Use the philosophy rules:
- Most users benefit? → core (
lua/blak/plugins/orlua/blak/core/). - Some users benefit, and the cost of carrying it is non-zero? → extra.
- Just personal taste? → your own
user.luaorplugins.specs.
Anatomy
Section titled “Anatomy”Create a file at lua/blak/extras/<group>/<name>.lua. Groups today: lang, ui, git, ai, editor. Add a new group folder if your extra doesn’t fit.
The file returns a single table:
return { id = "lang.zig", -- unique, dotted, matches path label = "Zig", -- short human label description = "zls + treesitter", -- one line shown in :BlakExtras
-- Any of these are optional; include only the ones your extra needs. treesitter = { "zig" }, -- merged into ensure_installed mason = { "zls" }, -- merged into mason.ensure_installed lsp = { servers = { zls = { settings = { zls = { enable_inlay_hints = true } }, }, }, }, format = { formatters_by_ft = { zig = { "zigfmt" } }, }, lint = { linters_by_ft = { zig = { "ziggrep" } }, }, snacks = { dim = { enabled = true }, -- deep-merged into snacks opts }, keys = { -- show up in :BlakKeys { lhs = "<leader>cc", rhs = ":!zig build<CR>", desc = "zig build" }, }, plugins = { -- lazy.nvim specs added to the set { "ziglang/zig.vim", ft = "zig" }, }, integrations = { -- only when another extra is enabled too ["debug.dap"] = { mason = { "codelldb" }, plugins = {}, keys = {} }, }, apply = function(config) -- runs after merge, before plugin load -- escape hatch for anything the declarative fields don't cover config.snacks.scroll = { enabled = false } end,}Integrations with other extras
Section titled “Integrations with other extras”integrations holds what an extra adds only when another extra is enabled
too. Each entry is keyed by the other extra’s ID and may contain mason,
plugins, and keys. Blak applies it once both extras are enabled, in either
order, including when one is enabled in the current session. The language
extras use this to give debug.dap and test.neotest their adapters, so
neither installs anything for a language you do not use.
Integration plugin specs are lazy.nvim fragments. They can extend another
extra’s spec, for example by adding a dependency and merging opts into
nvim-dap or neotest. Keep the merge keyed by name so that two extras that
add the same adapter do not create it twice.
Shipped pins
Section titled “Shipped pins”Every plugin an extra installs needs a pin in the checkout’s lazy-lock.json.
After adding or renaming a plugin, run make pins and commit the updated
lockfile. make validate reports plugins without a pin.
Aliases and alternate language stacks
Section titled “Aliases and alternate language stacks”Use alias = "lang.typescript" to preserve an older extra ID while delegating
to the canonical extra. Keep aliases pointed directly at a canonical ID. The
canonical stack is applied once even when both IDs are enabled; an alias’s
optional apply function runs before delegation to migrate old settings.
Use supersedes = { "lang.python" } when an alternate language stack replaces
the basic stack’s defaults. This affects only LSP servers, formatter lists, and
linter lists contributed by the superseded extra. Explicit user settings and
defaults edited afterward are preserved. The result is independent of which
extra is enabled first.
Superseding does not disable the saved extra or remove its plugins, parsers, or installed tools. Document which extra users should enable, which to disable, and when a restart is needed to unload the previous stack.
Performance contract
Section titled “Performance contract”Extras are opt-in, but they still must not become surprise startup cost. Every plugin spec in an extra must lazy-load with one of these:
cmdfor command-driven tools.ftfor language or filetype-specific behavior.eventfor UI that can appear after startup, such asVeryLazy.keyswhen lazy.nvim owns the mapping.lazy = trueonly when Blak keymaps or provider code explicitly load the plugin on demand.
Avoid lazy = false in extras. If a feature truly has to load at startup, it probably belongs in core or needs a design discussion first.
Register it
Section titled “Register it”Add the require path to the module list in lua/blak/extras/init.lua:
local modules = { -- ... "blak.extras.lang.zig",}That’s the entire registration. The registry caches results and looks up extras by their id field, so the order of modules doesn’t matter.
Test it locally
Section titled “Test it locally”./dev-install.shblak-dev:BlakExtras:BlakExtras enable lang.zig:BlakExtras sync " if plugins changed:BlakToolsInstall " if mason changed:BlakTreesitterInstall " if parsers changed:BlakDoctor " confirm everything resolvedThen exercise the actual behavior — open a .zig file, check LSP attaches (:lua = vim.lsp.get_clients()), confirm formatting works.
Validate
Section titled “Validate”make validateThe static checker enforces:
- The require path in
modulesmatches an actual file. - The file’s
idfield is unique across all extras. - Plugin specs lazy-load with
cmd,event,ft,keys, or explicitlazy = true. - Every plugin has a shipped pin in
lazy-lock.json. - The file’s Lua syntax balances delimiters and keywords.
Then a smoke run:
make smokeBoots Neovim headless, calls require("blak").setup(), and runs :checkhealth blak.
Document it
Section titled “Document it”A few places, all in this repo:
- Add a dedicated page under
docs/src/content/docs/extras/<group>/<name>.md. - Link that page from the Extras guide and the docs sidebar in
docs/astro.config.mjs. - If the extra adds a keymap, mention it in the Keymaps page under “Keymaps added by extras.”
Open a PR
Section titled “Open a PR”Describe what the extra adds and why it isn’t a core feature. The bar for core is high — extras are the path of least resistance.
Reversibility checklist
Section titled “Reversibility checklist”After disabling an extra and restarting, its contributions should be absent unless another enabled extra or the user configuration also supplies them:
- ✅ Treesitter parsers — removed from the install list (already-installed parsers stay until you uninstall manually; that’s fine).
- ✅ Mason tools — removed from the install list.
- ✅ LSP servers — the
vim.lsp.config()entry isn’t registered. - ✅ Formatters / linters — not added to the by-filetype maps.
- ✅ Keymaps — not registered.
- ✅ Plugin specs: lazy.nvim removes them on the next
:BlakExtras sync.
If your extra has side effects beyond these (writes a file, mutates a global), undo them in a second function or document them in the extras guide so users know.