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.

Animated GIF showing a custom error in neovim

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.