lsp.nvim · Debug & inspect · vimdoc
:help lsp.nvim
One roof for the whole LSP setup
doc/lsp.nvim.txt — rendered from the plugin's own vimdoc
*lsp.nvim.txt* One roof for the whole LSP setup *lsp.nvim*
CONTENTS
1. Introduction ............................. |lsp.nvim-introduction| 2. Requirements ............................. |lsp.nvim-requirements| 3. Setup .................................... |lsp.nvim-setup| 4. Configuration ............................ |lsp.nvim-config| 5. Keymaps .................................. |lsp.nvim-keymaps| 6. Commands ................................. |lsp.nvim-commands| 7. Health ................................... |lsp.nvim-health| 8. Architecture ............................. |lsp.nvim-architecture| 9. Status and roadmap ....................... |lsp.nvim-status| Note on the file name: this isdoc/lsp.nvim.txt, notdoc/lsp.txt. Neovim's own runtime ships adoc/lsp.txt(|lsp|), and a second file of that name makes:help lsp.txtambiguous. Every tag here is prefixedlsp.nvim-for the same reason.
1. INTRODUCTION
lsp.nvim is the umbrella for everything LSP-related in a Neovim config:
- the own subsystem -- server registry, attach handling, capabilities,
formatter and workspace-diagnostics toggles, the doctor;
- the LSP-adjacent third-party plugins -- trouble.nvim, conform.nvim,
lazydev.nvim, mason.nvim, the completion engine;
- every LSP and diagnostics keymap, in one catalogue instead of five files.
A config's lua/lsp/** is a stateful subsystem, not a set of declarative
options, which is why it belongs in its own plugin -- the same reasoning that
produced dap.nvim for the debug protocol.
The module root is lsp on purpose: Neovim occupies only vim.lsp and
nvim-lspconfig only lspconfig, so an existing require("lsp.…") path in a
config keeps resolving once the code moves here. The flip side is that a config
which still carries its own lua/lsp/** shadows this plugin on the
'runtimepath' -- the two are meant to swap, not to coexist.
2. REQUIREMENTS
- Neovim 0.11 or newer. - lib.nvim (https://github.com/StefanBartl/lib.nvim), a HARD dependency: the |:Lsp| command is built onlib.nvim.bindings.usercmd.composer, andlsp/init.luarequireslib.nvim.notifyat its top. Without lib.nvimrequire("lsp")itself raises -- there is no degraded mode. It is never optional, in the code or in the docs. - ui.nvim (https://github.com/StefanBartl/ui.nvim), a SOFT dependency with teeth: setup() survives without it, butlsp/lspdoctor/init.luarequiresui.kitat its top, so |:LspDoctor| is not registered at all andtools.ts_type_lookupfails -- both recorded as setup warnings. The pickers behind |:Lsp-root|pick/addand |:Lsp-info|'s viewer go the same way. Everything else runs.
3. SETUP
*lsp.nvim.setup()*
require("lsp").setup()
Calling setup() merges your options over the defaults, binds the configured keymap preset and registers |:Lsp|, then bootstraps the LSP core: handlers, diagnostics, capabilities, attach, formatter, the command family, the language modules, the configured servers, and the extra tools. It is safe to call once; a second call is refused with a warning rather than doing all of that twice. Every bootstrap step is wrapped. One broken server module or tool does not take the rest of the setup with it -- it is recorded and shown by |:Lsp-status| and |:checkhealth-lsp|. setup() returns true when at least one server was set up. With lazy.nvim:
{
"StefanBartl/lsp.nvim",
import = "lsp.pack",
dependencies = { "StefanBartl/lib.nvim" },
event = { "BufReadPre", "BufNewFile" },
opts = {},
}
*lsp.nvim-pack*import = "lsp.pack"additionally installs and configures the ecosystem, in four spec modules:core(conform, lazydev, workspace-diagnostics),ui(trouble, lensline, inc-rename) and a completion engine -- blink.cmp by default, nvim-cmp whenpack.completion = "cmp", neither onfalse. Drop theimportto bring your own: the plugin then wires up whatever of those is installed and reports the rest in |:checkhealth-lsp|. Which of them gets installed is a SEPARATE channel fromopts, and has to be: lazy evaluatesimportwhile it is still collecting specs, long beforesetup(opts)exists to be read. Set it beforerequire("lazy").setup():
vim.g.lsp_nvim = {
pack = {
core = true, -- conform, lazydev, workspace-diagnostics
ui = true, -- trouble, lensline, inc-rename
completion = "blink", -- "cmp" | "blink" | false (default: blink)
disable = { "lensline.nvim" },
},
}
vim.gdecides WHETHER a plugin is installed,optsdecides HOW everything is configured. Note thatimportnames a directory: lazy requires every module underlua/lsp/pack/, so the selection above is applied per spec throughenabled, not by importing conditionally. *lsp.nvim.status()*
require("lsp").status()
Returns a snapshot of what the plugin currently is: whether setup() has run, the resolved config, the keymaps actually bound, whether |:Lsp| registered, the servers that were set up, the attached LSP clients, and every warning collected during setup. |:Lsp-status| and |:checkhealth-lsp| both read this, so the two cannot disagree.
4. CONFIGURATION
The options are resolved from four layers, lowest to highest:
1. lua/lsp/config/DEFAULTS.lua the documented values
2. lua/lsp/config/PRESETS.lua selected by preset -- how much of
this should run on this machine
3. the |lsp.nvim.setup()| options what you wrote
4. .nvim-lsp.json what this checkout needs
A preset sits below your options because it moves the floor rather than
overruling you; the project file sits above them because "here, not globally"
is the one thing it is for. Layers 1-3 are resolved first, since they are where
project.enable and project.file come from -- a project file cannot decide
whether project files are read.
Defaults (excerpt -- the full table is lua/lsp/config/DEFAULTS.lua):
{
preset = "default", -- "default" | "lean" | "full"
project = { enable = true, file = ".nvim-lsp.json" },
servers = { "bashls", "lua_ls", "gopls", "marksman", "html",
"ts_ls", "tailwindcss", "csharp" },
diagnostics = { -- these two keys only; see the note below
ui = "auto", -- "auto" | "native" | "trouble" -- ]d/[d's sink
debounce_ms = 150, -- publishDiagnostics throttle; 0 = off
},
formatter = { on_save = false, timeout_ms = 1500 },
workspace = {
markers = { ".git", "go.work", "go.mod", "package.json", ... },
containers = { "packages", "apps", "services", ... },
},
inlay_hints = { enable = false, filetypes = {} },
lightbulb = { -- the code-action indicator
enable = true, filetypes = {},
kinds = { "quickfix", "source" }, -- {} = unfiltered
render = "sign", -- or "virtual_text"
text = "",
debounce_ms = 150, priority = 20,
},
winbar = { -- the LSP breadcrumb: folder > file > Class > method
enable = true, filetypes = {},
show_file = true, folder_level = 1, separator = " › ",
chips = true, align = "left", -- "right"/"center" = right edge/centred
max_symbols = { markdown = 1 }, -- symbols allowed after the file
debounce_ms = 60, refresh_ms = 300,
},
peek = { -- floating, editable definition (lsp, lsT, :Lsp peek)
width = 0.7, height = 0.5, border = "rounded", beacon = true,
keys = { close = "q", edit = "<C-o>", vsplit = "<C-v>",
split = "<C-x>", tabedit = "<C-t>" },
},
implement = { -- implementation markers; off unless you turn it on
enable = false, filetypes = {},
kinds = { Interface = true }, text = " %d impl",
debounce_ms = 600, max_requests = 20,
},
code_actions = { picker = "auto", gitsigns = false },
finder = { references = true, implementations = true,
definitions = true, declarations = false, typedefs = false },
auto_restart = { -- bring a crashed server back
enable = true, max_attempts = 4,
initial_delay_ms = 1000, max_delay_ms = 30000,
reset_after_ms = 60000,
},
attach = {
use_workspace_diagnostics = true,
workspace_diagnostics_projects = {},
use_lazydev = true,
},
mason = { ensure_install = false, overrides = { ... } },
lspdoctor = { use_notify = false, list_limit = 8, ... },
tools = {
eslint_prettier = { enable = true, filetypes = { ... } },
lsp_signature = { enable = true },
ts_type_lookup = { enable = true },
deprecated_help = { enable = true },
},
languages = { enable = true, env_links = true },
completion = {
personal_names = { enable = true, labels = nil },
},
rename = { provider = "auto" }, -- "auto"|"inc_rename"|"native"
keymaps = { enable = true, preset = "default", map = {} },
usrcmds = { enable = true, legacy_aliases = true },
which_key = { enable = true },
menu = { enable = true },
integrations = { ui_menu = true },
}
diagnosticsreally is those two keys and nothing else. The look --update_in_insert,severity_sort,virtual_text,float,signs,underline-- lives inlsp.core.diagnostics.baseline(), not here, and that is a correctness point rather than tidiness: this table is merged LAST into the single|vim.diagnostic.config()|call, after anything another plugin contributed, so that what you write always wins. Leaving lsp.nvim's own defaults in it would have meant a plugin's contribution being overruled by lsp.nvim's default rather than by anything you asked for. Anything you add here still merges last and still wins.
Field reference
preset ("default"|"lean"|"full")
Option profile the rest of the table starts
from -- one word instead of roughly twenty
fields. lean turns down the work paid per
keystroke, per attach and per redraw
(virtual text, the signatureHelp round
trip, the workspace scan on attach, the ~25
legacy command registrations); on-demand
actions such as gd, hover and rename are
untouched. full is the inverse trade.
Anything you name explicitly still wins over
the preset. No preset ever sets
mason.ensure_install or
formatter.on_save -- a profile is a
performance dial, not consent to install
software or rewrite your files. Not
keymaps.preset, which picks a set of keys;
this picks a set of options, one of which is
keymaps.preset.
project.enable (boolean) Look for a per-project override file at all.
project.file (string) Its name. Found once, at setup() time, by
walking upward from the working directory;
the first hit wins. Read once because that
is when servers are enabled and tools are
set up -- re-reading after a :cd would
report a config that is not the one running.
The file is JSON, not Lua: cloning a
repository must not be enough to run its
code, and JSON cannot express a function.
Only servers, diagnostics, formatter,
inlay_hints, lightbulb, attach,
workspace, tools and languages are
accepted -- the keys the repository knows the
answer to.
Keymaps, :Lsp registration and mason are
yours; anything else in the file is dropped
with a warning. |:Lsp| status and
|:checkhealth-lsp| name the file that was
used.
servers (string[]) Server names to set up and enable. Each
resolves to lsp.servers.<name>, with
lsp.servers.webdev.<name> tried as a
fallback for names without a dot. This used
to be a hardcoded ACTIVE list inside
core/registry.lua, so turning a server on
or off meant editing the plugin. An empty or
malformed list falls back to the defaults --
"no language server at all" looks exactly
like a broken install and is never what a
typo should produce.
diagnostics (table) Merged last into the one
|vim.diagnostic.config()| call, made after
the servers are enabled, so neither a server
config nor another plugin's contribution can
overwrite it -- except ui and
debounce_ms, which are this plugin's own
and are stripped before that call. Only
those two have defaults here; the rest of
the table is whatever you add.
diagnostics.debounce_ms (integer) Throttle window for
textDocument/publishDiagnostics, in
milliseconds. A chatty server (ts_ls is
the reference case) publishes several times
per keystroke pause and every push
re-renders. The window is leading-edge: the
first push of a burst goes through
immediately and only the ones inside the
window are collapsed to the newest, so the
push a user waits for is never the one
delayed. Coalescing keeps the newest payload
and never merges -- a diagnostics list
replaces a file's diagnostics wholesale, so
a merge would resurrect entries the server
had just cleared. 0 turns the throttle off.
formatter.on_save (boolean) Format on write at startup. The runtime
toggle (|:Lsp-format| toggle, or the
:LspFormatToggle alias) owns it from there;
this is only the starting position. The hook
is a BufWritePre autocommand in the
LspFormatOnSave group -- an in-editor
format, so it belongs before the write. The
shell-based tools.eslint_prettier runs on
BufWritePost instead, for the opposite
reason: a child process needs the file on
disk to already be the buffer.
formatter.timeout_ms (integer) Upper bound for one format request.
workspace.markers (string[]) A directory holding one of these is offered
as a workspace folder by |:Lsp| root add.
Broader than any one server's
root_markers on purpose: the question is
"could a language server sensibly be
pointed here", not "is this that server's
root". Replaces the default list rather
than merging into it, and an explicitly
empty list is honoured -- offering nothing
but the client roots and the cwd is a
coherent wish.
workspace.containers (string[]) Directory names that hold projects rather
than being one. After walking upward, the
candidate search reads the outermost
project's children and descends exactly one
level through these names -- that is where
a monorepo's sibling package lives, and an
upward walk never looks sideways. Bounded
at one readdir per name; an unbounded
descent would stat a whole repository to
fill a picker.
inlay_hints.enable (boolean) Global startup default for Neovim's native
inlay hints (|vim.lsp.inlay_hint|). The
runtime toggle (|:Lsp| hints, <leader>th)
owns it from there.
inlay_hints.filetypes (table<string, boolean>)
Per-filetype override of that default. An
absent key inherits enable; false is an
explicit "off here". Absent and false are
deliberately different -- that is why this is
a map and not a list, and a list is rejected
with a warning rather than silently
overriding nothing.
lightbulb.enable (boolean) Global startup default for the code-action
indicator: a mark in the line when
textDocument/codeAction has something to
offer there. The runtime toggle (|:Lsp|
lightbulb, <leader>tb) owns it from there.
lightbulb.filetypes (table<string, boolean>)
Per-filetype override, resolved exactly as
inlay_hints.filetypes is.
lightbulb.kinds (string[]) CodeActionKind prefixes that light the
indicator; a kind matches exactly or as a
dotted child. This is why enable can
default to on: unfiltered, the indicator is
lit permanently under servers that offer
refactors everywhere, and one that is always
on carries no information. An action with no
kind always counts -- kind is optional in
the protocol. {} turns the filter off.
lightbulb.render ("sign"|"virtual_text")
Where it draws. Both obvious places are
taken -- the sign column carries diagnostic
signs, virtual_text sits at end of line --
so sign borrows the sign column on the
cursor line only, above the diagnostic signs,
and virtual_text draws at the window edge.
lightbulb.text (string) The indicator itself. Truncated to two
display cells when rendered as a sign.
lightbulb.debounce_ms (integer) Window between the last cursor movement and
the request -- what the feature costs. One
request per cursor position, sent only to
clients advertising codeActionProvider,
and never in insert mode.
lightbulb.priority (integer) Extmark priority. Above |vim.diagnostic|'s
sign priority (10) by default, which is the
point: on a line that has both, the
actionable mark is the one worth seeing.
winbar.enable (boolean) Global startup default for the LSP breadcrumb
in the window bar: the file's path, then
every symbol containing the cursor. Replaces
lspsaga's symbol_in_winbar. The runtime
toggle (|:Lsp-winbar|, <leader>tW) owns it
from there. Off under preset = "lean".
winbar.filetypes (table<string, boolean>)
Per-filetype override, resolved exactly as
inlay_hints.filetypes is.
winbar.show_file (boolean) Draw the path in front of the symbols.
winbar.folder_level (integer) Directories shown before the file name.
winbar.separator (string) Between parts.
winbar.chips (boolean) Rounded, coloured chips; false is one flat
string coloured by linked highlight groups.
winbar.align ("left"|"right"|"center")
"left" (default), "right" or "center": the
latter two wrap the breadcrumb in the
'statusline'/'winbar' built-in %= item,
pushing it to the window's right edge or
splitting it evenly between both. No
padding is computed -- %= does that.
A colorscheme's own WinBar underline is
unaffected by this option; `:hi WinBar
gui=NONE` removes it if it bothers you.
winbar.max_symbols (table<string, integer|false>)
How many symbols may follow the file, per
filetype. { markdown = 1 } by default,
because marksman reports headings as a
nested outline and a cursor in an H3 would
otherwise draw file > H1 > H2 > H3. A map,
so your entries merge over the default;
markdown = false lifts the cap.
winbar.debounce_ms (integer) Between the last cursor movement and the
repaint. Reads a cache; sends nothing.
winbar.refresh_ms (integer) Between the last edit and the next
textDocument/documentSymbol request.
peek.width (number) Width of the peek float: a fraction of the
editor up to 1, cells above.
peek.height (number) Height, the same way.
peek.border (string|string[])
nvim_open_win border.
peek.beacon (boolean) Flash the target line when a peek is taken
into a real window.
peek.keys (table<string, string|false>)
Keys inside the peek, by action: close,
edit, vsplit, split, tabedit.
Buffer-local to the peeked buffer and given
back when the last peek over it closes.
false unbinds an action.
implement.enable (boolean) Implementation markers: a count at the end
of the line of an interface that something
implements. Off by default -- one
textDocument/implementation request per
marked symbol per edit pause.
implement.filetypes (table<string, boolean>)
Per-filetype override.
implement.kinds (table<string, boolean>)
SymbolKind names that get a marker.
{ Interface = true } by default. A map,
not a list: lists merge index by index.
implement.text (string) Marker text; %d is the count.
implement.debounce_ms (integer) Between the last edit and the requests.
implement.max_requests (integer) Cap on requests per round.
code_actions.picker ("auto"|"fzf-lua"|"native")
What lsa opens. auto is fzf-lua's
picker, with the edit previewed as a diff,
when fzf-lua is installed, and
vim.lsp.buf.code_action when it is not.
code_actions.gitsigns (boolean) Offer gitsigns' hunk actions (stage, reset,
preview) in the same list. Runs a small
in-process language server, which shows up
in |:Lsp| servers -- hence off by default.
The winbar and |:Lsp| stop / restart look
past it; other plugins that list
vim.lsp.get_clients() will see it.
finder.references (boolean) Sources of lsf. A map of switches rather
finder.implementations (boolean) than a list, because lists merge index by
finder.definitions (boolean) index. References, implementations and
finder.declarations (boolean) definitions are on by default.
finder.typedefs (boolean)
auto_restart.enable (boolean) Bring a language server back when it dies
mid-session. A crashed server is otherwise
invisible -- hover stops answering,
completion goes empty, diagnostics freeze --
and it reads as slowness until someone types
|:Lsp| restart.
Four exits are deliberately not crashes: one
this plugin asked for (a force-stop is a
SIGTERM and would otherwise be
indistinguishable from a kill, so every
deliberate stop declares itself first), a
clean exit nobody asked for, an exit during
|:qa|, and a client that died before it ever
attached -- the last is where a retry loop
would be a hazard, and |:Lsp| recover owns
it.
auto_restart.max_attempts (integer)
Consecutive attempts before it gives up and
says so. The counter survives the giving-up
so |:LspDoctor| startup can report how bad it
got.
auto_restart.initial_delay_ms (integer)
Wait before the first attempt. Doubles from
there: 1s, 2s, 4s, 8s at the defaults.
auto_restart.max_delay_ms (integer)
Cap on that doubling. A value below
initial_delay_ms would make the backoff
shrink instead of grow and is raised to it
with a warning.
auto_restart.reset_after_ms (integer)
How long a relaunched client must stay alive
before the attempt counter clears. Survival
rather than attach: clearing it on attach
would let a server that crashes two seconds
after every attach restart forever, since
each attach would forgive the previous crash.
attach.use_workspace_diagnostics (boolean)
Populate workspace-wide diagnostics on
attach. On by default: the module measures
workspace size itself and refuses above its
own max_files gate.
attach.workspace_diagnostics_projects (table)
project folder -> boolean, a per-project
override of the switch above. ~ and $VAR
expand; the most specific folder wins. false
skips the populate (and its max_files
warning) AND holds back the pushes a server
sends for files you have not opened -- marksman
publishes those on its own. Open files are
unaffected. :Lsp workspace changes it at
runtime. A .nvim-lsp.json may set it for
its own folders only (., a relative path,
or an absolute one inside it); other keys
are dropped with a warning. An entry that is
not absolute once expanded (a $VAR that is
not set here; on Windows also /x) is
ignored with a warning, also listed in
:checkhealth lsp.
attach.use_lazydev (boolean) Wire lazydev into lua_ls attaches.
mason.ensure_install (boolean) Install missing LSP/linter/formatter
packages on setup. Off by default --
installing software is a side effect a
plugin should not perform unasked.
mason.overrides (table) Per-category force-on/off, keyed lsp,
dap, linters, formatters.
lspdoctor (table) Forwarded to lsp.lspdoctor.setup().
lspdoctor.formatter_priority (string[])
Order in which |:LspDoctor|'s report ranks
the LSP clients that could format the
buffer. **Report only** -- it chooses
nothing, which is why it sits under
lspdoctor and not under formatter. What
actually formats is conform's chain for the
filetype, with LSP as the fallback conform
falls back to; on every filetype conform
covers, no LSP client formats at all. The
report names conform's answer first and this
ranking second.
lspdoctor.probe_timeout (integer, ms)
How long :LspDoctor probe waits for
diagnostics to come back from a buffer of
deliberately broken content. Default 5000.
Generous on purpose: the answer that matters
is "nothing arrived", and a timeout too short
for a busy server produces that answer for a
pipeline that works.
tools.<name>.enable (boolean) Master switch per extra tool:
eslint_prettier, lsp_signature,
ts_type_lookup, deprecated_help.
tools.eslint_prettier.filetypes (string[])
Filetypes that tool attaches to.
languages.enable (boolean) Apply the filetype-specific setup under
lsp/languages/** before the servers are
registered.
languages.env_links (boolean) Resolve $VAR/..., ${VAR}/... and ~/...
Markdown link targets, which marksman cannot:
definition and hover through an in-process
lsp.nvim-envlinks client, which also
warns about env links whose file or
#heading is missing (marksman reports no
link that carries a #fragment), and
completes the directories such a target
names (/, $, { trigger it).
Resolved through gopath.nvim's
resolve_text when installed, built-in
otherwise. On by default.
completion.personal_names.enable (boolean)
Register the hand-written personal-names
completion source. Engine-neutral: it is set
up from |lsp.nvim.setup()| rather than from
nvim-cmp's opts, which is what stopped a
switch to blink silently dropping it.
completion.personal_names.labels (fun(): (string|{name: string})[])|nil
A reader, not a list. The names are the host
config's data, so the config hands them over
instead of this plugin reaching into it.
Without a reader the source falls back to
completion/personal_names/extra.lua alone.
rename.provider ("auto"|"inc_rename"|"native")
Backend for the rename action, which both
bound rename keys go through. "auto" prefers
inc-rename when it is installed and falls
back to |vim.lsp.buf.rename()|. The two keys
used to run different renames; routing both
through one option is what stopped them
drifting apart.
keymaps.enable (boolean) Master switch. false = the plugin binds no
keys at all.
keymaps.preset ("default"|"minimal"|"none")
Which entry of the keymap catalogue is bound.
"default" is the full set, "minimal" only the
keys with no plausible native equivalent,
"none" binds nothing while leaving the
catalogue available. An unknown value falls
back to "default" and is reported by
|:checkhealth-lsp|.
keymaps.map (table<string, string|false>)
Per-action override, keyed by the catalogue's
action name. A string replaces that action's
left-hand side; false drops the mapping; an
absent key keeps the preset's default.
usrcmds.enable (boolean) Register the |:Lsp| verb on setup, and
nothing else. false removes exactly one
command, :Lsp itself. It does NOT remove
the 25 flat aliases below -- switching those
off is legacy_aliases, and the two are
independent in both directions. Turning this
off while leaving the aliases on leaves the
whole flat family working with no verb over
it, which is a coherent wish and the reason
the two are separate switches.
usrcmds.legacy_aliases (boolean) Register the 25 flat :Lsp*/:Diag*
commands as aliases onto the same functions
the |:Lsp| routes call. On by default:
muscle memory beats tidiness and an alias
costs a line. Off drops exactly those 25 and
nothing else. :LspDoctor, :LspMdHints,
:EslintFix and the :TypeDef* family are
not aliases and are registered either way.
which_key.enable (boolean) Label the bound key prefixes as which-key
groups. which-key is a soft dependency: when
it is absent this does nothing and no mapping
is affected.
integrations.ui_menu (boolean) Let ui.nvim's right-click menu (ui.menu)
compose this plugin's fly-outs. false
hides them there only; items() still
serves any other host. Default true.
menu.enable (boolean) Contribute this plugin's entries to the
right-click context menu (nvzone/menu, a
soft dependency). The entries mirror the
resolved keymap catalogue, so
keymaps.preset and keymaps.map decide
what appears. With nvzone/menu absent this
only gates whether lsp.integrations.menu
returns entries at all.
An out-of-range value never raises. It degrades to the documented default and
is recorded as a warning, visible in |:Lsp-status| and |:checkhealth-lsp| -- a
typo in a config should cost a feature, not a startup. Each warning also names
the layer the value came from: (from setup()), (from preset "lean"), `(from
.nvim-lsp.json)`.
Every enum is checked this way, and so is every numeric field, every list and
every filetype map. The numeric rule has no exceptions: the set is derived from
DEFAULTS rather than listed by hand, so a field cannot be added without one.
Six of the twelve used to have no check and reached :Lsp status unaltered --
lightbulb.priority, formatter.timeout_ms, lspdoctor.list_limit,
lspdoctor.probe_timeout, lspdoctor.scratch_threshold and
lspdoctor.semantic_tokens_timeout. Nothing raised, because the consumers
defend themselves, but the report then showed what you typed rather than what
was running, which is the one thing a status report must not do.
integrations is the one option in this document's history that never
arrived: nothing reads it, and a default nothing reads is a promise the plugin
does not keep. completion and rename DID arrive -- both are in
DEFAULTS.lua and both are read (lsp/init.lua for the completion source,
bindings/actions.lua for the rename backend) -- and both are documented
above.
5. KEYMAPS
Keymaps are data.lua/lsp/config/KEYMAPS.luaholds one entry per action -- left-hand side, mode, action, description -- andbindings/keymaps.luabinds what is left after yourkeymaps.mapoverrides are applied. Adding a mapping means adding a catalogue entry; nothing is hardcoded at the binding site.docs/BINDINGS.mdis generated from the same table byscripts/gen_bindings.lua, which CI checks, so the list cannot drift from the code. Thedefaultpreset binds 57 entries;minimalbinds the 33 that have no Neovim 0.11 equivalent (it drops 24:grn,grt,]d/[d, twelve of the fourteen prefixlessls*keys, the five<leader>xl*Trouble views, and the three leader keys added with the navigation features --<leader>xo,<leader>xa,<leader>tW);nonebinds nothing.minimalkeeps twols*keys,lscandlsC: call hierarchy is the one thing in that family Neovim has no default for. So the'timeoutlen'cost below is NOT bought back by the preset -- two prefixlesslsmaps cost exactly what fourteen do. `keymaps.map = { picker_incoming_calls = false, picker_outgoing_calls = false }` is what finishes the job. The navigation keys -- what replaced lspsaga -- arelsp/lsT(peek the definition / type definition in a floating, editable window),lsa(code actions with a diff preview;grain Visual mode for a selection, because anls*key there would make everylwait out'timeoutlen'),<leader>xa(the quick fix for the diagnostic on this line),lsf(finder),lsh/lsH(type hierarchy),<leader>xo(outline sidebar) and<leader>tW(winbar toggle).docs/FEATURES/NAVIGATION.mdhas the detail. Overriding, per action:
keymaps = {
map = {
goto_definition = "gd", -- a string replaces the left-hand side
rename_leader = false, -- false drops the mapping
},
}
Two properties of the bound keys are worth knowing: - The prefixlessls*family costs every Normal-modela'timeoutlen'wait, because Neovim must see whether ansfollows. That is the price of a prefixless three-character mapping, and it is deliberate. -grnandgrtcollide with Neovim 0.11's owngr*maps -- but those are GLOBAL, not buffer-local.$VIMRUNTIME/lua/vim/_core/defaults.luasets all six (grn,gra,grr,gri,grt,gO) at startup, "unconditionally to avoid different behavior depending on whether an LSP client is attached"; in a--headless --nopluginsession with no client at all,maparg("grn", "n", false, true).bufferis0for every one of them. Being global, the catalogue'srenameandgoto_type_definition_grreplace Neovim's outright the moment the binder runs: after setup() under thedefaultpreset,maparg("grn").descreads "LSP: Rename symbol" where it read "vim.lsp.buf.rename()" before. That is what routinggrnthroughrename.providerneeds, and it does not need|LspAttach|to get it.bindings/autocmds.luare-binds the two buffer-locally on LspAttach anyway; with the collision global on both sides, that hook is belt and braces rather than the thing that makes the catalogue win.
6. COMMANDS
One command with eighteen subcommands and <Tab> completion, built withlib.nvim.bindings.usercmd.composer. The folding is done: the flat command family (:LspStatus, :LspLog, :LspInfo, :LspRecover, :LspStartHere, :LspStopHere, :LspRestartHere, :LspForceRestart, :LspFormat*, :LspWorkspaceDiagnostics*, :Diag*) is now 25 thin aliases onto the same functions the routes below call, so the two cannot drift apart.usrcmds.legacy_aliases = falsedrops them. |:LspDoctor| and :LspMdHints keep their own verbs and are not aliases, and neither are :EslintFix or the :TypeDef* family, which come fromtools/. Every subcommand below completes its arguments, and every argument set here is closed unless it says otherwise. *:Lsp* :Lsp status *:Lsp-status* Report what the plugin has set up: resolved config, keymaps bound, whether the command registered, and any configuration warnings. :Lsp servers *:Lsp-servers* The servers this plugin set up, plus the LSP clients Neovim currently has attached with their root directory and buffer count. The gap between the two is what one usually wants to see. :Lsp health *:Lsp-health* Run|:checkhealth|for this plugin. :Lsp info *:Lsp-info* Detailed LSP information for the current buffer, in ui.nvim's viewer. The:LspInfoalias is the same thing. :Lsp start [server] *:Lsp-start* :Lsp stop [server] *:Lsp-stop* :Lsp restart [server] *:Lsp-restart* Lifecycle for this buffer's clients: all of them with no argument, one by name with one. The server argument completes from the LIVE set rather than a list frozen at setup -- attached clients first, since "restart this one" usually means one of those, then everythingserversconfigures, attached or not. :Lsp force-restart {server} *:Lsp-force-restart* Restart one server with a full teardown first. Its own subcommand rather than a flag onrestart, because a literal word afterrestartwould be ambiguous with a server actually called "force". The server name is required here. :Lsp recover *:Lsp-recover* Start the servers that should be running on this buffer and are not. This is what owns the caseauto_restartdeliberately refuses: a client that died before it ever attached, where an automatic retry loop is a hazard. :Lsp format [action] *:Lsp-format* Format once, or control format-on-save. Actions:once(default),on,off,toggle,status,which.whichanswers which engine would format this buffer -- conform's chain for the filetype, or the LSP fallback. :Lsp hints [action] [filetype] *:Lsp-hints* Neovim's native inlay hints (|vim.lsp.inlay_hint|), globally or for one filetype. Actions:toggle(default),on,off,status,clear. As with |:Lsp-lightbulb|,clearneeds a filetype -- dropping "the global override" would be dropping the setting itself, so it is refused with a warning. :Lsp diag {action} [list] *:Lsp-diag* Diagnostics into a list, or movement within one. Actions:qf,loc,next,prev-- required, since there is no sensible default for "do something with diagnostics". The optional second argument picks the listnext/prevmove in,qforloc, defaulting toloc. Movement is one step per invocation: the keymaps fall back to|v:count1|, which is right for a keypress and wrong for a command, wherev:countholds whatever the last keypress left behind. :Lsp workspace [action] [project] *:Lsp-workspace* Workspace-wide diagnostics. Actions:on,off,toggle,status(default),now,clear,list.nowpopulates immediately rather than waiting for an attach. Without {project} the global switch moves. With one, only that folder's override does. {project} is.(the cwd's project root), a folder name under $REPOS_DIR, or a path; it completes., the $REPOS_DIR folders and existing overrides.cleardrops an override,listshows them. Off also holds back pushes for files you have not opened and clears what a scan already showed; on orclearreplays them. Not persisted -- see attach.workspace_diagnostics_projects. :Lsp root [action] *:Lsp-root* Roots and workspace folders. Two mechanisms under one word, because from where you sit both answer "what does this server consider my project". show (default) The active root scope, plus every attached client's resolved root, the workspace folders it holds, and whether it accepts a runtime change. pick Switch the resolution scope: cwd, git root, or the file's own path. Reaches only the servers whoseroot_diris a function. Of the eightserversship by default that is three:lua_ls,marksman, andcsharp-- which registers under the config nameomnisharp, so that is what:Lsp root showcalls it.bashls,gopls,html,ts_lsandtailwindcssdeclareroot_markers, which Neovim resolves itself with no hook to intercept. Aroot_dirfunction is necessary and not sufficient, though: of the three, onlylua_lsreads the scope at all (servers/lua_ls/rootresolver.lua) and only it listens forUser LspRootScopeChangedto recompute for open buffers.marksmanandcsharpresolve from their own markers and are unaffected by a pick. add Pick a directory and add it as a workspace folder on every attached client that accepts one. This is LSP's own multi-root mechanism (workspace/didChangeWorkspaceFolders): it reaches every server that advertiseschangeNotifications,root_markersones included, and takes effect without a restart. The monorepo case it exists for: gopls sitting inpackages/api, one pick ofpackages/web, and definitions across the package boundary resolve. remove Pick one of the held folders and take it off. list Whataddwould offer, as a report, without opening a picker. Candidates are found by walking upward from the buffer for |lsp.nvim-config|workspace.markers, then reading the outermost project's children and descending one level throughworkspace.containers-- the sibling package an upward walk can never see. Anything already a workspace folder is left out. A client that declares noworkspaceFolders.supported, or that never asked forchangeNotifications, is skipped rather than sent a notification it did not ask for.:Lsp root shownames it, with the reason. :Lsp autorestart [action] *:Lsp-autorestart* Whether a crashed server is brought back on its own. Actions:toggle(default),on,off,status.statusis the one to read after something went wrong: it names every server with a failed attempt on record, why the last attempt failed, and how far the backoff had got. The same counter is what |:LspDoctor| startup reports. :Lsp lightbulb [action] [filetype] *:Lsp-lightbulb* The code-action indicator, globally or for one filetype. Actions:toggle(default),on,off,status,clear. With no filetype the global default moves; with one, an override is written for that filetype alone, andcleargives it back to the global.statusanswers the question that decides whether the indicator is worth having in this buffer: which attached clients advertisecodeActionProvider, which CodeActionKinds are on the allowlist, and whether a mark is on screen right now. :Lsp winbar [action] [filetype] *:Lsp-winbar* The LSP breadcrumb in the winbar, globally or for one filetype. Actions:toggle(default),on,off,status,clear, with the same meaning as for |:Lsp-lightbulb|.statussays which attached client answerstextDocument/documentSymbolin this buffer and whether the symbols cached for it are fresh. :Lsp implement [action] [filetype] *:Lsp-implement* Implementation markers on interfaces, globally or for one filetype. Same actions and arguments as |:Lsp-winbar|.statusnames the client that would answertextDocument/implementationand how many markers are on screen. Off unlessimplement.enablesays otherwise. :Lsp peek [kind] *:Lsp-peek* Peek a definition in a floating, editable window.kindisdefinition(default),type_definition,implementationordeclaration; the last two have no key. Inside the floatqcloses,<C-o>takes the buffer into the window you came from,<C-v>/<C-x>into a split and<C-t>into a tab (peek.keys). :Lsp log open *:Lsp-log-open* Open Neovim's LSP log file (|vim.lsp.get_log_path()|) in a split. Warns when no log file exists yet. :Lsp log level {level} *:Lsp-log-level* Set the LSP log level. Completes over trace, debug, info, warn, error and off. :LspDoctor [report] *:LspDoctor* Six reports on the LSP state of the current buffer, each answering one question. With!the report opens in a scratch buffer instead of being printed. Reachable as:Lsp doctortoo, over the same six names -- the route takeslspdoctor.MODESitself rather than repeating the spellings, so the two cannot come to offer different reports. They differ in one thing only, and deliberately: with no argument:LspDoctorrunsall, while:Lsp doctorrunsstartup. The route always opens a scratch split, the combined report is long, and the question one arrives with is almost always "why is my server not running". startup Is the server running, and if not, why? Executable, attempts, last error, and what to run next. resolve Where the filetype -> server chain breaks, in five steps. buffer Clients, diagnostic counts, provider conflicts, offset encodings, formatter. Capped atlspdoctor.list_limit. capabilities Thebufferreport uncapped, plus root_dir, workspace folders and the full capability set per client. probe Whether diagnostics arrive at all: hands the attached clients a buffer they cannot parse and waitslspdoctor.probe_timeout. The only report that provokes rather than observes, and the only one that costs anything -- which is whyallleaves it out. all (default) The four observing reports. The older nameshealth,debug,quickanddeep(which mapped ontostartup,resolve,bufferandcapabilities) were accepted but never offered, and were removed on 2026-09-02.lua/lsp/lspdoctor/README.mddocuments the reports in full. Reports open in a scratch split rather than a notification: they are multi-line, and meant to be read and copied from.
7. HEALTH
*:checkhealth-lsp*
:checkhealth lsp
Six sections, in this order:Environment(Neovim version, lib.nvim),lsp.nvim(what setup() registered, including every warning it worked around),Servers,Ecosystem(the plugins around this one),Diagnostics(who owns|vim.diagnostic.config()|and what each contributor put there), andPer-buffer diagnosis, a pointer to |:LspDoctor|. The servers section reads left to right along four numbers: installed (what Mason has on disk, whateverserverssays), configured, set up, and attached -- both in total and in the buffer you were in when you opened the report. That last one is the alternate buffer, not the current one: Neovim creates thehealth://buffer and makes it current before running a single check, so reading the current buffer would report on the report. The alternate only survives the first|:checkhealth|of a session; after that the section falls back to the most recently used file buffer, and says so rather than reporting a zero it would have invented. An installed server that nothing attaches to costs nothing. The section warns about exactly one thing: a server whose cost scales with attached buffers (ts_ls/tsserver,pyright,jdtls,omnisharp) held open across more than twenty of them. Never on a count alone -- five buffers onts_lsis a working set, and warning about that would train you to skip the section. Severity follows dependency hardness: something the plugin cannot work without is an error, something it uses when present is information. conform.nvim is the one ERROR in the ecosystem section -- it is the formatter's primary engine, and without it formatting falls back to the LSP. Everything else there, trouble.nvim included, is informational. Note that informational does not mean unwired: trouble is driven from the keymap catalogue, which binds thirteen keys for it under thedefaultpreset, anddiagnostics.uiroutes]d/[dthrough it. Thelsp.nvimsection warns separately about keys bound for a plugin that is not installed, and names them.
8. ARCHITECTURE
Three layers, each answering a different question:
pack/ WHAT gets installed -- LazySpec export, no logic
integrations/ HOW third-party plugins are wired -- one adapter each
core/ the own code on vim.lsp.* -- registry, attach, servers, ...
The core does not reach into the integrations:core/attach.luanever requires lazydev itself, the adapter does. That keeps the core testable without a plugin manager and makes swapping a completion engine a one-file change.scripts/gen_map.luadeclares the rule so it can be checked rather than merely intended. The tree is complete:pack/,integrations/,core/,servers/,languages/,formatter/,diagnostics/,lspdoctor/,tools/,usercmds/,completion/,config/,bindings/,@types/,health.luaandinit.lua. The adapters own every third-partyrequire. They are not called by the core; they hand capability contributors and attach hooks tolsp/init.lua, which passes them in as plain functions. That is whycore/attach.luano longer knows lazydev or NvChad exist, and why |lsp.nvim.setup()| is the only place that composes the two layers.
9. STATUS AND ROADMAP
All five migration phases are done. The core lives here, configures the servers, owns every LSP keymap, reaches third-party plugins only through the adapters inintegrations/, and installs and configures them frompack/. The last item that used to stand here -- folding the flat:Lsp*commands into |:Lsp| routes -- is done too. There are eighteen routes, and the 25 flat commands that remain are aliases onto the same functions, switchable off withusrcmds.legacy_aliases. See |lsp.nvim-commands|. What is left isintegrationsas a configurable option: the adapters exist and are wired, but nothing reads a user-suppliedintegrationstable, so there is deliberately no default for one. See the end of |lsp.nvim-config|.