Skip to content

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.

Use the philosophy rules:

  • Most users benefit? → core (lua/blak/plugins/ or lua/blak/core/).
  • Some users benefit, and the cost of carrying it is non-zero? → extra.
  • Just personal taste? → your own user.lua or plugins.specs.

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 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.

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.

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.

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:

  • cmd for command-driven tools.
  • ft for language or filetype-specific behavior.
  • event for UI that can appear after startup, such as VeryLazy.
  • keys when lazy.nvim owns the mapping.
  • lazy = true only 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.

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.

Terminal window
./dev-install.sh
blak-dev
:BlakExtras
:BlakExtras enable lang.zig
:BlakExtras sync " if plugins changed
:BlakToolsInstall " if mason changed
:BlakTreesitterInstall " if parsers changed
:BlakDoctor " confirm everything resolved

Then exercise the actual behavior — open a .zig file, check LSP attaches (:lua = vim.lsp.get_clients()), confirm formatting works.

Terminal window
make validate

The static checker enforces:

  • The require path in modules matches an actual file.
  • The file’s id field is unique across all extras.
  • Plugin specs lazy-load with cmd, event, ft, keys, or explicit lazy = true.
  • Every plugin has a shipped pin in lazy-lock.json.
  • The file’s Lua syntax balances delimiters and keywords.

Then a smoke run:

Terminal window
make smoke

Boots Neovim headless, calls require("blak").setup(), and runs :checkhealth blak.

A few places, all in this repo:

  1. Add a dedicated page under docs/src/content/docs/extras/<group>/<name>.md.
  2. Link that page from the Extras guide and the docs sidebar in docs/astro.config.mjs.
  3. If the extra adds a keymap, mention it in the Keymaps page under “Keymaps added by extras.”

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.

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.