Motivation
Neovim users should be familiar with LSP-related plugins and commands.
Run :Mason
, find lua-language-server
, type i
and the Lua LSP server will be installed.
Add the following line in the neovim config file for setup, or just provide a lua_ls = {}
table in a lsp section if the config is built on top of a distribution.
require('lspconfig').lua_ls.setup{}
Open a lua file, run :LspInfo
and you can see lua_ls
has been started.
Translation between lua-language-server
(mason package name) and lua_ls
(lspconfig
server name) is done by mason-lspconfig
without any configuration.
These package manager and configuration layer make our life easier, but at the same time hide the actual commands to start and connect to a LSP server.
So in this post I am going to explore the neovim native LSP API. The goal is simple: make go to definition works without relying on any plugin.1
Things done by plugins
To accomplish the result without plugins, look at each plugin’s role first.
- mason:
mason
installs the executable of a LSP server. - lspconfig:
lspconfig
starts a LSP server using the installed executable. It provides default server configuration and displays helpful information such as attached buffers. - mason-lspconfig:
mason-lspconfig
provides the LSP server installed via mason tolspconfig
. It can also automatically setup a LSP server installed via Mason.
Things to DIY
Without mason
,
- I have to install the LSP server
Without lspconfig
,
- I have to use neovim’s native LSP API to inform the editor that I want to use LSP, probably with configuration
Without mason-lspconfig
,
- I have to specify the location of the installed executable
Choose a LSP server
I will use Zig’s zls because of the following reasons:
- I don’t have any Zig project so even if I mess up during the installation process, there will be no problem
- The installation instruction seems easy
Installing Zig LSP
The installation process is indeed easy. With a few steps I can get the executable zig
and zls
Getting zig
-
Download the master release of Zig
-
Unpack the file and read its README.md
A Zig installation is composed of two things:
- The Zig executable
- The lib/ directory
At runtime, the executable searches up the file system for the lib/ directory, relative to itself:
- lib/
-
There is a
zig
executable andlib/
directory inside the unpacked directory so I should putzig
into my PATH and it will find the necessarylib/
at runtime -
Run
zig version
:0.12.0-dev.3154+0b744da84
DONE 🥳
Getting zls
Following zls’s From Source installation guide
git clone https://github.com/zigtools/zls
cd zls
zig build -Doptimize=ReleaseSafe
zig-out/bin/zls
is produced. Try to run it:
info : ( main ): Starting ZLS 0.12.0-dev.480+dd307c5 @ 'zig-out/bin/zls'
DONE 🥳
Another init.lua
I plan to have two neovim config side-by-side
- One already configured config (LazyVim)
- One experimental config on LSP
I will use the configured config to see the expected behavior of LSP actions. And I will try to reproduce the behavior in the experimental config.
It is possible to have multiple neovim config and specify NVIM_APPNAME
to use config from a non-standard directory.
First, create an empty config in ~/.config/nvim-lsp/init.lua
. Then, I can switch between the two config:
Start nvim
without NVIM_APPNAME
will use the config from ~/.config/nvim
nvim
Start nvim
with NVIM_APPNAME=nvim-lsp
will use the config from ~/.config/nvim-lsp
NVIM_APPNAME=nvim-lsp nvim
Go to definition demo
In LazyVim, go to definition can be accomplished via gd
. I copied queue.zig
from Zig’s code examples to try this out. Typing gd
while the cursor is on a symbol jumps to the corresponding definition. It works for different types of symbol:
- struct method:
enqueue
,dequeue
- symbol outside current file:
expectEqual
- struct field:
this.start
- unwrapped optional:
next
unwrapped inif (start.next) |next|
gd
without LSP
Surprisingly, when opening neovim with empty config, typing gd
already works for several types of symbol:
- struct method:
enqueue
,dequeue
- struct field:
this.start
I find that gd
is a built-in search command:
Goto local Declaration. When the cursor is on a local variable, this command will jump to its declaration. This was made to work for C code, in other languages it may not work well.
gd
will try to find local declaration. This explains why expectEqual
cannot be found.
For unwrapped optional like next
, the built-in gd
incorrectly thinks that its definition is from the struct field next
. If I change the name to nexts
, the built-in gd
finds the correct definition from if (start.next) |nexts|
.
NVIM_APPNAME=nvim-lsp nvim
:h lsp
Now it’s time to study neovim’s API to have a gd
powered by LSP.
The main reference is Neovim’s help page on LSP. But I will also cheat by looking at lspconfig
code in order to know the detailed configuration passed to neovim’s LSP API.
This is the example command to start a LSP server:
vim.lsp.start({
name = 'my-server-name',
cmd = {'name-of-language-server-executable'},
root_dir = vim.fs.dirname(vim.fs.find({'setup.py', 'pyproject.toml'}, { upward = true })[1]),
})
The server config table in lspconfig
resembles the arguments passed to vim.lsp.start
. I can probably copy the table to nvim-lsp/init.lua
.
lua/lspconfig/server_configurations/zls.lua
local util = require 'lspconfig.util'
return {
default_config = {
cmd = { 'zls' },
filetypes = { 'zig', 'zir' },
root_dir = util.root_pattern('zls.json', 'build.zig', '.git'),
single_file_support = true,
},
...
}
Adding LSP
To start a LSP, I need a way to find root_dir
. Look like build.zig
is a good choice. After referencing Zig’s documentation, I have a simple build script with a test step:
Ready to add vim.lsp.start
at the beginning of init.lua
:
vim.lsp.start({
name = "my-zls",
cmd = { "zls" },
filetypes = { "zig", "zir" },
root_dir = vim.fs.dirname(vim.fs.find({ "build.zig" }, { upward = true })[1]),
single_file_support = true,
})
Use the following command to get the LSP client started via vim.lsp.start
. The =
preceding the function call prints the returned table in :message
.
:lua =vim.lsp.get_active_clients()
The following message is produced:
{ {
_on_attach = <function 1>,
attached_buffers = {},
cancel_request = <function 2>,
commands = {},
config = {
cmd = { "zls" },
filetypes = { "zig", "zir" },
flags = {},
get_language_id = <function 3>,
name = "my-zls",
root_dir = "/Users/oni/repo/zig",
settings = {},
single_file_support = true
},
handlers = {},
id = 1,
initialized = true,
is_stopped = <function 4>,
messages = {
messages = {},
name = "my-zls",
progress = {},
status = {}
},
name = "my-zls",
...
I can see my-zls
😺
Let me see if I can go to definition. Since I have not set up keymap yet, I have to use vim.lsp.buf.definition()
instead of gd
.
Unfortunately, it is not working. Running :lua vim.lsp.buf.definition()
on all of the symbols mentioned above has no effect 🤨
FileType event
Looking carefully on the message from :lua =vim.lsp.get_active_clients()
, the lsp client has started but no buffer is attached.
attached_buffers = {}
This reminds me the bufnr
information displayed by :LspInfo
:
1 client(s) attached to this buffer:
Client: lua_ls (id: 1, bufnr: [21])
filetypes: lua
autostart: true
root directory: Running in single file mode.
cmd: /Users/oni/.local/share/nvim/mason/bin/lua-language-server
This information actually comes from neovim vim.lsp.*
API too.
The current task is to tell LSP client to attach to buffers of file type zig
.
As suggested in Neovim LSP help page:
To ensure a language server is only started for languages it can handle, make sure to call vim.lsp.start() within a FileType autocmd.
This is also what lspconfig
is doing here.
Finding what to expect when FileType
event fires.
vim.api.nvim_create_autocmd("FileType", {
pattern = "*",
callback = function(event)
print(vim.inspect(event))
end,
})
event
is a table:
{
buf = 1,
event = "FileType",
file = "queue.zig",
id = 3,
match = "zig"
}
I can probably use pattern
in nvim_create_autocmd
to trigger callback only when neovim encounters a Zig file. But here I want to try using match
from the event:
vim.api.nvim_create_autocmd("FileType", {
pattern = "*",
callback = function(event)
if event.match == "zig" then
vim.lsp.start(...)
end
end,
})
It is time to verify the result.
Pay attention to attached_buffers
in the message:
-
When neovim starts, it will not start
zls
-
Neovim starts
zls
when it encounter the first Zig file -
Neovim reuses the same LSP client and add new buffers to
attached_buffers
This behavior is also mentioned in the help of
vim.lsp.start
:Create a new LSP client and start a language server or reuses an already running client if one is found matching name and root_dir. Attaches the current buffer to the client.
Now I can say that I am using LSP without plugins 🥳
gd
with LSP
To complete the story, I am going to add gd
to keymap. This should be added only for buffers with LSP attached, which can be accomplished using the LspAttach
event:
vim.api.nvim_create_autocmd("LspAttach", {
callback = function(event)
vim.keymap.set("n", "gd", vim.lsp.buf.definition, { desc = "Goto definition", buffer = event.buf })
end,
})