A focused guide to configuring Neovim’s built-in LSP client in Neovim 0.12
Neovim 0.12 continues the native LSP direction introduced in 0.11:
You define or extend server configs with
vim.lsp.config().You activate them with
vim.lsp.enable().You inspect health with
:checkhealth vim.lsp.You manage enabled configs with built-in
:lspcommands such as:lsp enable,:lsp disable, and:lsp restart.
This matters because native LSP configuration is now straightforward enough that you do not need an elaborate wrapper just to get language servers running.
There are three separate concerns:
Neovim LSP client
Talks to language servers.
Handles keymaps, diagnostics, hover, rename, code actions, etc.
LSP server definitions
Per-server configs such as
lua_ls,gopls,ts_ls,pyright, etc.These can come from your own config, or from the
nvim-lspconfigplugin.
Server installation
Actually getting the server binaries onto your machine.
Neovim does not install servers by itself.
Mason helps with this part.
A common source of confusion is mixing up configuration with installation.
If you want lots of ready-made server definitions, install neovim/nvim-lspconfig. In the Neovim 0.11+ model, its job is mainly to provide the lsp/ config files that Neovim can consume natively. You then use vim.lsp.enable() yourself instead of the old require('lspconfig').XYZ.setup(...) pattern.
So the modern split is:
Neovim built-in LSP = runtime and activation
nvim-lspconfig = catalog of server definitions
Mason = installs tools
mason-lspconfig = bridges Mason package names and LSP config names, and can auto-enable installed servers
This is the smallest practical setup if the language servers are already installed on your machine and discoverable in $PATH.
-- init.lua or lua/config/lsp.lua
-- Example: extend the built-in / lspconfig-provided config for lua_ls
vim.lsp.config('lua_ls', {
settings = {
Lua = {
runtime = { version = 'LuaJIT' },
diagnostics = {
globals = { 'vim' },
},
workspace = {
checkThirdParty = false,
library = {
vim.env.VIMRUNTIME,
},
},
telemetry = { enable = false },
},
},
})
-- Enable one or more servers
vim.lsp.enable('lua_ls')
vim.lsp.enable({ 'gopls', 'jsonls' })vim.lsp.config('lua_ls', {...})modifies or defines a config.vim.lsp.enable('lua_ls')tells Neovim to start or attach that server for matching buffers.If a server depends on root detection, the project needs the expected root markers such as
.git,go.mod,package.json, and so on.
Neovim can load LSP configs from multiple places.
vim.lsp.config('gopls', {
settings = {
gopls = {
staticcheck = true,
gofumpt = true,
},
},
})
vim.lsp.enable('gopls')You can place a config file at:
~/.config/nvim/lsp/foo.luaExample:
-- ~/.config/nvim/lsp/foo.lua
return {
cmd = { 'true' },
filetypes = { 'text' },
root_markers = { '.git' },
}Then enable it:
vim.lsp.enable('foo')If you install nvim-lspconfig, its server definitions are loaded from lsp/ directories on runtimepath. You can override them either:
with
vim.lsp.config('server_name', {...}), orby adding files under
after/lsp/
Example:
vim.lsp.config('rust_analyzer', {
settings = {
['rust-analyzer'] = {
cargo = { allFeatures = true },
checkOnSave = true,
},
},
})A clean structure is enough. You do not need a huge framework.
~/.config/nvim/
├── init.lua
└── lua/
└── config/
├── lsp.lua
└── autocmds.luaExample:
-- init.lua
require('config.lsp')-- lua/config/lsp.lua
vim.lsp.config('lua_ls', {
settings = {
Lua = {
diagnostics = { globals = { 'vim' } },
},
},
})
vim.lsp.enable({ 'lua_ls', 'gopls', 'jsonls' })Neovim ships LSP defaults, but most people still want buffer-local mappings on attach.
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local bufnr = args.buf
local map = function(mode, lhs, rhs, desc)
vim.keymap.set(mode, lhs, rhs, { buffer = bufnr, desc = desc })
end
map('n', 'K', vim.lsp.buf.hover, 'LSP Hover')
map('n', 'gd', vim.lsp.buf.definition, 'Go to definition')
map('n', 'gD', vim.lsp.buf.declaration, 'Go to declaration')
map('n', 'gi', vim.lsp.buf.implementation, 'Go to implementation')
map('n', 'gr', vim.lsp.buf.references, 'References')
map('n', '<leader>rn', vim.lsp.buf.rename, 'Rename symbol')
map({ 'n', 'v' }, '<leader>ca', vim.lsp.buf.code_action, 'Code action')
map('n', '<leader>f', function()
vim.lsp.buf.format({ async = true })
end, 'Format buffer')
end,
})A solid baseline:
vim.diagnostic.config({
severity_sort = true,
update_in_insert = false,
float = {
border = 'rounded',
source = 'if_many',
},
underline = true,
virtual_text = {
spacing = 2,
source = 'if_many',
prefix = '●',
},
signs = {
text = {
[vim.diagnostic.severity.ERROR] = 'E',
[vim.diagnostic.severity.WARN] = 'W',
[vim.diagnostic.severity.INFO] = 'I',
[vim.diagnostic.severity.HINT] = 'H',
},
},
})Neovim 0.12 has native completion improvements, but LSP completion behavior and UI are still often paired with a completion plugin such as nvim-cmp or alternatives. If you want pure built-in LSP only, you can still run LSP without any completion plugin.
LSP does not require Mason.
LSP also does not require nvim-cmp.
vim.lsp.config('lua_ls', {
settings = {
Lua = {
diagnostics = { globals = { 'vim' } },
workspace = {
checkThirdParty = false,
library = {
vim.env.VIMRUNTIME,
'${3rd}/luv/library',
},
},
telemetry = { enable = false },
},
},
})
vim.lsp.enable('lua_ls')vim.lsp.config('gopls', {
settings = {
gopls = {
staticcheck = true,
gofumpt = true,
usePlaceholders = true,
},
},
})
vim.lsp.enable('gopls')vim.lsp.config('ts_ls', {
settings = {
typescript = {
inlayHints = {
includeInlayParameterNameHints = 'all',
includeInlayFunctionParameterTypeHints = true,
includeInlayVariableTypeHints = true,
},
},
javascript = {
inlayHints = {
includeInlayParameterNameHints = 'all',
includeInlayFunctionParameterTypeHints = true,
includeInlayVariableTypeHints = true,
},
},
},
})
vim.lsp.enable('ts_ls')
Start here, in this order:
:checkhealth vim.lsp:lsp:set filetype?If the buffer filetype is wrong, the server may never attach.
Many servers need a project root. If you open a random file outside a recognizable workspace, the server may not start.
If you are not using Mason, make sure the binary is in your shell PATH.
Examples:
which gopls
which lua-language-server
which typescript-language-server
vim.lsp.log.set_level('trace')Then inspect the log:
vim.cmd('tabnew ' .. vim.lsp.log.get_filename())Now the practical setup most people actually want.
Installs and manages external tools: LSP servers, formatters, linters, DAP adapters.
Ensures a list of tools is installed automatically. Good for reproducible setups across machines.
Bridges Mason with LSP config names. It can:
translate names like
lua_ls↔lua-language-serverexpose convenience commands like
:LspInstallautomatically enable installed LSP servers using
vim.lsp.enable()
Still important. It provides the ready-made LSP config definitions that native Neovim can consume.
For a modern Neovim 0.12 setup, this combination is the most practical:
mason-org/mason.nvimmason-org/mason-lspconfig.nvimWhoIsSethDaniel/mason-tool-installer.nvimneovim/nvim-lspconfig
Even though the runtime is native, nvim-lspconfig remains useful because it ships the server configs.
Use this order:
mason.nvimnvim-lspconfigmason-lspconfig.nvimmason-tool-installer.nvimyour
vim.lsp.config(...)overridesvim.lsp.enable(...)if you are enabling manually
Important points:
mason.nvimshould be set up early, not lazily deferred.mason-lspconfig.nvimexpects both Mason andnvim-lspconfigto already be available.
{
'mason-org/mason.nvim',
opts = {},
},
{
'neovim/nvim-lspconfig',
},
{
'mason-org/mason-lspconfig.nvim',
dependencies = {
'mason-org/mason.nvim',
'neovim/nvim-lspconfig',
},
opts = {},
},
{
'WhoIsSethDaniel/mason-tool-installer.nvim',
dependencies = { 'mason-org/mason.nvim' },
opts = {
ensure_installed = {
'lua_ls',
'gopls',
'ts_ls',
'jsonls',
'stylua',
'prettier',
'goimports',
},
},
}That plugin spec is only half the story. You still need LSP config and activation.
This is the cleanest setup if you want full control.
require('mason').setup()require('mason-tool-installer').setup({
ensure_installed = {
'lua_ls',
'gopls',
'ts_ls',
'jsonls',
'stylua',
'prettier',
},
})Because mason-lspconfig is present, mason-tool-installer can accept LSP config names like lua_ls and gopls, not only raw Mason package names.
require('mason-lspconfig').setup({
automatic_enable = false,
})This disables auto-enabling so you stay in charge.
vim.lsp.config('lua_ls', {
settings = {
Lua = {
diagnostics = { globals = { 'vim' } },
workspace = { checkThirdParty = false },
telemetry = { enable = false },
},
},
})
vim.lsp.config('gopls', {
settings = {
gopls = {
staticcheck = true,
gofumpt = true,
},
},
})vim.lsp.enable({
'lua_ls',
'gopls',
'ts_ls',
'jsonls',
})predictable
explicit
easy to reason about
avoids surprise attachments
best if you want different behavior per machine or per project
This is the lower-friction setup.
require('mason').setup()
require('mason-tool-installer').setup({
ensure_installed = {
'lua_ls',
'gopls',
'ts_ls',
'jsonls',
'stylua',
'prettier',
},
})Define server-specific config before auto-enable is expected to attach in real use.
vim.lsp.config('lua_ls', {
settings = {
Lua = {
diagnostics = { globals = { 'vim' } },
workspace = { checkThirdParty = false },
telemetry = { enable = false },
},
},
})
vim.lsp.config('gopls', {
settings = {
gopls = {
staticcheck = true,
gofumpt = true,
},
},
})require('mason-lspconfig').setup({
automatic_enable = true,
})With this setup, installed servers are automatically enabled via vim.lsp.enable().
fewer lines
easier initial setup
convenient for general-purpose configs
less explicit
can be harder to debug when you want selective behavior
This is usually the best compromise.
require('mason-lspconfig').setup({
automatic_enable = {
exclude = {
'rust_analyzer',
'ts_ls',
},
},
})Then enable excluded servers manually when you want special handling.
vim.lsp.config('ts_ls', {
settings = {
typescript = {
inlayHints = {
includeInlayParameterNameHints = 'all',
},
},
},
})
vim.lsp.enable('ts_ls')This pattern is useful for servers that often need custom root logic, custom formatting policy, or conflict handling.
This is a complete practical example for Neovim 0.12.
-- lua/config/lsp.lua
require('mason').setup()
require('mason-tool-installer').setup({
ensure_installed = {
'lua_ls',
'gopls',
'ts_ls',
'jsonls',
'bashls',
'stylua',
'prettier',
'shfmt',
},
})
vim.lsp.config('lua_ls', {
settings = {
Lua = {
runtime = { version = 'LuaJIT' },
diagnostics = { globals = { 'vim' } },
workspace = {
checkThirdParty = false,
library = {
vim.env.VIMRUNTIME,
},
},
telemetry = { enable = false },
},
},
})
vim.lsp.config('gopls', {
settings = {
gopls = {
staticcheck = true,
gofumpt = true,
usePlaceholders = true,
},
},
})
vim.lsp.config('ts_ls', {
settings = {
typescript = {
inlayHints = {
includeInlayParameterNameHints = 'all',
includeInlayFunctionParameterTypeHints = true,
includeInlayVariableTypeHints = true,
},
},
javascript = {
inlayHints = {
includeInlayParameterNameHints = 'all',
includeInlayFunctionParameterTypeHints = true,
includeInlayVariableTypeHints = true,
},
},
},
})
require('mason-lspconfig').setup({
automatic_enable = {
exclude = { 'ts_ls' },
},
})
vim.lsp.enable('ts_ls')
vim.diagnostic.config({
severity_sort = true,
update_in_insert = false,
float = {
border = 'rounded',
source = 'if_many',
},
underline = true,
virtual_text = {
spacing = 2,
source = 'if_many',
prefix = '●',
},
signs = {
text = {
[vim.diagnostic.severity.ERROR] = 'E',
[vim.diagnostic.severity.WARN] = 'W',
[vim.diagnostic.severity.INFO] = 'I',
[vim.diagnostic.severity.HINT] = 'H',
},
},
})
vim.api.nvim_create_autocmd('LspAttach', {
callback = function(args)
local bufnr = args.buf
local map = function(mode, lhs, rhs, desc)
vim.keymap.set(mode, lhs, rhs, { buffer = bufnr, desc = desc })
end
map('n', 'K', vim.lsp.buf.hover, 'LSP Hover')
map('n', 'gd', vim.lsp.buf.definition, 'Go to definition')
map('n', 'gD', vim.lsp.buf.declaration, 'Go to declaration')
map('n', 'gi', vim.lsp.buf.implementation, 'Go to implementation')
map('n', 'gr', vim.lsp.buf.references, 'References')
map('n', '<leader>rn', vim.lsp.buf.rename, 'Rename symbol')
map({ 'n', 'v' }, '<leader>ca', vim.lsp.buf.code_action, 'Code action')
map('n', '<leader>f', function()
vim.lsp.buf.format({ async = true })
end, 'Format buffer')
end,
})This is the cleanest separation.
Mason manages binaries.
vim.lsp.config()defines behavior.vim.lsp.enable()activates servers.
Have a single file such as:
lua/config/lsp.luaor split by language if it gets large.
Servers like TypeScript, Rust, or anything that may compete on formatting or needs custom root logic are often easier to manage explicitly.
That is the first debugging step now, not old habits built around legacy commands.
Examples:
LSP config name:
lua_lsMason package name:
lua-language-server
mason-lspconfig exists largely to bridge this mismatch.
Even if you are “doing native LSP,” nvim-lspconfig is still the easiest way to get sane server definitions without writing every config from scratch.
It does not. Native LSP config is not an installer.
That older style still appears in many guides, but for 0.12 the cleaner path is:
vim.lsp.config()vim.lsp.enable()
If you rely on definitions from nvim-lspconfig, make sure that the plugin is loaded before trying to enable a config.
If a server is not attaching, isolate the problem:
is the binary installed?
is the filetype correct?
is root detection working?
is the config enabled?
Some LSP servers’ format. Some teams prefer dedicated formatters. Decide intentionally whether vim.lsp.buf.format() should use the server, an external formatter, or a filtered client list.
If you want the most practical answer:
install
nvim-lspconfiginstall
mason.nviminstall
mason-tool-installer.nviminstall
mason-lspconfig.nvimUse Mason to install tools
Use
vim.lsp.config()for overridesUse either:
automatic_enable = falseandvim.lsp.enable(...)manually, orautomatic_enable = { exclude = { ... } }for a hybrid approach
That gives you:
native Neovim 0.12 LSP
reproducible installs
minimal boilerplate
explicit control where it matters
Neovim LSP help:
:help lsp,:help vim.lsp.config,:help vim.lsp.enableNeovim 0.12 news:
:help news-0.12neovim/nvim-lspconfigREADME andlsp/configsmason-org/mason.nvimREADMEmason-org/mason-lspconfig.nvimREADMEWhoIsSethDaniel/mason-tool-installer.nvimREADME

Comments
Nothing yet. Say the first thing.
Sign in to join the conversation.