How to Set Up LSP Configuration in Neovim: A Complete Guide Using jdhao/nvim-config
Configure LSP in Neovim by installing nvim-lspconfig via lazy.nvim, defining global capabilities in lua/lsp_utils.lua, setting up buffer-local keymaps in lua/config/lsp.lua, and placing server-specific overrides in the after/lsp/ directory.
This guide examines how the jdhao/nvim-config repository implements a production-ready LSP configuration in Neovim. By combining lazy.nvim for plugin management with Neovim's native vim.lsp APIs, the setup achieves conditional server loading, custom keymaps, and language-specific optimizations without relying on external LSP installer plugins.
1. Installing the LSP Plugin with lazy.nvim
The foundation of the LSP configuration in Neovim begins in lua/plugin_specs.lua, where the repository declares neovim/nvim-lspconfig as a dependency. The plugin specification includes a config hook that loads the core LSP logic:
{
"neovim/nvim-lspconfig",
config = function()
require("config.lsp")
end,
},
When you run :Lazy sync, lazy.nvim clones the plugin into ~/.local/share/nvim/lazy and executes the config function, which sources lua/config/lsp.lua to initialize the LSP stack.
2. Defining Shared Client Capabilities
Before any language server attaches, the repository establishes global capabilities in lua/lsp_utils.lua. This module exports a get_default_capabilities function that enhances Neovim's base protocol with folding support:
local M = {}
M.get_default_capabilities = function()
local capabilities = vim.lsp.protocol.make_client_capabilities()
capabilities.textDocument.foldingRange = {
dynamicRegistration = false,
lineFoldingOnly = true,
}
return capabilities
end
return M
These capabilities are injected globally in lua/config/lsp.lua using Neovim 0.10+'s vim.lsp.config API:
local capabilities = require("lsp_utils").get_default_capabilities()
vim.lsp.config("*", { capabilities = capabilities, flags = { debounce_text_changes = 500 } })
The debounce_text_changes flag reduces unnecessary server notifications by waiting 500ms after text changes.
3. Configuring Buffer-Local LSP Behavior
The core LSP logic resides in lua/config/lsp.lua within an autocommand callback triggered when any LSP client attaches to a buffer (lines 3-92). This handler:
- Retrieves the active client via
vim.lsp.get_client_by_id - Creates buffer-local keymaps using
vim.keymap.setwithopts.buffer = bufnr - Implements a custom
gdhandler that deduplicates definition locations before populating the location list - Disables Ruff's hover provider when Pyright is active using
client.server_capabilities.hoverProvider = false
Key buffer-local mappings include gd (go to definition), K (hover documentation), and <space>rn (rename). The K mapping customizes the hover UI:
map("n", "K", function()
vim.lsp.buf.hover { border = "single", max_height = 20, max_width = 130 }
end)
4. Enabling Servers Conditionally
Rather than starting all servers unconditionally, lua/config/lsp.lua maintains an enabled_lsp_servers table mapping server names to their required executables:
local enabled_lsp_servers = {
pyright = "delance-langserver",
ruff = "ruff",
lua_ls = "lua-language-server",
vimls = "vim-language-server",
bashls = "bash-language-server",
yamlls = "yaml-language-server",
}
A validation loop checks for executable availability using a utils.executable helper. Only servers with existing binaries are enabled via vim.lsp.enable(server_name):
for name, exe in pairs(enabled_lsp_servers) do
if utils.executable(exe) then
vim.lsp.enable(name)
else
vim.notify(
string.format("Executable '%s' for server '%s' not found!", exe, name),
vim.log.levels.WARN,
{ title = "Nvim-config" })
end
end
5. Server-Specific Configuration Files
The repository leverages nvim-lspconfig's convention of loading files from after/lsp/{server_name}.lua for per-server overrides. These files return configuration tables automatically merged when vim.lsp.enable() runs.
Python (Pyright) – after/lsp/pyright.lua customizes the command, capabilities, and analysis settings:
return {
cmd = { "delance-langserver", "--stdio" },
settings = {
pyright = { disableOrganizeImports = true },
python = {
analysis = {
typeCheckingMode = "standard",
inlayHints = {
callArgumentNames = "partial",
functionReturnTypes = true,
},
},
},
},
capabilities = {
textDocument = {
hover = { contentFormat = { "plaintext" } },
},
},
}
Ruff – after/lsp/ruff.lua enables import organization:
return {
init_options = {
settings = {
organizeImports = true,
},
},
}
6. Putting It All Together
To replicate this LSP configuration in Neovim from scratch, create the following minimal init.lua that bootstraps lazy.nvim and loads the plugin spec:
-- Bootstrap lazy.nvim
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.uv.fs_stat(lazypath) then
vim.fn.system({ "git", "clone", "--filter=blob:none",
"https://github.com/folke/lazy.nvim.git", "--branch=stable", lazypath })
end
vim.opt.rtp:prepend(lazypath)
-- Load plugins
require("lazy").setup({
spec = {
{ "neovim/nvim-lspconfig", config = function() require("config.lsp") end },
},
})
Place the lsp_utils.lua, config/lsp.lua, and after/lsp/ files from the jdhao/nvim-config repository in your ~/.config/nvim/lua/ directory. Run :Lazy sync, restart Neovim, and open a file matching one of the enabled servers. The LSP client will attach automatically if the executable exists, activating the buffer-local keymaps and server-specific settings.
Summary
- Install nvim-lspconfig via lazy.nvim in
lua/plugin_specs.luaand defer initialization toconfig.lsp. - Define global capabilities in
lua/lsp_utils.luato enable folding and consistent client behavior across all servers. - Set up buffer-local keymaps inside the
LspAttachautocommand inlua/config/lsp.luato ensure mappings exist only when LSP is active. - Enable servers conditionally by checking executable availability with
utils.executablebefore callingvim.lsp.enable(). - Override per-server settings by placing
{server_name}.luafiles in theafter/lsp/directory, following nvim-lspconfig's conventions.
Frequently Asked Questions
Why does the configuration disable Ruff's hover provider?
The jdhao/nvim-config repository disables Ruff's hoverProvider by setting client.server_capabilities.hoverProvider = false when both Ruff and Pyright are active. This prevents duplicate hover information, ensuring that Pyright—which provides richer type information—handles all hover requests while Ruff focuses on linting and import organization.
What is the purpose of the after/lsp/ directory in Neovim LSP configuration?
The after/lsp/ directory follows nvim-lspconfig's loading convention, automatically sourcing any {server_name}.lua files found there when vim.lsp.enable() activates that server. This separation keeps server-specific logic isolated from the core LSP setup, making it easier to maintain language-specific settings like Pyright's inlay hints or Ruff's import sorting without modifying the global configuration.
How do I add a new language server to this configuration?
To add a new server, first install the corresponding language server binary on your system. Then add an entry to the enabled_lsp_servers table in lua/config/lsp.lua with the server name and executable command. If the server requires custom settings, create a new file at after/lsp/{server_name}.lua returning a configuration table with your desired cmd, settings, and capabilities overrides.
What does the debounce_text_changes flag do in vim.lsp.config?
The debounce_text_changes = 500 setting in lua/config/lsp.lua tells the Neovim LSP client to wait 500 milliseconds after you stop typing before sending text synchronization notifications to the server. This reduces CPU load and prevents excessive diagnostic updates while editing, improving performance when working with large files or slower language servers.
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →