RSS Amplifier

.dotfiles · Apr 8, 2026

Native LSP in Neovim 0.12

0
Sign in to vote or save

Adib Hanna · .dotfiles

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 :lsp commands 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:

  1. Neovim LSP client

    • Talks to language servers.

    • Handles keymaps, diagnostics, hover, rename, code actions, etc.

  2. 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-lspconfig plugin.

  3. 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.lua

Example:

-- ~/.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', {...}), or

  • by 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.lua

Example:

-- 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_lslua-language-server

  • expose convenience commands like :LspInstall

  • automatically 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.nvim

  • mason-org/mason-lspconfig.nvim

  • WhoIsSethDaniel/mason-tool-installer.nvim

  • neovim/nvim-lspconfig

Even though the runtime is native, nvim-lspconfig remains useful because it ships the server configs.

Use this order:

  1. mason.nvim

  2. nvim-lspconfig

  3. mason-lspconfig.nvim

  4. mason-tool-installer.nvim

  5. your vim.lsp.config(...) overrides

  6. vim.lsp.enable(...) if you are enabling manually

Important points:

  • mason.nvim should be set up early, not lazily deferred.

  • mason-lspconfig.nvim expects both Mason and nvim-lspconfig to 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.lua

or 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_ls

  • Mason 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-lspconfig

  • install mason.nvim

  • install mason-tool-installer.nvim

  • install mason-lspconfig.nvim

  • Use Mason to install tools

  • Use vim.lsp.config() for overrides

  • Use either:

    • automatic_enable = false and vim.lsp.enable(...) manually, or

    • automatic_enable = { exclude = { ... } } for a hybrid approach

That gives you:

  • native Neovim 0.12 LSP

  • reproducible installs

  • minimal boilerplate

  • explicit control where it matters

  1. Neovim LSP help: :help lsp, :help vim.lsp.config, :help vim.lsp.enable

  2. Neovim 0.12 news: :help news-0.12

  3. neovim/nvim-lspconfig README and lsp/ configs

  4. mason-org/mason.nvim README

  5. mason-org/mason-lspconfig.nvim README

  6. WhoIsSethDaniel/mason-tool-installer.nvim README

Read the original on dotfiles.substack.com

Comments

Nothing yet. Say the first thing.

    Sign in to join the conversation.