Skip to main content

Neovim Lua LSP 2025: Fixing 'Client Not Attached' and `vim.lsp.config` Errors

 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:

  1. Use vim.api.nvim_create_autocmd('LspAttach') for keymaps and settings.
  2. Safely handle capabilities (integrating with cmp or blink).
  3. Handle the ts_ls (formerly tsserver) 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.