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:

TypeDirectionPattern
RequestEitherHas id + method. Expects a response.
ResponseEitherHas id + result or error.
NotificationEitherHas 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:

MethodHandler
textDocument/hoverShows floating window with docs
textDocument/definitionJumps to definition
textDocument/referencesPopulates quickfix list
textDocument/publishDiagnosticsFeeds into vim.diagnostic
textDocument/completionReturns completion items
window/showMessageShows server messages via vim.notify
window/logMessageLogs 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:

ModeHow it works
FullSends entire document on every change
IncrementalSends only the changed range
NoneNo 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:

EventWhen
LspAttachClient attached to a buffer
LspDetachClient detached from a buffer
LspNotifyServer sent a notification
LspProgressServer sent a progress update
LspRequestRequest sent to or received from server
LspTokenUpdateSemantic 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:

FeatureAPI
Inline completionvim.lsp.inline_completion (Copilot-style ghost text)
Code lenses as virtual linesRendered inline instead of as virtual text
Document colorAutomatic color highlighting
Linked editing rangeEdit matching tags/names simultaneously
On-type formattingFormat as you type
:lsp commandCLI interface for LSP operations