Custom diagnostics in neovim
No matter what editor/IDE you use to edit your source code, you probably use some kind diagnostics solution. And when I say “diagnostics”, I just mean any kind of UI to show you made a mistake (e.g. red underline for a compiler error). Diagnostics become especially powerful when your editor exposes an API to add custom diagnostics for your custom tools.
In this article we will look at a practical example on how you can display error messages in neovim based on a custom command line tool. I do want to focus more on the neovim side so we are not going to look at the source code of the command line tool. But we still need some context on what we are dealing with. Feel free to skip to the next section if you don’t really care.
Overview apigen
apigen
is a small side project/experiment of mine which allows you to generate
source code and API documentation for a Go based Web API. The command line tool
will parse the source code of a go project using the builtin go/parser
and
go/ast
packages and then generate some routing source code and/or
documentation based on some special comments. Let’s look at an example:
// SayHello documentation summary.
//
// @route GET /hello/:name
// @p:name string Name documentation
func SayHello(ctx *gin.Context) {
name := ctx.Param("name")
ctx.JSON(http.StatusOK, gin.H{
"message": fmt.Sprintf("Hello %s!", name),
})
}
Here some context for the function above but don’t worry if you are not
familiar with the specifics. What you see above is a http handler function for
the Gin library. In this example we read
the name
parameter from the path and return a JSON object containing a
message greeting the person with the given name
. The comments are just
regular go comments and don’t have any meaning by default. The comments are
parsed by the apigen
CLI tool which then generates some additional routing
code. Usually you would write something like this to connect the handler
function written above with the router.
router := ... // setup gin router
router.GET("/hello/:name", SayHello)
As you might have guessed, this code will be generated by apigen
based on the
@route
annotation. The @p:name
is used when generating API documentations
to describe the name parameter. The apigen
tool does a few other things as
well but that’s not really important for this article.
What problem are we trying to solve?
We now have a custom CLI tool specific for our project which generates source
code based on special comments. The problem is, the go compiler doesn’t
know anything about the special meaning of those comments. So existing go
tooling cannot tell us if we did something wrong (e.g. a spelling error in an
@
annotation). We only find out once we manually run the apigen
tool. Much
nicer would be an error message in our editor just like any other compiler
error.
Diagnostics in neovim
This is where a programmable editor like neovim really shines. I have a custom tool which is able to output errors with line and column information without any support for the editor I use. No problem, I can just write a small plugin to add support for any custom tool myself.
For the specific use case described above the diagnostic framework from neovim
will be the most relevant part. Run :h vim.diagnostic
within neovim to learn
more.
Nvim provides a framework for displaying errors or warnings from external tools, otherwise known as “diagnostics”. These diagnostics can come from a variety of sources, such as linters or LSP servers. The diagnostic framework is an extension to existing error handling functionality such as the |quickfix| list.
The diagnostic framework allows us to tell neovim about the errors from our
custom apigen
tool so neovim can display them. In the specific case of
apigen
the error format is very simple. Line and column information are
printed on one line and the error message on a second line. If for example I
misspell @route
as @rout
I get the following error.
13:3
invalid annotation @rout
Now that we know the error format, we can start writing the plugin. I’m using lua to configure neovim so the following plugin is written in lua as well.
-- ~/.config/nvim/lua/plugins/apigen/init.lua
local M = {}
M.setup = function ()
-- Create a custom apigen namespace to make sure we don't mess
-- with other diagnostics.
M.namespace = vim.api.nvim_create_namespace("apigen")
-- Create an autocommand which will run the check_current_buffer
-- function whenever we enter or save the buffer.
vim.api.nvim_create_autocmd({"BufWritePost", "BufEnter"}, {
group = vim.api.nvim_create_augroup("ApiGen", { clear = true }),
-- apigen currently only parses annotations within *.api.go
-- files so those are the only files we want to check within
-- neovim as well.
pattern = "*.api.go",
callback = M.check_current_buffer,
})
end
return M
And here the function doing the actual work.
M.check_current_buffer = function ()
-- Reset all diagnostics for our custom namespace. The second
-- argument is the buffer number and passing in 0 will select
-- the currently active buffer.
vim.diagnostic.reset(M.namespace, 0)
-- Get the path for the current buffer so we can pass that into
-- the command below.
local buf_path = vim.api.nvim_buf_get_name(0)
-- Running `apigen -check FILE_PATH` will print error messages
-- to stderr but won't generate any code.
local cmd = "apigen -check " .. buf_path
-- You can also use vim.fn.system to run an external command.
-- In our case the error output is printed on multiple lines.
-- The first line will print "LINE:COL" and the second line the
-- error message itself. vim.fn.systemlist will return a lua
-- table containing each line instead of a single string.
local output = vim.fn.systemlist(cmd)
local exit_code = vim.v.shell_error
-- `apigen` exits with 0 on success and greater zero on error
if (exit_code ~= 0) then
-- parse line and col from the first line of the output
-- TODO: should probably do some error checking here ;)
local line, col = string.match(output[1], "(%d+):(%d+)")
-- vim.diagnostic.set allows you to set multiple diagnostics
-- for the given buffer. We only set one because `apigen`
-- currently exits on the first error it finds.
vim.diagnostic.set(M.namespace, 0, {
{
lnum = tonumber(line),
col = tonumber(col),
message = output[2]
}
})
end
end
return M
I hope everything is clear based on the code comments. Here some additional
notes though. The apigen
tool currently only supports checking a file not
stdin. So this function only works if the buffer was already saved to a file.
Not really a problem for my use case but something to keep in mind.
The call to vim.fn.systemlist
will block the UI until it completes so you
probably don’t want to run anything slow. Also not a problem in this specific
case. Running apigen -check
is pretty much instant.
Now all we need to do is include the plugin and call the setup function somewhere in our neovim config.
local apigen = require("plugins.apigen")
apigen.setup()
There you go. Neovim displaying custom error messages inside a go comment.
Closing thoughts
Writing custom tools/plugins for your editor is really fun! And, as we can see from the code above, it can also be really easy to do. Granted, the plugin we looked at in this article is not the most flexible but the nice thing about writing tools for yourself is “it works on my machine” is all you really need.