LSP Internals
How NeoVim’s built-in LSP client works under the hood: client lifecycle, RPC transport, handler dispatch, and capability negotiation.
Client Lifecycle
When you call vim.lsp.enable("lua_ls"), here’s what happens:
sequenceDiagram
participant Buffer as Opened buffer
participant Nvim as NeoVim LSP client
participant Server as Language server
Buffer->>Nvim: FileType event
Nvim->>Nvim: Match enabled config
Nvim->>Nvim: Resolve root directory
Nvim->>Nvim: Reuse client if possible
alt No reusable client
Nvim->>Server: Spawn process and start RPC
Nvim->>Server: initialize(client capabilities)
Server-->>Nvim: server capabilities
Nvim->>Server: initialized
end
Nvim->>Server: textDocument/didOpen
Nvim-->>Buffer: LspAttach
The reuse check (step 4) is key to efficiency. Opening 10 Lua files in the same project shares one lua-language-server instance.
-- See active clients
vim.lsp.get_clients() -- Returns list of active client objects
-- Each client has:
-- client.id - Unique numeric ID
-- client.name - Server name ("lua_ls")
-- client.config - The resolved config table
-- client.capabilities - Merged client capabilities
-- client.server_capabilities - What the server supports
-- client.root_dir - Resolved root directory
RPC Transport
The LSP client communicates via JSON-RPC 2.0 over stdio. The transport layer lives in runtime/lua/vim/lsp/_transport.lua and runtime/lua/vim/lsp/rpc.lua.
sequenceDiagram
participant Nvim as NeoVim
participant Server as Language Server
Nvim->>Server: Content-Length header
Nvim->>Server: JSON-RPC request body
Server-->>Nvim: Content-Length header
Server-->>Nvim: JSON-RPC response body
The protocol carries three message types:
| Type | Direction | Pattern |
|---|---|---|
| Request | Either | Has id + method. Expects a response. |
| Response | Either | Has id + result or error. |
| Notification | Either | Has method only. No response expected. |
Key implementation detail: NeoVim uses vim.uv (libuv) for non-blocking I/O on the stdio pipes. The server process runs independently; NeoVim reads its stdout asynchronously via the event loop.
-- You can send raw requests to a server
local client = vim.lsp.get_clients({ name = "lua_ls" })[1]
if client then
client:request("textDocument/hover", {
textDocument = { uri = vim.uri_from_bufnr(0) },
position = { line = 10, character = 5 },
}, function(err, result)
if result then
print(vim.inspect(result))
end
end)
end
Handler Dispatch
When a response or notification arrives, NeoVim dispatches it through a handler chain:
flowchart TD
incoming[Server response or notification] --> buffer[Buffer-local handler]
buffer --> client[Client-specific handler]
client --> global[Global handler]
Default handlers live in runtime/lua/vim/lsp/handlers.lua. You can override at any level:
-- Override globally: show hover in a floating window with a border
vim.lsp.handlers["textDocument/hover"] = vim.lsp.with(
vim.lsp.handlers.hover, {
border = "rounded",
}
)
-- Override per-client
vim.lsp.config["lua_ls"] = {
handlers = {
["textDocument/hover"] = function(err, result, ctx, config)
-- Custom hover handling
vim.lsp.handlers.hover(err, result, ctx, config)
end,
},
}
Key built-in handlers:
| Method | Handler |
|---|---|
textDocument/hover | Shows floating window with docs |
textDocument/definition | Jumps to definition |
textDocument/references | Populates quickfix list |
textDocument/publishDiagnostics | Feeds into vim.diagnostic |
textDocument/completion | Returns completion items |
window/showMessage | Shows server messages via vim.notify |
window/logMessage | Logs to LSP log file |
Capability Negotiation
On initialization, client and server exchange capabilities. NeoVim sends what it supports; the server responds with what it supports. Features are only enabled if both sides agree.
-- What NeoVim tells the server it can do
local client_capabilities = vim.lsp.protocol.make_client_capabilities()
-- Includes: textDocument/completion, textDocument/hover,
-- textDocument/definition, workspace/configuration, etc.
-- You can extend capabilities (e.g., for nvim-cmp)
local capabilities = vim.lsp.protocol.make_client_capabilities()
capabilities.textDocument.completion.completionItem.snippetSupport = true
capabilities.textDocument.completion.completionItem.resolveSupport = {
properties = { "documentation", "detail", "additionalTextEdits" },
}
vim.lsp.config["*"] = {
capabilities = capabilities,
}
The server’s capabilities determine what features are available:
-- Check what a server supports
vim.api.nvim_create_autocmd("LspAttach", {
callback = function(args)
local client = vim.lsp.get_client_by_id(args.data.client_id)
if client then
print("Supports hover:", client.server_capabilities.hoverProvider)
print("Supports rename:", client.server_capabilities.renameProvider)
print("Supports formatting:", client.server_capabilities.documentFormattingProvider)
end
end,
})
Dynamic Registration
Servers can dynamically register/unregister capabilities after initialization. For example, a server might not advertise formatting support initially, but register it after loading a config file.
NeoVim handles this in runtime/lua/vim/lsp/_capability.lua. The client:supports_method() function checks both static and dynamically registered capabilities.
Document Synchronization
NeoVim keeps the server in sync with buffer changes. Three sync modes exist:
| Mode | How it works |
|---|---|
| Full | Sends entire document on every change |
| Incremental | Sends only the changed range |
| None | No sync (server reads files directly) |
NeoVim prefers incremental when the server supports it. The change tracking lives in runtime/lua/vim/lsp/_changetracking.lua.
-- This happens automatically when you type:
-- 1. Buffer change detected
-- 2. Debounced (doesn't send on every keystroke)
-- 3. textDocument/didChange sent with incremental edits
-- 4. Server re-parses and may send new diagnostics
LSP Log
When things go wrong, check the LSP log:
-- Open the log file
vim.cmd.edit(vim.lsp.get_log_path())
-- Set log level for debugging
vim.lsp.set_log_level("debug") -- "trace", "debug", "info", "warn", "error"
The log shows all JSON-RPC messages. Set to “trace” to see every request and response.
Gotcha: The log grows fast at “trace” level. Remember to set it back to “warn” when done debugging.
LSP Events
NeoVim fires autocommand events throughout the LSP lifecycle:
| Event | When |
|---|---|
LspAttach | Client attached to a buffer |
LspDetach | Client detached from a buffer |
LspNotify | Server sent a notification |
LspProgress | Server sent a progress update |
LspRequest | Request sent to or received from server |
LspTokenUpdate | Semantic token updated |
-- Show progress messages
vim.api.nvim_create_autocmd("LspProgress", {
callback = function(args)
local data = args.data
if data.params and data.params.value then
local msg = data.params.value.message or ""
local title = data.params.value.title or ""
print(title .. ": " .. msg)
end
end,
})
New in 0.12
Several LSP features added in the latest development version:
| Feature | API |
|---|---|
| Inline completion | vim.lsp.inline_completion (Copilot-style ghost text) |
| Code lenses as virtual lines | Rendered inline instead of as virtual text |
| Document color | Automatic color highlighting |
| Linked editing range | Edit matching tags/names simultaneously |
| On-type formatting | Format as you type |
:lsp command | CLI interface for LSP operations |