LSP Setup

NeoVim 0.11+ introduced a declarative LSP configuration system. You define server configs, enable them, and NeoVim handles starting/stopping clients as you open files.

The New Way (0.11+)

Three steps: configure, enable, customize on attach.

-- 1. Configure a language server
vim.lsp.config["lua_ls"] = {
  cmd = { "lua-language-server" },
  filetypes = { "lua" },
  root_markers = { ".luarc.json", ".luarc.jsonc", ".git" },
  settings = {
    Lua = {
      runtime = { version = "LuaJIT" },
      workspace = { library = vim.api.nvim_get_runtime_file("", true) },
    },
  },
}

-- 2. Enable it
vim.lsp.enable("lua_ls")

-- 3. Customize behavior on attach (optional)
vim.api.nvim_create_autocmd("LspAttach", {
  callback = function(args)
    local client = vim.lsp.get_client_by_id(args.data.client_id)
    local buf = args.buf

    -- Keymaps for LSP-attached buffers
    vim.keymap.set("n", "gd", vim.lsp.buf.definition, { buffer = buf })
    vim.keymap.set("n", "K", vim.lsp.buf.hover, { buffer = buf })
    vim.keymap.set("n", "<leader>ca", vim.lsp.buf.code_action, { buffer = buf })
    vim.keymap.set("n", "<leader>rn", vim.lsp.buf.rename, { buffer = buf })

    -- Format on save
    if client and client:supports_method("textDocument/formatting") then
      vim.api.nvim_create_autocmd("BufWritePre", {
        buffer = buf,
        callback = function()
          vim.lsp.buf.format({ bufnr = buf })
        end,
      })
    end
  end,
})

Config Files on Runtimepath

Instead of inline config, you can put server configs in files:

~/.config/nvim/
  lsp/
    lua_ls.lua        -- return { cmd = ..., filetypes = ... }
    gopls.lua
    ts_ls.lua
  after/lsp/
    lua_ls.lua        -- Overrides/extends the base config
-- lsp/gopls.lua
return {
  cmd = { "gopls" },
  filetypes = { "go", "gomod", "gowork", "gotmpl" },
  root_markers = { "go.mod", ".git" },
  settings = {
    gopls = {
      analyses = { unusedparams = true },
      staticcheck = true,
    },
  },
}
-- init.lua - just enable, config is found automatically
vim.lsp.enable({ "lua_ls", "gopls", "ts_ls" })

Config resolution merges in order:

  1. vim.lsp.config["*"] (wildcard, applies to all servers)
  2. lsp/<name>.lua files on runtimepath
  3. after/lsp/<name>.lua files
  4. Inline vim.lsp.config["name"] = {...} calls

Common Server Configs

-- TypeScript
vim.lsp.config["ts_ls"] = {
  cmd = { "typescript-language-server", "--stdio" },
  filetypes = { "typescript", "typescriptreact", "javascript", "javascriptreact" },
  root_markers = { "tsconfig.json", "jsconfig.json", "package.json", ".git" },
}

-- Python
vim.lsp.config["pyright"] = {
  cmd = { "pyright-langserver", "--stdio" },
  filetypes = { "python" },
  root_markers = { "pyproject.toml", "setup.py", ".git" },
  settings = {
    python = {
      analysis = { typeCheckingMode = "basic" },
    },
  },
}

-- Rust
vim.lsp.config["rust_analyzer"] = {
  cmd = { "rust-analyzer" },
  filetypes = { "rust" },
  root_markers = { "Cargo.toml" },
  settings = {
    ["rust-analyzer"] = {
      checkOnSave = { command = "clippy" },
    },
  },
}

-- JSON with schemas
vim.lsp.config["jsonls"] = {
  cmd = { "vscode-json-language-server", "--stdio" },
  filetypes = { "json", "jsonc" },
  root_markers = { ".git" },
  settings = {
    json = {
      validate = { enable = true },
    },
  },
}

Built-in Completion

NeoVim has a built-in LSP completion source. Enable it per-client:

vim.api.nvim_create_autocmd("LspAttach", {
  callback = function(args)
    local client = vim.lsp.get_client_by_id(args.data.client_id)
    if client and client:supports_method("textDocument/completion") then
      vim.lsp.completion.enable(true, client.id, args.buf, {
        autotrigger = true,  -- Trigger on typing (not just manual)
      })
    end
  end,
})

With autotrigger enabled, completions appear as you type. Use <C-y> to confirm, <C-e> to dismiss.

Tip: The built-in completion is functional but minimal. For a richer experience (snippet expansion, multiple sources, fuzzy matching), use nvim-cmp or blink.cmp.

Default Behaviors (Free Without Config)

NeoVim sets these automatically when an LSP client attaches:

FeatureWhat happens
omnifuncSet to vim.lsp.omnifunc (trigger with <C-x><C-o>)
tagfuncSet to vim.lsp.tagfunc (<C-]> uses LSP)
formatexprSet to vim.lsp.formatexpr() (gq uses LSP)
KMapped to vim.lsp.buf.hover()
Document colorsHighlighted automatically if server supports it

Default LSP Keymaps (0.11+)

These ship with NeoVim, no config needed:

KeymapAction
grnvim.lsp.buf.rename()
gravim.lsp.buf.code_action()
grrvim.lsp.buf.references()
grivim.lsp.buf.implementation()
grtvim.lsp.buf.type_definition()
grxvim.lsp.codelens.run()
gOvim.lsp.buf.document_symbol()
<C-S>vim.lsp.buf.signature_help() (insert mode)

Gotcha: These default keymaps only activate when an LSP client is attached. If grn doesn’t work, check :LspInfo (or :checkhealth lsp) to verify a client is running.

Diagnostics

Diagnostics come from LSP and display as virtual text, signs, and underlines. Configure them globally:

vim.diagnostic.config({
  virtual_text = { spacing = 2 },
  signs = {
    text = {
      [vim.diagnostic.severity.ERROR] = "E",
      [vim.diagnostic.severity.WARN] = "W",
      [vim.diagnostic.severity.INFO] = "I",
      [vim.diagnostic.severity.HINT] = "H",
    },
  },
  float = { border = "rounded", source = true },
  severity_sort = true,
})

Navigate diagnostics with built-in keymaps:

KeymapAction
]dNext diagnostic
[dPrevious diagnostic
]DLast diagnostic
[DFirst diagnostic
<C-W>dOpen diagnostic float

The Old Way (nvim-lspconfig)

Before 0.11, the community plugin nvim-lspconfig was required. It still works and has configs for 100+ servers:

-- Old style (still works, not needed for 0.11+)
require("lspconfig").lua_ls.setup({
  settings = {
    Lua = {
      runtime = { version = "LuaJIT" },
    },
  },
})

For 0.11+, vim.lsp.config + vim.lsp.enable replaces nvim-lspconfig for most use cases. The plugin is still useful if you want its pre-built configs without writing them yourself.