Imported from Aetf/ucw.nvim (
AGENTS.md). Install upstream withnpx skills add Aetf/ucw.nvim. Copyright stays with the author.
AGENTS.md — working in ucw.nvim
The rules for changing this Neovim config, for agents and humans. Read this
first. The reference behind each rule is elsewhere: docs/architecture.md
is the code map, docs/extending.md the extension points (recipes, module
APIs, test helpers, tooling), docs/features.md and docs/keys.md what the
editor does and which key does it, docs/testing.md the suite's mechanics.
The reasoning behind a rule is in the design document it cites under
docs/design/.
What this is
A personal Neovim config on lazy.nvim, one spec file per plugin under
lua/ucw/plugins/, with LSP on Neovim's native vim.lsp.config /
vim.lsp.enable layers. There is no plugin engine, no logger and no
framework of its own. Boot order is docs/architecture.md § Boot; the
LspAttach handler is installed before lazy.nvim on purpose. Context
predicates for spec cond are lua/ucw/targets.lua
(is_gui/is_firenvim/is_vscode/is_full_ui).
Layout
lua/ucw/plugins/*.lua— one lazy.nvim spec per plugin, including its keys.lua/ucw/plugins/user/— a same-named file here merges over the base spec (later import wins). Empty today.lua/ucw/{options,builtin-plugins,keys,extras,targets,toggles,utils,git,health,gui}.lua,lua/ucw/keys/actions.lua,lua/ucw/neotree/,lua/ucw/textobjects/— the config's own code.lua/ucw/lsp/—servers.lua(the server list),init.lua(enable/filetypes),attach.lua(LspAttach),actions.lua,vscode.lua,ltex_dict.lua,texlab_sync.lua,utils.lua.after/lsp/<server>.lua— per-server settings Neovim discovers itself.ftplugin/<ft>.lua— filetype options andconformformatters.scripts/—keymap-snapshot.lua,keys-doc.lua,luarc-lint-config.lua,tui-drive.sh.tests/,docs/,justfile,mise.toml,.luarc.json,lazy-lock.json.
How to add or change a plugin
One file, lua/ucw/plugins/<plugin>.lua, returning a lazy.nvim spec. Start
from a neighbour (gitsigns.lua for an eager plugin with keys, codediff.lua
for a lazy one, lspconfig.lua for ft-triggered). Each of these fails
silently when wrong:
keys =makes the spec lazy. A plugin that has to exist from startup (gutter, tabline, session autosave, formatter) needs an explicitlazy = falsenext to its keys, and a line intests/test_keys.lua's eager census (an explicit list; extend it when you add one).cond = require('ucw.targets').is_full_uion anything with a UI surface of its own. A spec withcondfalse does not exist in lazy.nvim's plugin table at all, so code reaching into it from another spec mustpcallor share the gate.- Order between eager specs is only guaranteed by
dependencies. optsis a table,configis a function; anything that has to run aftersetup()(Snacks.toggle registration, buffer-local overrides) goes inconfig.lazy-lock.jsonis checked in and CI fails if it drifts. Update through:Lazy update; never hand-edit.:Lazy! installdoes not downgrade an installed plugin; to A/B an older version,git -C <plugin dir> checkout <sha>or:Lazy! restore.- Keys go under the prefix
docs/design/phase9-keybindings.md§5 assigns, not the plugin's README defaults. A new namespace claims a free prefix, gets an eager group header inwhich-key.lua(lowercase label, expliciticon,mode = { 'n', 'x' }) and a row in the group census intests/test_keys.lua. Thenjust keys-doc:docs/keys.md's tables are generated andtests/test_keys_doc.luafails while they are stale.
Removing a plugin: delete the spec, run :Lazy clean, commit the lockfile
change. just lint reads its library list from the lockfile, so a plugin
left on disk but out of the spec does not leak its globals into the lint.
Key idioms
-
Autocmds —
vim.api.nvim_create_autocmdwith a named, clearedaugroup; no DSL.BufModifiedSetis gone in 0.13; useOptionSetwith patternmodified. -
Keys — a plugin's keys live in its own spec as lazy.nvim
keys = { { lhs, rhs, desc = '...', silent = true }, ... }entries;lua/ucw/plugins/which-key.luakeeps only group headers and core editor keys; toggles areSnacks.toggleobjects (lua/ucw/toggles.luafor the editor's own, the plugin's spec for its own) so which-key shows live state, never a plain<cmd>rhs. Named actions inkeys/actions.lua, LSP ones inlua/ucw/lsp/actions.luaas paths (so a renamed API fails a test instead of leaving a dead key). -
A
descthat looks like a rhs ({ lhs, desc = '<cmd>...<cr>' }) is a key that does nothing and a popup entry that reads like code; in bothwk.addandkeys =the rhs is the second array element and a missing one is accepted silently.tests/test_keys.luafails on it. -
The keymap snapshot sees only global maps. Anything registered on
LspAttach,FileTypeorBufWinEnteris buffer-local and needs its ownnvim_buf_get_keymapassertion, both halves (present where it should be, absent where it should not). -
Previous/next is
[/]+ a category letter, count-aware where native ([d,[q).gnever means a direction;g[/g]are mini.ai's edge motions.]]/[[are not remapped. -
qcloses a read-only window;<Esc>never does. Terminal-style windows close with the key that opened them (docs/design/phase9.5-trial-period.md§7.3). -
Toggles are global unless the state is inherently per buffer. The inlay-hint toggle drives the global flag and
attach.luamirrors it into each buffer (the per-bufferSnacks.toggle.inlay_hints()factory is the trap;docs/extending.md§ucw.toggles). -
Options — plain
vim.opt.*inoptions.lua, commented with why. Takevim.opt.Xinto a---@type vim.Optionlocal before:appendso lua_ls does not infer the field's type from some plugin's assignment. -
LSP — use Neovim's native layers. To add a server: one line in
lua/ucw/lsp/servers.lua(name = { filetypes }), which drivesvim.lsp.enable(), Mason'sensure_installedand the lazyfttrigger at once; addafter/lsp/<name>.luaonly if it needs settings, and keep that file table-only (function fields replace nvim-lspconfig's outright instead of composing). Per-buffer behaviour goes in anLspAttachautocmd:lua/ucw/lsp/attach.luafor anything general, the plugin's own spec for anything server-specific. Nothing is eager and there is no enable key.docs/design/phase3-lsp-redesign.md.Invariants that break silently:
- A spec that starts a language server must
dependenciesonmason.nvim. Mason'ssetup()puts the server binaries onPATH; without it the client never starts and nothing reports it. ucw.lsp.vscodeis the only writer ofclient.settings. Anything file-backed is a sidecar in that module, never a second writer (docs/extending.md§ucw.lsp.vscode,docs/design/phase3-settings-composition.md).- A toggle and the thing it toggles must agree on scope. Enabling a
capability with a literal
trueat attach makes any toggle over it appear to need two presses and forget itself on the next file. - Per-server capability edits go in
attach.lua's table, not inafter/lsp/(ruff'shoverProvideris declined there). vim.lsp.configmerge semantics: tables deep-merge, lists replace whole, a function field at the top level replaces the lower layer's.
- A spec that starts a language server must
-
Formatting —
conform;formatters_by_ftis set per filetype inftplugin/<ft>.lua. A server that self-reports formatting but must not format (lua_ls,texlab) needslsp_format = 'never'there, in every filetype the server attaches to.docs/design/phase6-format-lint.md. -
Anything that runs a
BufWriteor auto-installs at boot is gated onis_full_ui(format-on-save, treesitter parser install);#nvim_list_uis() > 0alone is not the gate, embedded contexts attach a UI too. -
Binaries resolve through
PATH, Mason appended last.:checkhealth ucwprints what each declared binary resolved to; a broken binary onPATHfails silently everywhere else.
Testing
Harness is mini.test; just unit / just int / just all / just ci.
Run tests only through just or with mise exec -- in front: the formatters
tests/test_format.lua drives are the versions mise.toml pins. Write tests
with H.new_unit_test() (pure modules) or H.new_integration_test() (the
booted config); the helper API and the child's traps are in
docs/extending.md § Testing and docs/testing.md.
A guard is not done until it has been reverse-verified: reinstate the bug it covers and watch it go red.
Judge what the config draws, not only its logs: docs/tui-observation.md
(child.get_screenshot() in tests, just tui … ad hoc; asynchronous views
need a state poll before a capture).
CI
.github/workflows/ci.yml only ever calls just, so reproducing CI is:
just ci # the suite (matrix: neovim stable + nightly, nightly advisory)
just lint # lua-language-server --check, gated on .luarc.json
just fmt-check # stylua --check
git diff --exit-code lazy-lock.json # after `just ci`: the lockfile did not drift
Three things that fail quietly when undone:
.luarc.json'sruntime.pathandruntime.pathStricttravel together (tests/test_luarc.lua); withoutpathStrict,require('snacks')resolves to this repo's ownsnacks.luaspec and the lint hides real findings.just linterrors rather than checking less when an input ($VIMRUNTIME, the plugin library,deps/mini.nvim) is missing. Do not remove the checks.just test/plugins/lint/keys-docsetXDG_CONFIG_HOME+NVIM_APPNAMEso this checkout is the config Neovim loads;rtpis not a substitute. Simulating a runner needs a copy and an empty config directory (docs/testing.md§ CI).
Suppressions are ---@diagnostic disable-next-line: <code> with a comment
naming the evidence; the ones deliberately left are listed in
docs/design/phase7-ci.md §7.
Conventions
- Style: stylua (
stylua.toml), everywhere includingtests/;just fmtwrites,just fmt-checkis the gate. - Comments and docs are as-built: what the code does now and why, never
what was tried. A new feature — including the debugger under
<leader>dand AI integration under<leader>a, both reserved — lands as one ordinary change: spec, keys, tests, and the edits to this file,docs/architecture.md,docs/features.md,docs/keys.mdanddocs/extending.mdtogether. Write a design document underdocs/design/only when reopening a recorded decision. - No logger. Use
vim.notify; everything it emits is retrievable afterwards from<leader>n. VAR=x just …does not reach the recipe (thejustwrapper is a zsh script that resetsXDG_*); set variables on the actual process (docs/testing.md§ CI).- Mason experiments use a scratch
XDG_DATA_HOME; never write into the real~/.local/share/nvimfrom a test. - After a Neovim upgrade, update nvim-treesitter and
:TSUpdatetogether. A query/runtime mismatch kills the async parse coroutine and every redraw errors until:e!.