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:
masoninstalls the executable of a LSP server. - lspconfig:
lspconfigstarts a LSP server using the installed executable. It provides default server configuration and displays helpful information such as attached buffers. - mason-lspconfig:
mason-lspconfigprovides 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
zigexecutable andlib/directory inside the unpacked directory so I should putziginto 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:
nextunwrapped 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
zlswhen it encounter the first Zig file -
Neovim reuses the same LSP client and add new buffers to
attached_buffersThis 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,
})