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.

Things to DIY

Without mason,

Without lspconfig,

Without mason-lspconfig,

Choose a LSP server

I will use Zig’s zls because of the following reasons:

Installing Zig LSP

The installation process is indeed easy. With a few steps I can get the executable zig and zls

Getting zig

  1. Download the master release of Zig

  2. Unpack the file and read its README.md

    A Zig installation is composed of two things:

    1. The Zig executable
    2. The lib/ directory

    At runtime, the executable searches up the file system for the lib/ directory, relative to itself:

    • lib/
  3. There is a zig executable and lib/ directory inside the unpacked directory so I should put zig into my PATH and it will find the necessary lib/ at runtime

  4. 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

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:

gd without LSP

Surprisingly, when opening neovim with empty config, typing gd already works for several types of symbol:

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:

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,
})

This is how LazyVim do it too2.


  1. Here is the result ↩︎

  2. LazyVim calls telescope lsp_definitions() instead of vim.lsp.buf.definition() ↩︎