Overview
This page is the spaced-repetition companion to the Extending Neovim topic: five fixed drills that
force active recall instead of passive re-reading. Work through them in order -- short-answer recall
first, then scenario judgment, then hands-on repetition, then a checklist to confirm real automaticity,
and finally why/why-not prompts that test whether you can explain the reasoning, not just execute the
API call. Every answer is hidden in a <details> block; try each item yourself before opening it.
Unlike the Just Enough Lua drilling page's lua kata.lua scripts, this topic has no standalone
interpreter -- keymaps, autocommands, LSP attachment, and Tree-sitter highlighting only exist inside a
running Neovim session. Every code kata below was verified with a real nvim --headless session against
the same Neovim v0.12.3 install this topic's worked examples used, exactly like ../learning/code/'s own
transcript.txt convention.
Recall Q&A
Eighteen short-answer questions, one per concept (co-01 through co-18). Answer from memory, then
check.
Q1 (co-01 -- init-lua-entrypoint). Where does Neovim look for its single Lua entry point at startup,
and what does placing a lua/ directory beside it do?
Answer
init.lua under stdpath('config') (~/.config/nvim/init.lua by default) -- the one file Neovim
sources every time it starts. A lua/ directory placed beside it joins the runtimepath, which is what
makes every file inside it require()-able later.
Q2 (co-02 -- options-vim-o-vs-opt). Two accessors set editor options: vim.o and vim.opt. What's
the practical difference, and which option-flavor forces you to pick correctly?
Answer
vim.o gives direct scalar assignment for boolean/number/string values. vim.opt wraps the same options
in a Lua-table-like object supporting :append()/:remove(), which matters for options that are really
comma-separated lists under the hood -- assigning a list-like option with vim.o silently overwrites the
whole list instead of extending it.
Q3 (co-03 -- global-and-scoped-variables). Name the four scoped-variable wrappers this topic covers, and which one holds the single most common variable any config sets.
Answer
vim.g (global), vim.b (buffer), vim.w (window), vim.t (tab) -- typed wrappers over Vim's
corresponding variable scopes. vim.g.mapleader is the most common vim.g example any config sets.
Q4 (co-04 -- keymap-set). What are vim.keymap.set's four arguments, and what two different things
can rhs be?
Answer
mode, lhs, rhs, opts. rhs can be a string (an Ex command or another mapping) or a Lua function,
called directly with no command-line round trip.
Q5 (co-05 -- autocommands). Which two API calls pair up for event-driven config, and what does the
augroup's clear = true option prevent?
Answer
vim.api.nvim_create_autocmd(event, opts) paired with vim.api.nvim_create_augroup(name, {clear = true}). clear = true wipes the group's prior entries before each re-source adds new ones -- without
it, autocommands silently pile up duplicates every time a config is re-sourced.
Q6 (co-06 -- user-commands). Name at least four fields nvim_create_user_command's opts table
accepts, and name six fields its callback's argument table exposes.
Answer
Any four of nargs, range, count, complete, desc, force, preview, addr, plus generic
boolean |command-attributes| like bang and bar. The callback table exposes args, bang, count,
fargs, line1, line2, mods, name, nargs, range, reg, smods.
Q7 (co-07 -- lua-module-system). How does a dotted require() path map to a file on disk, and what
does package.loaded cache?
Answer
Subdirectories under lua/ map to dotted paths (lua/config/options.lua becomes
require('config.options')); an init.lua inside a folder collapses that folder into one requireable
unit. Once required, a module is cached in package.loaded until something explicitly clears that entry.
Q8 (co-08 -- plugin-management-vim-pack). Which packpath side does vim.pack.add install a plugin
into, and what tracks installed state across restarts?
Answer
The opt side of the packpath (site/pack/core/opt), never a start/ directory -- vim.pack
:packadds it programmatically. A JSON packlockfile tracks installed state.
Q9 (co-09 -- plugin-management-lazy-nvim). Name the four lazy-loading triggers lazy.nvim's
plugin-spec table supports, and what convention does an opts field trigger.
Answer
event, cmd, ft, keys. Setting opts on a spec tells lazy.nvim to call that plugin's own
.setup(opts) automatically once it loads -- a convention the plugin itself must actually support.
Q10 (co-10 -- native-lsp-config). What replaced require('lspconfig').<name>.setup{...}, and what
does the '*' wildcard target do?
Answer
The native pair vim.lsp.config(name, cfg) / vim.lsp.enable(name). vim.lsp.config('*', {...}) sets
shared defaults every subsequently enabled server inherits, without repeating them per server.
Q11 (co-11 -- lsp-server-configs-and-lspconfig). What is nvim-lspconfig's role now, and how does
Neovim discover a user-authored server config?
Answer
nvim-lspconfig is a config-only registry supplying lsp/<name>.lua files that vim.lsp.config
auto-discovers on the runtimepath. A user-authored lsp/<name>.lua in a reader's own config is
discovered and merged the exact same way, with no plugin API involved.
Q12 (co-12 -- lsp-attach-and-keymaps). Which autocommand replaced the old on_attach callback, and
what two values does its callback receive directly?
Answer
LspAttach. Its callback receives the attaching client and the buffer number directly (via
args.data.client_id and args.buf), letting keymaps and behavior be scoped correctly per buffer.
Q13 (co-13 -- default-lsp-keymaps). Name at least five of the eight unconditional global LSP keymaps Neovim 0.11+ ships out of the box.
Answer
Any five of grn (rename), gra (code action), grr (references), gri (implementation), grt (type
definition), gO (document symbols), grx (run codelens), K (hover).
Q14 (co-14 -- diagnostics-config). Which function controls diagnostic rendering, and name three of its main knobs.
Answer
vim.diagnostic.config(opts, namespace?). Any three of virtual_text, signs, underline, float,
severity_sort.
Q15 (co-15 -- native-lsp-completion). What does vim.lsp.completion.enable's autotrigger option
do, and what third-party dependency does this remove?
Answer
autotrigger = true wires as-you-type completion directly into Neovim's built-in insert-mode completion
once a server attaches. This removes the hard dependency on a third-party completion plugin such as
nvim-cmp.
Q16 (co-16 -- treesitter-highlighting). Which five languages ship a bundled Tree-sitter parser with
automatic highlighting, and what happened to the original nvim-treesitter plugin?
Answer
C, Lua, Markdown, Vimscript, Vimdoc -- vim.treesitter.start() runs automatically for these via
Neovim's own bundled ftplugin/ files. The historic nvim-treesitter plugin was archived 2026-04-03;
installing parsers for other languages now points to the active community fork's :TSInstall command.
Q17 (co-17 -- treesitter-textobjects-and-queries). Name the four vim.treesitter.query API
functions this topic covers, and the built-in object-select/sibling-navigation keys.
Answer
parse, get, set, iter_captures. an/in ("a node"/"in node") for object selection, ]n/[n
for sibling navigation, plus vim.treesitter.foldexpr() for syntax-tree-based folding.
Q18 (co-18 -- writing-a-custom-plugin-module). What three pieces does a self-authored plugin module need to be indistinguishable in shape from a third-party plugin?
Answer
A require()-able module exposing M.setup(opts), an optional plugin/*.lua file for autoloading at
startup with no explicit require() call, and a lua/<name>/health.lua module integrating with
:checkhealth.
Applied problems
Twelve scenarios. Each describes a task without naming the API -- decide which Neovim mechanism solves it, then check. Every scenario is answerable by reasoning about the concepts alone -- no live Neovim session is required to attempt one.
AP1. A growing init.lua already has vim.opt.wildignore:append({ "*.pyc" }) in one section. A
teammate, editing a different section of the same file, wants to also exclude node_modules and writes
vim.o.wildignore = "node_modules". What happens to the *.pyc entry, and what should they have written
instead?
Answer
vim.o.wildignore = "node_modules" assigns a scalar string outright, silently REPLACING whatever
wildignore already held -- the *.pyc entry is gone, with no error anywhere. They needed
vim.opt.wildignore:append({ "node_modules" }) instead, which extends the existing list-like option
rather than overwriting it wholesale.
AP2. A formatter setting needs to vary per open file (Project A wants width 100, Project B wants
width 80), without one buffer's choice leaking into every other open buffer in the same session. Which
variable scope do you reach for, and what would go wrong with vim.g instead?
Answer
vim.b (buffer-scoped): vim.b.formatter_width = 100 is visible only in the buffer that set it. Using
vim.g instead would make whichever buffer set it LAST win globally -- switching to Project B's buffer
would silently show Project A's width too, since a global variable is visible from every buffer at once.
AP3. You want q to close a floating help window, but only in help buffers -- binding q globally
would break Neovim's own macro-recording keystroke everywhere else. What two mechanisms combine to solve
this safely?
Answer
A FileType autocommand that fires only when the buffer's filetype becomes help, combined with
vim.keymap.set's buffer = args.buf option inside that autocommand's callback. The mapping then exists
only in that one buffer; every other buffer's q keeps its built-in macro-recording behavior untouched.
AP4. A custom :Upper command should uppercase only the lines a user actually selected with a
Visual-mode range, not the whole buffer regardless of selection. Which opts field makes this possible,
and which two fields must the callback then actually read to honor it?
Answer
{ range = true } lets the command accept a Visual-selection or explicit line range. The callback must
then read o.line1 and o.line2 and apply its effect only to that span -- a callback that accepts
range = true but ignores line1/line2 (operating on the whole buffer regardless) silently defeats
the entire point of accepting a range.
AP5. Two modules, lua/a.lua and lua/b.lua, are both require()-d from init.lua. lua/a.lua
does real setup work (builds a lookup table) the moment it loads. If lua/b.lua also calls
require('a') later in the same session, does a.lua's setup work run a second time?
Answer
No. require() caches a module's return value in package.loaded the first time it runs; every
subsequent require('a') call, from anywhere in the config, returns that same cached table without
re-running the file. This is exactly what guarantees a.lua and b.lua share the identical table
instance, not two separately-initialized copies.
AP6. A plugin providing a rarely-used database GUI should never slow down every single Neovim
startup, but should still feel instant to open when a user does type its command. Which manager and
trigger fit this, and why does lazy.nvim's cmd field beat vim.pack for this specific need?
Answer
lazy.nvim with a cmd trigger (cmd = 'DBUI'). The command registers as a real, callable,
tab-completable stub immediately at startup, but the plugin's actual module code doesn't load until that
command is typed for the first time. vim.pack has no equivalent command-triggered lazy-loading of its
own -- it installs and :packadds a plugin's opt-side files, but does not defer loading behind a
specific Ex command the way lazy.nvim's cmd spec field does.
AP7. Five different language servers all need the identical capabilities table and the identical
root_markers = {'.git'}. Writing this once per server means five nearly-identical
vim.lsp.config(name, {...}) calls that could drift out of sync over time. What single call replaces
all five?
Answer
vim.lsp.config('*', { capabilities = ..., root_markers = {'.git'} }). The wildcard sets shared defaults
every subsequently enabled server inherits -- one call instead of five, with no drift risk from updating
five separate config tables whenever the shared setting needs to change.
AP8. A teammate hand-writes vim.keymap.set('n', 'grn', vim.lsp.buf.rename, { buffer = bufnr })
inside their own LspAttach autocommand, worried that rename won't work otherwise. Is this binding
necessary on Neovim 0.11+, and what happens if they skip it entirely?
Answer
No, it's redundant. Neovim 0.11+ ships grn (rename) as an unconditional global keymap the instant any
server attaches -- no custom binding required. Skipping it entirely, pressing grn still opens
vim.lsp.buf.rename()'s prompt with zero config.
AP9. A file has both an error and a warning on the same line. Without severity_sort, which one is
guaranteed to render on top, and what does turning it on change?
Answer
Neither is guaranteed -- without severity_sort, two diagnostics sharing one line render in whatever
order they were reported (effectively insertion order), so a low-severity hint could visually cover a
same-line error's sign or virtual text. Turning severity_sort on makes the error always outrank the
warning visually, regardless of which one Neovim happened to receive first.
AP10. A reader finds automatic completion (autotrigger = true) too aggressive and wants completion
only when they explicitly ask for it with Ctrl-Space, with no popup appearing while they simply type.
Which function do they bind to Ctrl-Space, and does it require autotrigger to be configured anywhere?
Answer
vim.keymap.set('i', '<C-space>', vim.lsp.completion.get). Manual and automatic triggering are
independent -- vim.lsp.completion.get can be bound directly with no autotrigger configuration
anywhere in the config; the two features never depend on each other to function.
AP11. A reader's .lua files show syntax-aware highlighting the instant they open them, with zero
plugins installed -- but a .rs (Rust) file they open right after shows no highlighting at all. Is this
a bug, and what's the general two-step fix?
Answer
Not a bug -- Lua is one of the five languages (C, Lua, Markdown, Vimscript, Vimdoc) whose parser ships
bundled with Neovim, with automatic vim.treesitter.start() wired in by Neovim's own
ftplugin/lua.lua. Rust is not one of the five, so nothing highlights it automatically. The general fix:
install the parser via the active community fork's :TSInstall rust (the original
nvim-treesitter/nvim-treesitter has been archived since 2026-04-03), then add a FileType autocommand
calling vim.treesitter.start(0, 'rust') for languages Neovim's bundled ftplugin/ files don't already
wire up.
AP12. A plugin author wants :checkhealth myplugin to report a per-check OK/ERROR result the same
way :checkhealth reports for any bundled Neovim component. Which file and which functions make this
work, and how does :checkhealth even find it?
Answer
lua/<name>/health.lua exposing M.check(), which internally calls vim.health.start('myplugin') to
open the report section and vim.health.ok(...)/vim.health.error(...) per individual check -- the
exact same functions core Neovim's own health checks use. :checkhealth <name> discovers and runs the
module purely from its location on the runtimepath, the identical discovery mechanism used for every
bundled and third-party health module.
Code katas
Eight hands-on repetition drills. Each is a before/after init.lua pair (some with a small supporting
tree) colocated under drilling/code/, verified with a real nvim --headless session -- every "before"
config is a real, runnable Neovim config that misapplies the concept being drilled; every "after" config
is the fix. Run each yourself, diagnose the bug from the observed behavior, fix it from memory, then
compare your fix against the "after" listing and each kata's transcript.txt before checking your work
against the actually-executed output shown.
Kata 1 -- mapleader ordering
Task. <leader>w should save the file, with <leader> resolving to <Space>. The version below is
broken: the keymap is created before vim.g.mapleader is set, so it silently binds to the wrong key
instead of erroring.
Before (drilling/code/kata-01-mapleader-ordering/before/init.lua)
vim.keymap.set('n', '<leader>w', ':w<CR>', { desc = 'Save file' })
vim.g.mapleader = ' 'Observed (buggy) result (captured by actually running the config above): <space>w has no mapping
at all; the mapping bound instead to \w (Neovim's default leader, backslash).
After (drilling/code/kata-01-mapleader-ordering/after/init.lua)
vim.g.mapleader = ' '
vim.keymap.set('n', '<leader>w', ':w<CR>', { desc = 'Save file' })Model solution
vim.g.mapleader = ' ' -- => THE FIX: must run BEFORE any <leader>-prefixed
-- => keymap (co-03) -- mapleader is resolved into the
-- => mapping's lhs at the exact moment vim.keymap.set runs
vim.keymap.set('n', '<leader>w', ':w<CR>', { desc = 'Save file' })
-- => now <leader> resolves to ' ' (co-04) -- the mapping
-- => is stored as a literal Space-prefixed lhs, <Space>wRoot cause: <leader> is substituted with whatever vim.g.mapleader currently holds at mapping
time, not at keypress time. The buggy version sets the keymap first, while mapleader still has its
default value (empty, which Neovim treats as backslash) -- reordering the two lines is the entire fix,
with no other change needed.
Run: nvim --headless -u init.lua -c 'verbose map <space>w' -c 'verbose map \w' -c 'qa!'
Output (before / after respectively -- see transcript.txt for the full captured run):
No mapping found
n \w * :w<CR>n <Space>w * :w<CR>
No mapping foundKata 2 -- augroup idempotence
Task. Re-sourcing a config three times should still leave exactly one BufWritePre autocommand, not
three. The version below registers its autocommand with no augroup at all, so every re-source adds
another copy instead of replacing the prior one.
Before (drilling/code/kata-02-augroup-idempotence/before/init.lua)
local function setup()
vim.api.nvim_create_autocmd('BufWritePre', { pattern = '*', command = 'echo "saving"' })
end
setup()Observed (buggy) result (captured by actually running the config above, then re-sourcing it twice
more): three total loads leaves three separate BufWritePre autocommands, each of which would
independently echo "saving" on every write.
After (drilling/code/kata-02-augroup-idempotence/after/init.lua)
local function setup()
local group = vim.api.nvim_create_augroup('MyConfig', { clear = true })
vim.api.nvim_create_autocmd('BufWritePre', { group = group, pattern = '*', command = 'echo "saving"' })
end
setup()Model solution
local function setup()
local group = vim.api.nvim_create_augroup('MyConfig', { clear = true })
-- => THE FIX: { clear = true } (co-05) wipes MyConfig's
-- => prior entries at the TOP of every load, before the
-- => next line re-adds exactly one
vim.api.nvim_create_autocmd('BufWritePre', { group = group, pattern = '*', command = 'echo "saving"' })
end
setup()Root cause: an autocommand created with no group at all has nothing to clear it -- every re-source
of the config runs nvim_create_autocmd again, adding one more entry with no way to remove the old ones.
Wrapping the same call in an augroup created with { clear = true } fixes this without touching the
autocommand itself.
Run: nvim --headless -u init.lua -c 'source init.lua' -c 'source init.lua' -c "lua print(#vim.api.nvim_get_autocmds({event='BufWritePre'}))" -c 'qa!'
Output: 3 (before) / 1 (after) -- three total loads either way (the initial load plus two
explicit re-sources).
Kata 3 -- module reload cache
Task. After editing a required module's file on disk, require()-ing it again should pick up the
edit without restarting Neovim. The version below never clears the module's cache entry first, so the
second require() silently returns the stale, already-loaded value.
Before (drilling/code/kata-03-module-reload-cache/before/init.lua)
local mod1 = require('config.scratch')
print('first require:', mod1.value)
local target = vim.fn.stdpath('config') .. '/lua/config/scratch.lua'
vim.fn.writefile({ 'return { value = 2 }' }, target)
local mod2 = require('config.scratch')
print('second require (no cache clear):', mod2.value)Observed (buggy) output (captured by actually running the config above, starting from
lua/config/scratch.lua holding return { value = 1 }):
first require: 1
second require (no cache clear): 1After (drilling/code/kata-03-module-reload-cache/after/init.lua)
local mod1 = require('config.scratch')
print('first require:', mod1.value)
local target = vim.fn.stdpath('config') .. '/lua/config/scratch.lua'
vim.fn.writefile({ 'return { value = 2 }' }, target)
package.loaded['config.scratch'] = nil
local mod2 = require('config.scratch')
print('second require (cache cleared first):', mod2.value)Model solution
local mod1 = require('config.scratch') -- => caches config.scratch in package.loaded (co-07)
print('first require:', mod1.value) -- => Output: first require: 1
local target = vim.fn.stdpath('config') .. '/lua/config/scratch.lua'
vim.fn.writefile({ 'return { value = 2 }' }, target)
-- => simulates a live on-disk edit while Neovim runs
package.loaded['config.scratch'] = nil -- => THE FIX: clears the cache entry FIRST
local mod2 = require('config.scratch') -- => re-reads and re-runs the file from disk (co-07)
print('second require (cache cleared first):', mod2.value)
-- => Output: second require (cache cleared first): 2Root cause: require() alone never re-reads an already-loaded module -- package.loaded still holds
the table from the first call, so a second require() with no cache clear returns that identical stale
object even though the on-disk file has changed. Clearing package.loaded['config.scratch'] = nil before
the second call is the entire fix.
Run: verified against an isolated XDG_CONFIG_HOME whose nvim/ is this kata's before/ or
after/ tree (nvim -u before/init.lua alone does not add a sibling lua/ to the runtimepath -- see
transcript.txt for why and the exact commands used).
Output: 1 then 1 (before) / 1 then 2 (after).
Kata 4 -- vim.pack version pin
Task. A plugin install should resolve to one specific, reproducible commit, not "whatever the default branch currently points to." The version below installs with a bare URL string, which has no version field to inspect or reproduce later.
Before (drilling/code/kata-04-pack-version-pin/before/init.lua)
vim.pack.add({ 'https://github.com/sainnhe/gruvbox-material' })Observed (buggy) result (captured by a genuine network git clone against the live repository):
vim.pack.get({'gruvbox-material'})[1].spec.version is nil -- there is no pinned version to point to,
only whatever commit the default branch's HEAD happened to be at install time.
After (drilling/code/kata-04-pack-version-pin/after/init.lua)
vim.pack.add({ { src = 'https://github.com/sainnhe/gruvbox-material', version = 'v1.0.0' } })Model solution
vim.pack.add({ -- => THE FIX: a TABLE spec, not a bare URL string
{ src = 'https://github.com/sainnhe/gruvbox-material', version = 'v1.0.0' },
-- => version = 'v1.0.0' pins an exact tag (co-08) --
-- => vim.pack.get() reports it back as spec.version,
-- => plus the resolved commit as rev
})Root cause: a bare URL string ({'https://github.com/...'}) has no version field at all -- there
is nothing to pin, so vim.pack.add tracks whatever the remote's default branch currently points to,
which can differ between machines or between runs. Switching to the table-spec form with an explicit
version field is the entire fix.
Run: vim.pack.get({'gruvbox-material'})[1] after each install -- see transcript.txt for the full
commands (both runs used isolated, throwaway XDG_CONFIG_HOME/XDG_DATA_HOME directories) and an
independent git ls-remote cross-check confirming the pinned tag's resolved commit matches exactly.
Output: version field: nil / resolved rev: 11d779b2... (before) vs. version field: v1.0.0 /
resolved rev: 1671e0ec... (after).
Kata 5 -- LspAttach buffer scope
Task. An LSP hover keymap (K) should exist only in buffers where a language server actually
attached. The version below binds it unconditionally at the top level, so it silently shadows Neovim's
built-in K (keyword-lookup) in every buffer, including plain text files with no server anywhere near
them.
Before (drilling/code/kata-05-lspattach-buffer-scope/before/init.lua)
vim.lsp.config('lua_ls', { cmd = { 'lua-language-server' }, filetypes = { 'lua' }, root_markers = { '.git' } })
vim.lsp.enable('lua_ls')
vim.keymap.set('n', 'K', vim.lsp.buf.hover, { desc = 'LSP hover' })Observed (buggy) result (captured with a genuinely attached lua-language-server): K exists in
scratch.lua (where lua_ls attaches) AND, wrongly, in scratch.txt too -- a plain text buffer with no
server involved at all.
After (drilling/code/kata-05-lspattach-buffer-scope/after/init.lua)
vim.lsp.config('lua_ls', { cmd = { 'lua-language-server' }, filetypes = { 'lua' }, root_markers = { '.git' } })
vim.lsp.enable('lua_ls')
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
vim.keymap.set('n', 'K', vim.lsp.buf.hover, { buffer = args.buf, desc = 'LSP hover' })
end,
})Model solution
vim.api.nvim_create_autocmd('LspAttach', { -- => THE FIX: bind INSIDE LspAttach (co-12), not at
callback = function(args) -- => the top level
vim.keymap.set('n', 'K', vim.lsp.buf.hover, { buffer = args.buf, desc = 'LSP hover' })
-- => buffer = args.buf (co-04, co-05) scopes K to ONLY
-- => the buffer that just attached
end,
})Root cause: vim.keymap.set('n', 'K', ...) with no buffer option is a global keymap, active in
every buffer the moment it's created -- regardless of whether any LSP client ever attaches there.
Wrapping the exact same call inside LspAttach's callback, scoped with buffer = args.buf, is what
confines it to buffers a server genuinely attached to, exactly Example 39's own pattern applied to a
fresh bug.
Run: see transcript.txt for the full nvim --headless commands against both scratch.lua and
scratch.txt.
Output: K in scratch.lua: true / K in scratch.txt: true (before, bug) vs. K in scratch.lua: true / K in scratch.txt: false (after, fixed).
Kata 6 -- diagnostics list target
Task. A <leader>xl keymap described as "Diagnostics to loclist" should populate the current
window's location list. The version below calls vim.fn.setqflist() instead, which always targets the
single global quickfix list -- the opposite of what the keymap's own description promises.
Before (drilling/code/kata-06-diagnostics-list-target/before/init.lua)
vim.keymap.set('n', '<leader>xl', function()
local diags = vim.diagnostic.get(0)
local items = vim.diagnostic.toqflist(diags)
vim.fn.setqflist({}, ' ', { title = 'Diagnostics', items = items })
vim.cmd('copen')
end, { desc = 'Diagnostics to loclist' })Observed (buggy) result (captured with a synthetic diagnostic set directly on the buffer, same technique as Example 79): the entry lands in the global quickfix list, while the location list stays empty.
After (drilling/code/kata-06-diagnostics-list-target/after/init.lua)
vim.keymap.set('n', '<leader>xl', vim.diagnostic.setloclist, { desc = 'Diagnostics to loclist' })Model solution
vim.keymap.set('n', '<leader>xl', vim.diagnostic.setloclist, { desc = 'Diagnostics to loclist' })
-- => THE FIX: vim.diagnostic.setloclist (co-14) targets
-- => the CURRENT WINDOW's location list directly -- no
-- => manual toqflist()/setqflist() plumbing needed at allRoot cause: vim.fn.setqflist() always writes to the single, global quickfix list, no matter how the
items were gathered -- calling it from a function whose own desc promises "loclist" is a real, easy
mix-up between the two genuinely different list mechanisms this topic covers (Examples 78 and 79 sit
side by side specifically to make the contrast explicit). vim.diagnostic.setloclist() is the dedicated
function for the location list; reaching for it directly avoids the mix-up entirely.
Run: see transcript.txt for the full commands, including how the keymap's callback is invoked
directly via maparg(...).callback().
Output: loclist count: 0 qflist count: 1 (before, bug) vs. loclist count: 1 qflist count: 0
(after, fixed).
Kata 7 -- Treesitter query merge
Task. A config author wants to ADD one custom highlight capture on top of Lua's bundled highlights
query, keeping every original capture. The version below calls vim.treesitter.query.set() directly
with only the new pattern, which REPLACES the entire compiled query outright instead of merging into it.
Before (drilling/code/kata-07-treesitter-query-merge/before/init.lua)
vim.treesitter.query.set('lua', 'highlights', '(identifier) @custom_ident')Observed (buggy) result (captured by actually running the config above): the bundled Lua highlights query drops from 37 distinct captures (this Neovim install's genuine baseline) down to exactly 1 -- keyword, string, comment, and number highlighting all silently disappear.
After (drilling/code/kata-07-treesitter-query-merge/after/init.lua)
local files = vim.treesitter.query.get_files('lua', 'highlights')
local original = table.concat(vim.fn.readfile(files[1]), '\n')
vim.treesitter.query.set('lua', 'highlights', original .. '\n(identifier) @custom_ident')Model solution
local files = vim.treesitter.query.get_files('lua', 'highlights')
-- => THE FIX: locate the BUNDLED query's own source
-- => file first (co-17), instead of writing only the
-- => new pattern
local original = table.concat(vim.fn.readfile(files[1]), '\n')
-- => read the original query TEXT back into a string
vim.treesitter.query.set('lua', 'highlights', original .. '\n(identifier) @custom_ident')
-- => concatenate the custom pattern onto the END of the
-- => original -- nothing from the bundled query is lostRoot cause: vim.treesitter.query.set(lang, name, query) fully replaces a language's compiled query
-- it has no "merge" mode. Passing only the one new pattern discards everything the bundled query already
provided. Reading the bundled query's own source text first (vim.treesitter.query.get_files), then
appending the new pattern to it before calling query.set(), keeps every original capture and adds one
more.
Run: see transcript.txt for the full commands and a baseline (untouched) capture count for
cross-reference.
Output: captures: 1 (before, bug) vs. captures: 38 (after: the original 37 plus the new one) --
baseline, untouched: captures: 37.
Kata 8 -- plugin setup opt-in
Task. A plugin's MyPluginCmd user command should only exist once a caller explicitly calls
require('myplugin').setup() -- never merely from require()-ing the module. The version below
registers the command at file (module) scope, so it leaks into existence the instant the module loads,
with no opt-in at all.
Before (drilling/code/kata-08-plugin-setup-opt-in/before/lua/myplugin/init.lua)
local M = {}
vim.api.nvim_create_user_command('MyPluginCmd', function() print('ran') end, {})
function M.setup(opts) end
return MObserved (buggy) result (captured with an init.lua that calls require('myplugin') and
deliberately never calls .setup()): MyPluginCmd exists anyway.
After (drilling/code/kata-08-plugin-setup-opt-in/after/lua/myplugin/init.lua)
local M = {}
function M.setup(opts)
vim.api.nvim_create_user_command('MyPluginCmd', function() print('ran') end, {})
end
return MModel solution
local M = {}
function M.setup(opts) -- => THE FIX: registration moves INSIDE M.setup() (co-18)
vim.api.nvim_create_user_command('MyPluginCmd', function() print('ran') end, {})
-- => now only runs once a caller explicitly opts in
end
return MRoot cause: any code written at a Lua module's file scope (outside every function) runs the instant
that module is require()-d -- module loading and setup() running are two genuinely separate steps
(co-18), and a plugin author who registers a command outside M.setup() accidentally couples the two,
handing every caller a side effect they never asked for. Moving the exact same
nvim_create_user_command call inside M.setup() is the entire fix.
Run: init.lua is identical in both before/ and after/ (require('myplugin'), .setup()
deliberately never called) -- see transcript.txt for the full commands.
Output: true (before, bug: leaked) vs. false (after, fixed: correctly absent).
Self-check checklist
Confirm each item without checking :help first. If you hesitate, that concept needs another pass.
- I can locate or create
init.lua, and explain what putting alua/directory beside it does forrequire(). (co-01) - I can choose correctly between
vim.oandvim.optfor a given option, and predict when a scalar assignment silently overwrites a list. (co-02) - I can pick the right scoped-variable wrapper (
vim.g/vim.b/vim.w/vim.t) for a described need without hesitation. (co-03) - I can write
vim.keymap.setwith a stringrhsand a functionrhs, and explain the difference in:verbose mapoutput. (co-04) - I can create an augroup-scoped autocommand and explain why
{ clear = true }keeps re-sourcing idempotent. (co-05) - I can define a user command with
nargs,range, andcomplete, and read backargs/fargs/line1/line2in its callback. (co-06) - I can predict which file a dotted
require()path resolves to, and explain whypackage.loadedcaching means a live-edited file needs a cache clear before the nextrequire()picks it up. (co-07) - I can install, pin, update, and remove a plugin with
vim.pack.add/get/update/delfrom memory. (co-08) - I can write a
lazy.nvimplugin spec with the right lazy-loading trigger (event/cmd/ft/keys) for a described need. (co-09) - I can enable a language server with
vim.lsp.config+vim.lsp.enable, and use the'*'wildcard for shared defaults. (co-10) - I can explain
nvim-lspconfig's current config-only role, and author my ownlsp/<name>.luaoverride. (co-11) - I can bind a buffer-local LSP keymap inside
LspAttach, scoped correctly to only attached buffers. (co-12) - I can name at least five of Neovim's eight default global LSP keymaps without checking
:help. (co-13) - I can configure
vim.diagnostic.config's rendering knobs (virtual_text/signs/underline/severity_sort) for a described need. (co-14) - I can wire native LSP completion with
autotrigger, and explain why it needs no third-party completion plugin. (co-15) - I can name the five bundled Treesitter parsers, and identify the correct installer for anything beyond them. (co-16)
- I can use the
vim.treesitter.queryAPI and thean/in,]n/[nbuilt-ins to inspect or select syntax-aware regions. (co-17) - I can package a self-authored Lua module with
setup(opts), an autoloadingplugin/file, and ahealth.luacheck. (co-18) - I can explain, in one sentence, why vanilla Neovim is mechanism and my own plugins/LSP/keymaps are
policy layered on top. (
mechanism-vs-policy) - I can explain, in one sentence, why LSP and Treesitter's language-agnostic uniformity is a
deliberate trade, not a free lunch. (
abstraction-and-its-cost)
Elaborative interrogation & self-explanation
Six why/why-not prompts, each tied to one of this topic's two Cross-Cutting Big-Idea tags. Answer each in your own words before opening the model explanation.
E1 (mechanism-vs-policy). Why does this topic teach vim.pack and lazy.nvim (plugin choice), LSP
server selection, and keymaps as separate, swappable layers on top of vanilla Neovim, instead of shipping
one "correct" opinionated config the way some editors do out of the box?
Model explanation
Vanilla Neovim (topic 1) is pure mechanism -- the modal grammar, motions, and text objects every config
sits on top of, identical no matter what's layered above it. Which plugins to install, which servers to
enable, which keys to bind is exactly the part that legitimately differs from developer to developer --
that's policy. Shipping one fixed opinionated config would collapse that distinction, forcing every
reader into someone else's policy choices; teaching the mechanism/policy boundary explicitly means a
reader can swap vim.pack for lazy.nvim, or lua_ls for a different server, without relearning
anything about how Neovim itself works underneath.
E2 (abstraction-and-its-cost). The LSP client speaks one protocol to every language server, and the
Treesitter query API speaks one capture interface to every bundled or installed grammar. What's the
concrete cost this topic pays for that uniformity, and where does it show up?
Model explanation
The cost is a layer of indirection between "I want X" and the server or parser actually doing the work
underneath. Concretely: Example 37's diagnostics did not disappear as expected in this exact
sandbox/version combination, even though the settings-merge itself genuinely reached the attached client
(server_capabilities.diagnosticProvider was nil) -- a language-agnostic abstraction still depends on
what the specific server underneath actually implements, and that gap is invisible until you check
server_capabilities directly, exactly what Examples 64 and 65 do for workspace symbols and inlay hints
rather than assuming a keymap "should" work.
E3 (mechanism-vs-policy). Why does Neovim 0.11+ ship default global LSP keymaps (grn, gra,
grr, ...) out of the box, instead of leaving every LSP keybinding entirely up to each config author's
own policy, the way Example 39's LspAttach pattern might suggest is the "correct" approach?
Model explanation
co-13's baseline keymaps are mechanism, not policy -- they guarantee any server that attaches gives a
reader a working baseline (rename, references, hover) with zero configuration, the same way modal
editing's motions work identically regardless of which plugins happen to be installed. LspAttach-scoped
custom keymaps remain the policy layer on top, for a reader who wants a DIFFERENT key than grn, or
additional behavior beyond the default set. The defaults don't remove that flexibility -- they just make
"did I forget to bind grn" no longer block basic productivity while a reader is still building their
own config's policy layer.
E4 (abstraction-and-its-cost). vim.pack and the active Treesitter fork both install code from
arbitrary git repositories with no vetting of what's inside. Why does this topic treat "plugin
management" as covered once install/pin/update/remove works, rather than also covering what the
installed code is doing?
Model explanation
This mirrors the LSP/Treesitter abstraction-and-its-cost trade at the tooling layer instead of the
protocol layer: vim.pack.add({...}) or lazy.nvim's spec table give a uniform installation mechanism
regardless of what a given repository's plugin actually contains -- exactly the uniformity that makes
plugin management learnable once and reusable for any plugin. The cost is that "installed and pinned"
says nothing about what a plugin's Lua code actually does at runtime; co-08's own verification method
(checking vim.pack.get()'s resolved rev, cross-checked against a real git ls-remote) confirms WHICH
commit installed, not WHAT that commit's code does -- a genuinely separate concern this topic's mechanism
does not cover.
E5 (mechanism-vs-policy). Example 47 shows Lua highlighting turned on with zero plugins and zero
config, purely from Neovim's own bundled ftplugin/lua.lua. Is that automatic behavior mechanism or
policy, and why does the answer matter for how a config author treats the other four bundled parsers?
Model explanation
It's mechanism -- Neovim's own runtime ships the decision to call vim.treesitter.start() for its five
bundled parsers (C, Lua, Markdown, Vimscript, Vimdoc), identically on every install, with no config
author choice involved at all. Recognizing this as mechanism (not something a reader's own config "did")
matters because it means a reader can trust these five languages will highlight correctly on any fresh
Neovim 0.12+ install with zero setup, while every other language remains a genuine POLICY decision --
which parser to install, and whether to write the FileType autocommand that starts it, exactly as
Example 49 does manually for Python.
E6 (abstraction-and-its-cost). co-18's whole promise is that a self-authored plugin module is
"indistinguishable in shape" from anything installed with vim.pack or lazy.nvim. What abstraction
makes that possible, and what's the cost of relying on it?
Model explanation
The Lua module system (co-07) -- require()-able files under lua/, an optional plugin/ autoload
file, an M.setup(opts) convention -- is the uniform shape both third-party plugins and self-authored
code share; learning it once (Example 20's config split) pays off again for authoring (Examples 71-77).
The cost: that uniformity is purely structural, not behavioral -- Example 33 shows a real, reproducible
error when opts = {} assumes a plugin follows the M.setup(opts) convention and it does not (plenary's
own __index-based lazy-loading metatable resolves .setup to an unrelated submodule instead of a
callable function). Looking the same on disk never guarantees a plugin actually honors every convention
its shape implies -- Kata 8's own bug (a command leaking outside setup()) is the same lesson from the
opposite direction: the shape is only a promise a plugin author has to keep on purpose.
← Previous: Capstone
Last updated July 13, 2026