In this episode we connect Neovim to the Language Server Protocol (LSP) to get IDE features like go-to-definition, symbol rename, and code actions, plus manage language servers visually with mason.nvim.

After building grammar-based code understanding with nvim-treesitter in episode 14 — accurate syntax highlighting, code folding, and textobjects — in this episode we jump to a higher level: the editor's ability to truly understand the semantics of your code. We will connect Neovim to the Language Server Protocol (LSP) and manage all language servers with mason.nvim.
Why is this important in the real world? Imagine you are refactoring on a backend team: renaming the method getUserById to findUser. Without LSP, you would have to find all the call sites manually — risking missing a file, or — worse — missing one call site that introduces a runtime bug only discovered in production. With LSP, one semantics-aware rename command replaces all references across the entire project, including files that are not currently open. This is the main differentiator between a "text editor" and an "IDE", and now you can have it without leaving Neovim.
LSP solves a huge problem that has plagued the editor world for years: every language has its own tooling (compilers, linters, type checkers), and every editor must integrate with each tool individually. The result is massive duplication of effort.
LSP standardizes all of this with a client-server architecture:
┌─────────────────┐ ┌─────────────────┐
│ Neovim Client │ JSON-RPC │ Language Server │
│ (nvim-lspconfig) ◄────────► (tsserver, gopls, │
│ ─────────────── │ over │ pyright, ...) │
│ • gd, K, gr │ stdio │ • parse project │
│ • diagnostics │ WebSocket│ • type check │
│ • completion │ │ • indexing │
└─────────────────┘ └─────────────────┘An explanation of each component:
gopls for Go, tsserver for TypeScript, pyright for Python.vim.lsp API + the nvim-lspconfig plugin that handles the wiring.textDocument/definition (the client asks "where is the definition of this symbol?") and textDocument/publishDiagnostics (the server reports "there is an error on line 12").The key concept: the server does not care which editor is used. The same server serves VS Code, Neovim, Emacs, and others. Once a language server is written, all editors can use it. This is why Microsoft — the creator of LSP in 2016 — called it "the protocol of the future": you no longer depend on a particular team to support your editor.
Note
One important mental model: the language server runs as a separate process that stays alive while Neovim is active. It stores the project index in its own memory. That is why gd responses feel instant even on large projects — the search is not done per-file, but through the database the server has already built. The trade-off: the server consumes its own RAM; it is normal for Neovim with 3-4 active servers to use a few hundred MB of extra RAM.
LSP integration in modern Neovim is built from three plugin layers working in sequence:
| Plugin | Role | Analogy |
|---|---|---|
mason.nvim | Install & manage server binaries | App Store (Play Store / npm install) |
mason-lspconfig.nvim | Bridge between Mason & LSP config | Adapter / installer that connects the app to the system |
nvim-lspconfig | Configuration for each server + default settings | The driver that knows how to "operate" each server |
Mason is not part of LSP itself — it is a package manager for editor tools: language servers, linters, and formatters. We will use it again in episode 17 for linters & formatters.
return {
{
"williamboman/mason.nvim",
cmd = { "Mason", "MasonInstall", "MasonUninstall", "MasonUpdate" },
config = function()
require("mason").setup({
ui = {
border = "rounded",
icons = {
package_installed = "✓",
package_pending = "➜",
package_uninstalled = "✗",
},
},
})
end,
},
}Why cmd = { "Mason", ... }? This is command-based lazy-loading as discussed in episode 9 — the Mason plugin is only loaded when you actually open its UI (:Mason) or run its install commands. Since Mason is rarely used every minute, burdening startup time just for it makes no sense.
Now run :Mason inside Neovim. You will see an interactive UI displaying hundreds of packages. Use / to search, then press i to install the highlighted package, u to uninstall, and X to update. Think of it like a marketplace: all language servers, linters, and formatters in one place, installed to the ~/.local/share/nvim/mason directory automatically without the hassle of configuring PATH.
After Mason manages the binaries, we need to tell nvim-lspconfig which server to run and with what configuration. Here is the complete configuration:
return {
{
"williamboman/mason-lspconfig.nvim",
dependencies = { "williamboman/mason.nvim" },
opts = {
ensure_installed = {
"lua_ls", -- Lua
"tsserver", -- TypeScript / JavaScript
"pyright", -- Python
"gopls", -- Go
"bashls", -- Bash / Shell
"yamlls", -- YAML
"jsonls", -- JSON
"dockerls", -- Dockerfile
},
automatic_enable = true,
},
},
{
"neovim/nvim-lspconfig",
dependencies = {
"williamboman/mason.nvim",
"williamboman/mason-lspconfig.nvim",
},
config = function()
local capabilities = vim.lsp.protocol.make_client_capabilities()
-- Foundation untuk nvim-cmp di episode 16
capabilities.textDocument.completion.completionItem.snippetSupport = true
local lspconfig = require("lspconfig")
local on_attach = function(_, bufnr)
local opts = { buffer = bufnr, silent = true }
vim.keymap.set("n", "gd", vim.lsp.buf.definition, opts)
vim.keymap.set("n", "gr", vim.lsp.buf.references, opts)
vim.keymap.set("n", "K", vim.lsp.buf.hover, opts)
vim.keymap.set("n", "<leader>rn", vim.lsp.buf.rename, opts)
vim.keymap.set("n", "<leader>ca", vim.lsp.buf.code_action, opts)
vim.keymap.set("n", "<leader>e", vim.diagnostic.open_float, opts)
vim.keymap.set("n", "[d", vim.diagnostic.goto_prev, opts)
vim.keymap.set("n", "]d", vim.diagnostic.goto_next, opts)
end
require("mason-lspconfig").setup({
handlers = {
function(server)
lspconfig[server].setup({
capabilities = capabilities,
on_attach = on_attach,
})
end,
},
})
end,
},
}Let's dissect the important parts:
mason-lspconfig.opts.ensure_installed — the list of servers automatically installed by Mason when the config first loads. Similar to ensure_installed in treesitter.automatic_enable = true — installed servers are automatically enabled for the appropriate filetypes.handlers — the function called for every detected server. This is the DRY pattern: a single setup() with the same capabilities and on_attach applies to all servers, without writing a block per server. If a server needs special configuration (e.g. lua_ls with settings), you can override it with a specifically-named handler.on_attach — the function called every time a server attaches to a buffer. This is where all LSP keymaps are defined, with buffer = bufnr so they only apply to the active buffer.Tip
A debugging trick you must memorize: :LspInfo to see which servers are active in the current buffer along with their status, and :LspLog to open the log file that can show why a server failed to attach. The combination of the two solves 90% of LSP problems.
Now let's define the LSP keymaps that are standard in almost all modern Neovim distributions (LazyVim, NvChad, and others use the same pattern):
| Shortcut | Action | Neovim Command | Function |
|---|---|---|---|
gd | Go to definition | vim.lsp.buf.definition | Jump to the declaration of the symbol under the cursor |
gr | References | vim.lsp.buf.references | Show all references of the symbol in the project |
K | Hover | vim.lsp.buf.hover | Show documentation + type signature |
<leader>rn | Rename | vim.lsp.buf.rename | Rename the symbol across the entire project at once |
<leader>ca | Code action | vim.lsp.buf.code_action | Show contextual actions (quick fix, refactor, import) |
<leader>e | Diagnostics popup | vim.diagnostic.open_float | Show error/warning details on the cursor line |
[d / ]d | Prev/Next diagnostic | vim.diagnostic.goto_prev | Jump between errors in the buffer |
A quick real-world usage guide:
gd — you immediately jump to its definition. Press Ctrl+o to return to your previous position (the jump list, episode 3).K on a function — a documentation window appears with the signature and docstring, without opening a browser.<leader>rn, type the new name, press Enter — all references across the project change, even in files not open. This is what makes refactoring in Neovim as comfortable as in a GUI IDE.<leader>ca when there is a red line — a list of quick fixes appears like "import this symbol" or "auto-fix typo".Now let's string all the LSP keymaps into one realistic workflow — writing a new function in a TypeScript project:
1. Ketik nama fungsi baru → muncul warning "x is declared but never used" (diagnostic).
2. Tekan K pada sebuah tipe → dokumentasi & type signature muncul, tanpa buka browser.
3. Saat memanggil fungsi dari library → gd untuk lompat ke definisinya, Ctrl+o kembali.
4. Error muncul di baris 12 → ]d lompat ke sana, <leader>e lihat detail lengkap.
5. Ingin ubah nama variabel di seluruh project → <leader>rn, ketik baru, Enter.
6. Mau auto-import symbol → letakkan kursor pada nama, <leader>ca, pilih "Add import".Notice the flow: writing, verifying, refactoring, and fixing all happen in one editor, without leaving the buffer and without switching context. This is what the editor understands code means — not merely coloring text.
Tip
There is one bonus keymap often forgotten: signature help. With Neovim defaults, press <C-x><C-o> in insert mode to trigger LSP completion manually, and add vim.keymap.set("n", "K", vim.lsp.buf.hover, ...) — we already set that up. For function signatures while typing arguments, many developers add a <C-k> keymap for vim.lsp.buf.signature_help. But remember: once nvim-cmp is active in episode 16, signature help and LSP completion will merge into a single popup.
The following three servers are the most commonly used by production teams. Notice how each has specific requirements:
require("lspconfig").tsserver.setup({
capabilities = capabilities,
on_attach = on_attach,
root_dir = require("lspconfig").util.root_pattern("package.json", "tsconfig.json", ".git"),
single_file_support = false,
})Key points per language:
root_dir based on package.json/tsconfig.json — the server only lives if the project has one of them. single_file_support = false prevents standalone .ts files (without a project) from triggering the server, because without a tsconfig type-checking would be half-hearted.settings.python.analysis to control how aggressive its type checking is. basic is a good middle ground between accuracy and noise.staticcheck — a static analysis engine that catches bugs the compiler does not detect. Always enable it for Go codebases.| Mistake | Symptom | Solution |
|---|---|---|
| Server not installed | Error client X not available or spawn ... failed | Install via :MasonInstall <server>; make sure the server name in ensure_installed is correct |
| Server attached but features dead | gd/gr do nothing | Check :LspInfo; make sure capabilities & on_attach are passed through the handler |
| Completion does not appear | LSP autocomplete popup is empty | This feature needs nvim-cmp (episode 16); in this episode capabilities has been prepared |
| Diagnostics only appear after saving | Errors do not appear in real-time while typing | Normal — many servers publish diagnostics when the file changes; make sure nothing overrides it |
pyright vs basedpyright confusion | Package not found in Mason | In Mason the package name is basedpyright for the fork version; pyright is the official one |
Forgot snippetSupport | Snippets from LSP never appear | Make sure capabilities.textDocument.completion.completionItem.snippetSupport = true (required before nvim-cmp) |
lua_ls does not recognize config | vim.opt and vim.api show up as undefined | lua_ls needs the Lua.workspace.library setting or diagnostics.globals to recognize Neovim's API |
Warning
The most confusing mistake for beginners: pressing gd and nothing happens even though the server is active. The most common cause, after the server not being installed, is LSP keymaps defined outside on_attach — a global keymap without buffer = bufnr will be overwritten by the active per-buffer keymap. Always define LSP keymaps inside on_attach, or make sure to use buffer in the global keymap.
In episode 15 we understood the LSP client-server architecture and why it has become the universal standard that solves editor tooling fragmentation. We built a complete pipeline: mason.nvim as the server package manager, mason-lspconfig.nvim as the bridge, and nvim-lspconfig as the configurator — plus the standard LSP keymaps (gd, gr, K, <leader>rn, <leader>ca, <leader>e) and specific configurations for TypeScript, Python, and Go.
You now have a Neovim that understands code semantically, on par with commercial IDEs, yet running in a terminal. But there is still one gap you can feel: while typing, there is no autocompletion popup showing suggestions from this language server.
In episode 16, we will fill that gap with the Autocompletion Engine using nvim-cmp & Snippets — a completion popup that merges LSP suggestions, buffer, path, and snippets into one agile interface. Stay motivated!