You just updated Neovim to the latest stable release (v0.10.x or the v0.11 nightly). You launch your editor, open a Rust or TypeScript file, and expect the usual flood of diagnostics and semantic highlighting. Instead, you get silence. Or worse, you invoke a keymap and get a stack trace: E5108: Error executing lua ... attempt to index a nil value.
The ecosystem is shifting. The transition from plugin-heavy LSP handling to Neovim's native vim.lsp implementation has reached a maturity point where legacy configurations are no longer just "deprecated"—they are breaking.
This post dissects why your on_attach logic is failing and provides a robust, Principal-grade Lua implementation to fix it using the modern LspAttach event architecture.
The Root Cause: Event Decoupling and API Deprecation
Two specific changes are responsible for 90% of LSP failures in 2024/2025 configurations:
1. The Death of vim.lsp.get_active_clients
In Neovim v0.10, vim.lsp.get_active_clients() was deprecated in favor of vim.lsp.get_clients(). Many older utility functions and statusline plugins rely on the former. If your config tries to filter attached clients using the old API to toggle formatting or inlay hints, it will crash with a nil error.
2. The on_attach Race Condition
Historically, we passed a monolithic on_attach function into the setup({}) call of nvim-lspconfig. While this still works technically, it creates a tight coupling between server initialization and buffer attachment. As Neovim's startup time decreases and plugins lazy-load more aggressively, the server setup call might occur after the buffer is loaded, or the attachment logic might misfire if root_dir detection lags.
The modern solution is to decouple these. We stop passing on_attach to individual servers and instead listen for the global LspAttach event.
The Solution: A Modular, Event-Driven LSP Config
We will refactor your LSP configuration to:
- Use
vim.api.nvim_create_autocmd('LspAttach')for keymaps and settings. - Safely handle capabilities (integrating with
cmporblink). - Handle the
ts_ls(formerlytsserver) rename and modern capabilities like Inlay Hints.
Prerequisites
Ensure you have the standard plugin manager setup (Lazy.nvim recommended) and neovim/nvim-lspconfig installed.
implementation
Create or update your lua/plugins/lsp.lua (or equivalent module) with the following. This code assumes a modular structure but can be placed in init.lua if necessary.
return {
"neovim/nvim-lspconfig",
dependencies = {
"williamboman/mason.nvim", -- Optional: for managing binaries
"williamboman/mason-lspconfig.nvim",
"hrsh7th/cmp-nvim-lsp", -- Assuming nvim-cmp, swap if using blink.cmp
},
config = function()
local lspconfig = require("lspconfig")
local map = vim.keymap.set
-- 1. MODERN DIAGNOSTIC CONFIGURATION
-- We define UI customization globally, not per-client
vim.diagnostic.config({
float = { border = "rounded" },
virtual_text = {
prefix = "●", -- Could use icons like ■
source = "if_many", -- Or "always"
},
signs = true,
underline = true,
update_in_insert = false,
severity_sort = true,
})
-- 2. THE CAPABILITIES MERGE
-- We must merge Neovim's default capabilities with the completion engine's
-- Note: If you are using 'blink.cmp', use blink's capabilities generator instead.
local capabilities = vim.lsp.protocol.make_client_capabilities()
capabilities = vim.tbl_deep_extend(
"force",
capabilities,
require("cmp_nvim_lsp").default_capabilities()
)
-- 3. THE ATTACH EVENT (The Critical Fix)
-- Instead of an 'on_attach' function passed to every server,
-- we use a global event listener.
vim.api.nvim_create_autocmd("LspAttach", {
group = vim.api.nvim_create_augroup("UserLspConfig", { clear = true }),
callback = function(event)
-- 'event.data.client_id' contains the ID of the client attaching
-- 'event.buf' contains the buffer number
local client = vim.lsp.get_client_by_id(event.data.client_id)
local bufnr = event.buf
-- Safely map function to define buffer-local keymaps
local function buf_map(mode, lhs, rhs, desc)
map(mode, lhs, rhs, { buffer = bufnr, desc = "LSP: " .. desc })
end
-- Standard LSP Keymaps
buf_map("n", "gd", vim.lsp.buf.definition, "Goto Definition")
buf_map("n", "gr", vim.lsp.buf.references, "Goto References")
buf_map("n", "K", vim.lsp.buf.hover, "Hover Documentation")
buf_map("n", "<leader>rn", vim.lsp.buf.rename, "Rename Symbol")
buf_map("n", "<leader>ca", vim.lsp.buf.code_action, "Code Action")
-- 4. INLAY HINTS (Neovim v0.10+ Native Support)
-- We check if the server supports it before enabling
if client and client.server_capabilities.inlayHintProvider and vim.lsp.inlay_hint then
buf_map("n", "<leader>th", function()
vim.lsp.inlay_hint.enable(not vim.lsp.inlay_hint.is_enabled({ bufnr = bufnr }))
end, "Toggle Inlay Hints")
end
-- 5. FORMATTING (Explicit Control)
-- Example: Disable tsserver formatting to allow prettier/null-ls to handle it
if client.name == "ts_ls" then
client.server_capabilities.documentFormattingProvider = false
end
end,
})
-- 6. SERVER SETUP
-- List your servers here.
-- Note: 'tsserver' is deprecated/renamed to 'ts_ls' in nvim-lspconfig as of late 2024
local servers = {
lua_ls = {
settings = {
Lua = {
diagnostics = { globals = { "vim" } },
workspace = {
library = {
[vim.fn.expand("$VIMRUNTIME/lua")] = true,
[vim.fn.stdpath("config") .. "/lua"] = true,
},
},
},
},
},
ts_ls = {}, -- Replaces tsserver
rust_analyzer = {},
gopls = {},
-- Add others here
}
-- Setup servers with mason-lspconfig or manual loops
require("mason").setup()
require("mason-lspconfig").setup({
ensure_installed = vim.tbl_keys(servers),
handlers = {
function(server_name)
local server_opts = servers[server_name] or {}
-- Deep merge capabilities to ensure completion works
server_opts.capabilities = vim.tbl_deep_extend(
"force",
capabilities,
server_opts.capabilities or {}
)
lspconfig[server_name].setup(server_opts)
end,
},
})
end,
}
Why This Fix Works
1. Execution Order Independence
By moving the configuration logic into the LspAttach autocommand, we invert control. The configuration code sits passively waiting for the LSP client to announce "I am ready." It does not matter if the server takes 500ms or 5ms to start; the keymaps and settings are applied at the exact moment the buffer attaches.
2. Client Capabilities Verification
In the snippet above, look at the Inlay Hints section:
if client and client.server_capabilities.inlayHintProvider ...
We are explicitly checking the server_capabilities object on the specific instance of the client attached to the current buffer. This prevents the common crash where a user attempts to enable hints globally, but one specific language server (e.g., an older Python pyright version) doesn't support it, causing a Lua error.
3. Native Toggle Logic
The toggle function for Inlay Hints utilizes vim.lsp.inlay_hint.is_enabled({ bufnr = bufnr }). This uses the new Neovim 0.10+ API directly, bypassing deprecated helper functions and ensuring future compatibility with v0.11.
Conclusion
The era of copy-pasting massive on_attach functions is over. By leveraging Neovim's event loop via LspAttach, you ensure your configuration is resilient to lazy-loading race conditions and API deprecations.
If you are still seeing Client not attached after applying this, verify your root_dir patterns. If the LSP cannot detect a valid root (like a .git folder or package.json), it will refuse to attach regardless of how perfect your Lua config is. You can debug this by running :LspInfo in the target buffer.