Using (n)vim quickfix to jump to errors
Motivation
I've recently been learning VHDL for hardware design. In this project, I am invoking several different build tools (GHDL, Yosys, Altera Quartus) and I have a fundamental workflow issue that I want to solve.
Let's say I just wrote some code and saved the file. For example, I have this simple VHDL package and I just changed the name of the record type:
library ieee;
use ieee.std_logic_1164.all;
package graphics is
-- This used to be called "pixel_t" but I just renamed it to "wide_pixel_t"
-- What consumers will be broken when I do that?
type wide_pixel_t is record
red : std_logic_vector(7 downto 0);
green : std_logic_vector(7 downto 0);
blue : std_logic_vector(7 downto 0);
end record wide_pixel_t;
end package graphics;
Now I want to check for any build or test issues I may have just introduced. What's the best way to discover and jump to those problem areas?
Trouble with Linters
While several tools like ale and nvim-lint can automatically run linters and checkers on your code, their fundamental approach has a few drawbacks.
First, the linter plugins are single-file focussed. This works well for simple errors like making a typo or error within the file being edited. However, in more complex projects the plugins won't be able to tell you about a change you just made in the current file that caused an error in a different file elsewhere in the project.
In the motivating example above, the plugin can't tell me that the change I made to graphics.vhd caused a new error to appear in vga.vhd. It can only tell me that I introduced an error in the file I'm currently editing.
The second issue with linter plugins is that invoking the required tools in complex projects isn't always easy. In many projects I work in, the build systems are complex and it isn't just a matter of invoking a single common command to invoke the tools and trigger a failure.
In the motivating example, tools like GHDL require specific arguments and multiple invocations to check the entire project.
Additionally, there may not always be a fast "check" command available. While I could feed a command like make all to a linter plugin, actually running that command might take a while. And it will produce output that I will usually want to see, but the plugins will generally hide the output.
For example, invoking Quartus is very slow. And while I could setup a separate build step that only checks the syntax of the code, that's extra work and seems unnecessary. If I have to eventually compile the code anyways, why not just use that build process to also extract the errors?
And if the project is using many different tools, there will likely be several different styles of diagnostic messages coming out of a make all command. I need a solution that can parse errors from a myriad of different tools and present the diagnostics in a unified manner.
While I will continue to use linters in some circumstances (e.g. a quick shellcheck run when writing bash scripts is very convenient), they don't seem completely sufficient for development in larger projects.
Language Servers
Language servers can solve some of the drawbacks of linters. In projects that have a uniform structure, they can work particularly well (e.g. Cargo projects in Rust).
However, the quality of language servers varies widely depending on the environment you're targetting. In the case for the motivating example, while there is a GHDL-based language server, it doesn't seem simple to make it compatible with my project. And it still wouldn't be able to capture diagnostics from other tools like Quartus.
Vim Quickfix
As is true for most challenges faced by programmers, this is not a unique problem. And vim itself includes a system for solving this exact problem: quickfix!
There are many guides online for using quickfix. The best reference is the vim user manual itself.
And while the fundamental tool is good, there are some drawbacks in its usage that I wanted to address:
- The main way to populate the quickfix list is by running :make. I don't like this command. I would much rather run my build command in a real terminal like I normally do. The output is easier to read, and I don't have to define the build command for every project I'm working in.
- The syntax for adding diagnostics parsers is very unfriendly. I would much rather use a normal regex parser than the weird scanf-regex hybrid system that vim uses.
Custom Parser
Both of these issues are simple to address.
The first step is a minor adjustment to my build workflow. Now instead of running just make to build, I run
make 2>&1 | tee /tmp/last-build.log
This saves the entire build output to a file that I can parse later.
To that end I created quickfix-parser, a Python script that parses the various diagnostics formats for the specific tools I'm working in. That tool can turn messages like this:
ghdl -s --std=08 -Wall src/pkg/graphics.vhd src/pkg/math.vhd src/vga_fifo_reader.vhd ... src/vga.vhd:29:23:error: no declaration for "pixel_t" pixel_i : in pixel_t; ^ src/vga.vhd:30:23:error: no declaration for "pixel_t" pixel_o : out pixel_t; ^ src/vga.vhd:40:21:error: entity "vga" was not analysed architecture rtl of vga is ^ make: *** [Makefile:106: simulations] Error 1
Into a uniform format like this:
type e file src/vga.vhd line 29 col 23 message no declaration for "pixel_t" type e file /tmp/last-build.log line 2 col 0 message no declaration for "pixel_t" type e file src/vga.vhd line 30 col 23 message no declaration for "pixel_t" type e file /tmp/last-build.log line 5 col 0 message no declaration for "pixel_t" type e file src/vga.vhd line 40 col 21 message entity "vga" was not analysed type e file /tmp/last-build.log line 8 col 0 message entity "vga" was not analysed
This is the source code for quickfix-parser at the time of writing. I will be slowly extending it over time to support parsing messages in more formats.
We can make use of that parser with this vim config (note this is lua as I use Neovim; this should be possible in vanilla vim too):
-- Setup my custom parser as the :make command
vim.opt.makeprg = "quickfix-parser /tmp/last-build.log"
vim.opt.errorformat = "type %t file %f line %l col %c message %m"
-- Also create some keybindings:
-- Jump to next item
vim.keymap.set('n', '<C-n>', ':cn<Cr>', opts)
-- Jump to previous item
vim.keymap.set('n', '<C-p>', ':cp<Cr>', opts)
-- Run the parser and open the quickfix list
vim.keymap.set('n', '<Leader>cc', ':make<Cr><Cr>:copen<Cr>', opts)
-- Run the parser, but exclude warnings
vim.keymap.set('n', '<Leader>ce', ':make -l e<Cr><Cr>:copen<Cr>', opts)
-- Close the quickfix window
vim.keymap.set('n', '<Leader>cq', ':cclose<Cr>', opts)
I also wanted to display the quickfix issues within the individual files as a marker on the relevant line, similar to how linter plugins show issues. For that purpose I'm using the dhruvasagar/vim-markify plugin.