No description
Find a file
2025-09-25 20:01:47 +00:00
README.md Update README.md 2025-09-25 20:01:47 +00:00

Neovim: LSP integration demystified | lspconfig → vim.lsp

The Problem

We get breaking changes with nvim-lspconfig commit 1f7fbc3

Effect: When you have something like this in your lspconfig.lua...

-- lua/configs/lspconfig.lua
local lspconfig = require "lspconfig"
lspconfig.html.setup {}

You see this when starting nvim

The require('lspconfig') "framework" is deprecated, use vim.lsp.config (see :help lspconfig-nvim-0.11) instead. Feature will be removed in nvim-lspconfig v3.0.0

To make this go away we use vim.lsp now.

Understanding LSP integration

NeoVim / lua doesn't come with any LSP magic on it's own. All it does is to integrate existing Language Servers which are provided as some sort of executable that lives somewhere on your system.

What do we need to get LSP integration in NeoVim?

  • A programming language we want to code in

  • Executable that implements the Language Server Protocol for our programming language

  • lua code to integrate that executable into neovim

LSP Integration Ingredients

Executable

Typically you would install your language server executable with :Mason

Lua integration

After installing your LSP, you would configure it with vim.lsp like so

-- vim.lsp configuration anatomy

vim.lsp.config("ls-name", {
  filetypes = { "extension" },
})

vim.lsp has default configs for certain known language servers baked into it, those defaults can be found here.

Caution

  • Some LSPs are standalone executables (e.g. rust-analyzer, roslyn)

  • Others are libraries/scripts that need a runtime to launch them (e.g. Bicep via dotnet, TypeScript via node, Lua via lua)

LSP Integration Example: bicep-lsp

"Bicep is a Domain Specific Language (DSL) for deploying Azure resources declaratively", the bicep-lsp is written in C#

bicep-lsp currently needs dotnet 8 installed on your system. In arch you can install it with sudo pacman -S dotnet-sdk-8.0

Executable

Install the bicep-lsp with

:MasonInstall bicep-lsp

Lua integration

After installing bicep-lsp, you would configure it with vim.lsp, ADD those lines to your lspconfig.lua

-- lua/configs/lspconfig.lua
-- ...

vim.filetype.add({ extension = { ramboefile = "bicep" } }) -- DEMO map .ramboefile to .bicep so it triggerd the bicep-lsp

local bicep_mason_path = vim.fn.stdpath("data") ..
    "/mason/packages/bicep-lsp/extension/bicepLanguageServer/Bicep.LangServer.dll"

vim.lsp.config("bicep", {
  cmd = { "dotnet", bicep_mason_path },
  filetypes = { "bicep" },
})

vim.lsp.enable("bicep")

Explanation

  • Servers like bicep-lsp, need extra configuration.

  • In the case of bicep this is so, because it is not standalone but it is a DLL that depends on the dotnet runtime and this DLL can reside anywhere in our file system.

  • We have to pass in it's path explicitely, vim.lsp can't know the path and therefore all the extra configuration.

  • If it was standalone then it would just be made available through the mason PATH and we could simply enable it like other lsps

Typically the full path to the bicep-lsp would look like something like this: dotnet "/home/ramboe/.local/share/nvim/mason/packages/bicep-lsp/extension/bicepLanguageServer/Bicep.LangServer.dll"

vim.filetype.add()

vim.filetype.add needs to be called if a given filetype is not supported

-- lua/configs/lspconfig.lua

vim.filetype.add({ extension = { ramboefile = "bicep" } }) -- map .ramboefile to .bicep so it triggerd the bicep-lsp

rest stays the same

-- lua/configs/lspconfig.lua

local bicep_mason_path = vim.fn.stdpath("data") ..
    "/mason/packages/bicep-lsp/extension/bicepLanguageServer/Bicep.LangServer.dll"

vim.lsp.config("bicep", {
  cmd = { "dotnet", bicep_mason_path },
  filetypes = { "bicep" },
})

vim.lsp.enable("bicep")

to check if a file type is already acknowledged, execute this in neovim:

:e $VIMRUNTIME/lua/vim/filetype.lua

and check if you can find your filetype.

Migrating to vim.lsp

NvChad's Sane Defaults

What we don't do anymore

In the old world, I would have to assign on_attach, on_init and capabilities manually like so

-- lua/configs/lspconfig.lua

-- load nvchad's sane defaults
local on_attach = require("nvchad.configs.lspconfig").on_attach
local on_init = require("nvchad.configs.lspconfig").on_init
local capabilities = require("nvchad.configs.lspconfig").capabilities

-- assign the sane defaults to each LSP individually
require("lspconfig").html.setup({
  on_attach = on_attach,
  on_init = on_init,
  capabilities = capabilities,
})

Explanation

  • on_init → global per-client setup (what this LSP can or cannot do).
  • on_attach → per-buffer setup (keymaps, formatting, behavior).
  • capabilities → feature negotiation (completion/snippets, etc.).

Together, they ensure every LSP server works consistently while still letting you customize per-client or per-buffer behavior.

What we do instead

In the new world require("nvchad.configs.lspconfig").defaults() does this automatically to every lsp that I use in my config.

Configuring your LSP

Once installed, most language servers can just be enabled with vim.lsp.enable(servers) and we are good. Example: vanilla nvchad lspconfig.lua:

-- lua/configs/lspconfig.lua
require("nvchad.configs.lspconfig").defaults()

local servers = { "html", "cssls" }

vim.lsp.enable(servers)

-- read :h vim.lsp.config for changing options of lsp servers

you can see the full migration of my own lspconfig.lua when you check the git history here from commit 9099d447e7