Skip to main content
LSP handlers are functions that process responses from language servers. Each LSP method can have a custom handler to control how Neovim reacts to server responses.

Handler Basics

Handlers are Lua functions with this signature:
function(err, result, ctx)
  -- err: error info or nil
  -- result: response result or nil
  -- ctx: calling context (method, client_id, bufnr, etc.)
end

Handler Context

The ctx table contains:
method
string
LSP method name (e.g., textDocument/hover)
client_id
integer
Client ID that sent the request
bufnr
integer
Buffer handle where request originated
params
table
Request parameters that were sent
version
integer
Document version at time of request (compare to current version to check staleness)

Handler Resolution

Handlers are resolved in order of priority:
  1. Handler passed to client:request() (highest priority)
  2. Handler in vim.lsp.handlers global table
  3. Handler passed to vim.lsp.start() in config
-- Priority 3: Config handler
vim.lsp.start({
  cmd = { 'server' },
  handlers = {
    ['textDocument/publishDiagnostics'] = my_diagnostics_handler,
  },
})

-- Priority 2: Global handler
vim.lsp.handlers['textDocument/hover'] = my_hover_handler

-- Priority 1: Request-specific handler
client:request('textDocument/hover', params, my_custom_hover_handler)

Custom Handlers

Basic Example

Create a custom hover handler:
vim.lsp.handlers['textDocument/hover'] = function(err, result, ctx, config)
  if err then
    vim.notify('Hover error: ' .. err.message, vim.log.levels.ERROR)
    return
  end
  
  if not result or not result.contents then
    vim.notify('No hover information', vim.log.levels.INFO)
    return
  end
  
  -- Use default handler with custom config
  local opts = vim.tbl_extend('force', config or {}, {
    border = 'rounded',
    max_width = 80,
  })
  
  return vim.lsp.handlers.hover(err, result, ctx, opts)
end

Filtering Results

Filter code actions by kind:
vim.lsp.handlers['textDocument/codeAction'] = function(err, result, ctx, config)
  if err then
    return
  end
  
  -- Only show quickfix actions
  local quickfixes = vim.tbl_filter(function(action)
    return action.kind and action.kind:match('^quickfix')
  end, result or {})
  
  if #quickfixes == 0 then
    vim.notify('No quickfix actions available', vim.log.levels.INFO)
    return
  end
  
  -- Use default handler with filtered results
  return vim.lsp.handlers['textDocument/codeAction'](err, quickfixes, ctx, config)
end

Server-to-Client Handlers

These handlers process requests and notifications from the server.

window/showMessage

Display messages from the server:
-- Default handler shows messages as vim.notify
vim.lsp.handlers['window/showMessage'] = function(err, result, ctx)
  local client = vim.lsp.get_client_by_id(ctx.client_id)
  local message = string.format('LSP[%s] %s', client.name, result.message)
  
  -- Map LSP MessageType to vim log levels
  local level = ({
    [1] = vim.log.levels.ERROR,  -- Error
    [2] = vim.log.levels.WARN,   -- Warning
    [3] = vim.log.levels.INFO,   -- Info
    [4] = vim.log.levels.DEBUG,  -- Log
  })[result.type] or vim.log.levels.INFO
  
  vim.notify(message, level)
end

window/showMessageRequest

Handle action requests from the server:
vim.lsp.handlers['window/showMessageRequest'] = function(err, result, ctx)
  if not result or not result.actions then
    return vim.NIL
  end
  
  vim.ui.select(result.actions, {
    prompt = result.message,
    format_item = function(action)
      return action.title
    end,
  }, function(choice)
    if choice then
      -- User selected an action, return it to server
      return choice
    end
    return vim.NIL
  end)
end

workspace/applyEdit

Apply workspace edits from the server:
vim.lsp.handlers['workspace/applyEdit'] = function(err, result, ctx)
  local client = vim.lsp.get_client_by_id(ctx.client_id)
  
  if result.label then
    print('Applying workspace edit:', result.label)
  end
  
  local success, error_msg = pcall(
    vim.lsp.util.apply_workspace_edit,
    result.edit,
    client.offset_encoding
  )
  
  return {
    applied = success,
    failureReason = error_msg,
  }
end

$/progress

Handle progress notifications:
vim.lsp.handlers['$/progress'] = function(err, result, ctx)
  local client = vim.lsp.get_client_by_id(ctx.client_id)
  local value = result.value
  
  if value.kind == 'begin' then
    print(string.format('[%s] %s: %s', client.name, value.title, value.message or ''))
  elseif value.kind == 'report' then
    if value.percentage then
      print(string.format('[%s] %d%%', client.name, value.percentage))
    end
  elseif value.kind == 'end' then
    print(string.format('[%s] %s: done', client.name, value.title))
  end
end

Client-to-Server Response Handlers

These handlers process responses to requests initiated by the client.

textDocument/definition

Customize go-to-definition behavior:
vim.lsp.handlers['textDocument/definition'] = function(err, result, ctx)
  if err then
    vim.notify('Error finding definition', vim.log.levels.ERROR)
    return
  end
  
  if not result or vim.tbl_isempty(result) then
    vim.notify('Definition not found', vim.log.levels.WARN)
    return
  end
  
  local client = vim.lsp.get_client_by_id(ctx.client_id)
  
  -- Single result: jump directly
  if not vim.islist(result) then
    vim.lsp.util.jump_to_location(result, client.offset_encoding)
    return
  end
  
  -- Multiple results: use quickfix
  if #result == 1 then
    vim.lsp.util.jump_to_location(result[1], client.offset_encoding)
  else
    vim.fn.setqflist({}, ' ', {
      title = 'LSP Definitions',
      items = vim.lsp.util.locations_to_items(result, client.offset_encoding),
    })
    vim.cmd.copen()
  end
end

textDocument/references

Custom references handler with filtering:
vim.lsp.handlers['textDocument/references'] = function(err, result, ctx, config)
  if not result or vim.tbl_isempty(result) then
    vim.notify('No references found', vim.log.levels.INFO)
    return
  end
  
  -- Filter out test files
  local filtered = vim.tbl_filter(function(ref)
    return not ref.uri:match('_test%.%w+$')
  end, result)
  
  local client = vim.lsp.get_client_by_id(ctx.client_id)
  local items = vim.lsp.util.locations_to_items(filtered, client.offset_encoding)
  
  vim.fn.setloclist(0, {}, ' ', {
    title = string.format('References (%d)', #items),
    items = items,
  })
  vim.cmd.lopen()
end

textDocument/completion

Custom completion handler:
vim.lsp.handlers['textDocument/completion'] = function(err, result, ctx)
  if err or not result then
    return
  end
  
  -- Normalize to CompletionList
  local items = result.items or result
  
  -- Filter items based on custom criteria
  local filtered = vim.tbl_filter(function(item)
    -- Only show functions and methods
    return item.kind == 3 or item.kind == 2  -- Function or Method
  end, items)
  
  if #filtered == 0 then
    return
  end
  
  -- Process completion items
  vim.lsp.completion._convert_to_completion_items(filtered, ctx.bufnr)
end

Handler Utilities

Wrapping Default Handlers

Extend default behavior:
-- Save original handler
local original_hover = vim.lsp.handlers['textDocument/hover']

vim.lsp.handlers['textDocument/hover'] = function(err, result, ctx, config)
  -- Add custom logic before
  print('Hover requested at', ctx.params.position.line, ctx.params.position.character)
  
  -- Call original handler
  local ret = original_hover(err, result, ctx, config)
  
  -- Add custom logic after
  vim.notify('Hover complete', vim.log.levels.DEBUG)
  
  return ret
end

Conditional Handlers

Different behavior per client:
vim.lsp.handlers['textDocument/formatting'] = function(err, result, ctx)
  local client = vim.lsp.get_client_by_id(ctx.client_id)
  
  if client.name == 'null-ls' then
    -- Special handling for null-ls
    vim.notify('Formatted with null-ls', vim.log.levels.INFO)
  elseif client.name == 'prettier' then
    -- Special handling for prettier
    vim.notify('Formatted with prettier', vim.log.levels.INFO)
  end
  
  -- Apply edits
  if result then
    vim.lsp.util.apply_text_edits(result, ctx.bufnr, client.offset_encoding)
  end
end

Error Handling

Robust error handling pattern:
local function safe_handler(handler_fn)
  return function(err, result, ctx, config)
    if err then
      local client = vim.lsp.get_client_by_id(ctx.client_id)
      vim.notify(
        string.format('LSP[%s] %s error: %s', client.name, ctx.method, err.message),
        vim.log.levels.ERROR
      )
      return
    end
    
    local ok, ret = pcall(handler_fn, err, result, ctx, config)
    if not ok then
      vim.notify(
        string.format('Handler error for %s: %s', ctx.method, ret),
        vim.log.levels.ERROR
      )
    end
    return ret
  end
end

vim.lsp.handlers['textDocument/hover'] = safe_handler(function(err, result, ctx, config)
  -- Custom hover logic
end)

Per-Client Handlers

Define handlers when starting a client:
vim.lsp.start({
  name = 'my-server',
  cmd = { 'language-server' },
  handlers = {
    ['textDocument/publishDiagnostics'] = function(err, result, ctx)
      -- Only for this client
      print('Diagnostics from my-server:', #result.diagnostics)
      
      -- Still process with default handler
      vim.lsp.diagnostic.on_publish_diagnostics(err, result, ctx)
    end,
  },
})

See Also

Client

LSP client lifecycle and methods

Protocol

LSP protocol constants and types

Diagnostics

Working with diagnostics

Completion

Completion handlers and configuration

Build docs developers (and LLMs) love