The Language Server Protocol is a protocol which defines a standard way of how a code editor and a static analysis tool should communicate.
The basic premise of the protocol is that you have a client and a server. The client is your text editor (neovim in this case) and the server is some external process that communicates with a client over RPC. A client can start a server as an external process and the two communicate with messages. This allows a server to analyze the current file you're editing without having to care about how that information is represented. It's your editor's (client) job to interpret the response from the server and showing you the errors.
In the past every editor had to implement their own language specific support (and some still do),
which lead to an n * m
problem. You have n
editors and m
languages, so you need n * m
plugins for every language to be supported in every editor. With LSP you simply need every editor to
implement the client side of the protocol and every language to implement the server side; an
n + m
problem!
While this sounds great in theory, the protocol itself is lacking in some areas and a lot of languages just don't have great implementations (language servers), especially languages that are dominated by IDEs such as Java or C#. JetBrains especially being the lead in the IDE space has their own analysis tools built into their IDEs which are a lot more powerful than most LSP server implementations. Nonetheless, the idea behind the protocol is great and the languages which do have good support for it benefit greatly from it, as they have to invest a lot less work to be "supported" by a lot of different editors.
neovim is one of these editors which implement the client side of LSP. Specifically the vim.lsp
Lua module exposes all that functionality, as everything is implemented in Lua directly.
LSP describes this concept of "capabilities", which are parts of the spec which clients and servers may implement, but are not forced to. When establishing a connection, client and server will exchange capabilities and agree on the set of capabilities which are implemented by both. You can then make requests to a server using specific methods supported by those capabilities. There are a lot of these, but I want to list a few examples so you can have a mental model of how it works:
textDocument/definition
will find the location of the definition of a symbol (e.g. a variable)textDocument/references
will find all locations where a symbol is referencedtextDocument/hover
will give you certain information about a symbol you'd expect when hovering over it with a mouse (neovim implements this using Lua functions, of course, but remember that this protocol was made by the same people who made VSCode)textDocument/completion
will give semantic completion for a given piece of text
The list goes on, but I think you get the idea. All of these methods that are supported by neovim
are exposed as Lua functions, with sensible default behavior. These functions are called "handlers".
They all have the same signature and you can override them either globally, per server, or per
request, if you see the need to. For more information on handlers, see :help lsp-handler
. For
a list of all handlers, see :help lsp-handlers
.
So the way you are mostly going to interact with LSP is using functions in the vim.lsp
module,
mainly the vim.lsp.buf
module which holds functions that are relevant in the context of a buffer.
The functions that correspond to the LSP methods I listed earlier are the following:
vim.lsp.buf.definition()
vim.lsp.buf.references()
vim.lsp.buf.hover()
vim.lsp.buf.completion()
I think you see the pattern.
As of writing this the only function of these that is actually mapped to a key by default is
vim.lsp.buf.hover()
, which is mapped to K
in any buffer that has a language server attached to
it. So if you want to use LSP, you should define keymaps for most of these functions, as it takes
forever to type them out by hand :)
nvim-lspconfig has a bunch of example mappings in their README, if you can't come up with any.
Okay, understanding the high level is cool and all, but how do we use it? Simple: vim.lsp.start
If you read :help LSP
you will see the first section mention this function. It is responsible for
spawning the external process that is your language server and establishing a connection.
Earlier I said that neovim was the client part of LSP; that was kind of a lie. What actually happens is that neovim creates a client anytime you start a server, so that there is always a 1:1 mapping of client <-> server. neovim just "manages" these clients so to speak.
The function can take a bunch of parameters which you can read about in :help vim.lsp.start()
but
I want to focus on the essentials.
vim.lsp.start({
name = "my cool language server",
cmd = { "/path/to/server" },
root_dir = "/path/to/project/root",
})
The name
of the server is internal to neovim; you can call it whatever you want (using a sensible
name is recommended, though). The cmd
is the command used to spawn the server; after all neovim
does not include language servers, they are installed separately. root_dir
is the root directory
of your project. Most languages have an idea of a "project"; in JavaScript you have a package.json
file at the root, in Rust you have a Cargo.toml
, etc.
The language server will need to know where your project is, and that's what root_dir
is for. To
make life easier for yourself you can define a small helper function to make finding the root
directory easier:
local function find_root(patterns)
-- Use the current working directory as a fallback
local cwd = vim.fn.getcwd()
-- Search for files that match the given `patterns`
local matches = vim.fs.find(patterns, { upward = true })
if vim.tbl_isempty(matches) then
-- We could not find our root files
return cwd
end
-- Get the parent directory of the first match
local root_dir = vim.fs.dirname(matches[1])
if root_dir == nil then
-- If it doesn't have a parent directory (for whatever reason), return the cwd as well
return cwd
end
return root_dir
end
Let's set up rust-analyzer as an example to demonstrate how to use this function:
vim.lsp.start({
name = "rust-analyzer",
cmd = { "rust-analyzer" },
root_dir = find_root({ "Cargo.toml" }),
})
Now, calling the function like this will immediately load the language server, but ideally we only
call it when we are in a Rust project. For language servers like rust-analyzer which only support
a single language, you can simply put the call to vim.lsp.start
into after/ftplugin/rust.lua
.
Other servers like the
typescript-language-server
however work for multiple languages; TypeScript and JavaScript in this case. So you probably want to
wrap the call to vim.lsp.start
in a function which you export from a module and then require
in
after/plugin/typescript.lua
and after/plugin/javascript.lua
. If you don't know how modules work,
checkout this section.
An example of such a setup would be the following:
-- lua/alphakeks/lsp.lua
local function find_root(patterns)
local cwd = vim.fn.getcwd()
local matches = vim.fs.find(patterns, { upward = true })
if vim.tbl_isempty(matches) then
return cwd
end
local root_dir = vim.fs.dirname(matches[1])
return vim.F.if_nil(root_dir, cwd)
end
return { find_root = find_root }
-- lua/alphakeks/lsp/tsserver.lua
local function start()
vim.lsp.start({
name = "tsserver",
cmd = { "typescript-language-server", "--stdio" },
root_dir = require("alphakeks.lsp").find_root({ "package.json" }),
})
end
return { start = start }
-- after/ftplugin/typescript.lua
require("alphakeks.lsp.tsserver").start()
-- after/ftplugin/javascript.lua
require("alphakeks.lsp.tsserver").start()
This however is a lot of boilerplate. Most servers have defaults that will apply on basically any system and you probably don't want to care about those details. This is where nvim-lspconfig comes into play. It's a plugin maintained by the neovim team, but external to neovim because it gets updated a lot more frequently. It's a collection of useful commands and preset configs for a ton of language servers. It reduces all the boilerplate I just showed you to basically
require("lspconfig").rust_analyzer.setup({})
require("lspconfig").tsserver.setup({})
This .setup()
function will take the same arguments as vim.lsp.start
and override any defaults
it has set internally, if you specify them. You can see a list of supported language servers as well
as all their default settings
here.
It's important to note that .setup()
will not immediately start a language server. Instead, it
will setup autocommands to start them when appropriate. This means you should not have these
in after/ftplugin
. Put them in after/plugin/lsp.lua
or in a Lua module that is loaded by your
config.
If you work with a lot of languages you will quickly realize that installing all these servers is a mess. Each one uses a different package manager and keeping versions in sync and making sure everything is installed when setting up a new machine is just a pain in the ass.
Unless you use something like nix of course :)
This is why the community has come up with a plugin to solve this.
mason.nvim still requires you to have all the package
managers installed, but it will handle the installation of the tools, as well as keeping them
isolated from the rest of your system. No more sudo npm i -g typescript-language-server
!
Mason can also handle other tools for you, like formatters or linters, which aren't language servers but still useful tools you might want installed alongside your editor.