schilk happens



Working with external warnings and diagnostics in NVIM.

Tuesday, 20 January 2026    Tags: nvim, rtl

While most modern programming languages feature very advanced editor integrations especially through LSP server implementations, VLSI, RTL, and (System)Verilog tooling is always a bit ... let's say different.

Fortunately, these tools are not inherently bad, but rather a little more old-fashioned. This means that while they might not be as straightforward to use, the tools and techniques for an efficient workflow are already well known and out there.

In particular, I was interested in being able to quickly see and navigate to all the numerous warnings and errors they produce directly in my editor nvim.

I will use verilator - an open-source SystemVerilog simulator that works by transpiling RTL models to C++ code - as an example for this post, but these same tools and techniques apply to everything from commercial simulators and backend tools to any tool that generates errors and warnings that are attached to a file location.

The Quickfix List

As is so often the case, this exact feature is already supported in (neo)vim without any external plugins.

In fact, for a simple C project built with a Makefile, you don't even have to configure anything! The command :make will cause vim to call make with no arguments, capture its output and parse it into the so-called quickfix list, which you can open with :cwindow:



Errors produced by :make in the quick-fix list.


As you scroll through the quickfix list, vim will automatically jump to the location of the errors and warnings. Even without opening the quickfix list, you can jump between entries using :cn and :cp.

For more information about the quickfix list, see :h quickfix.

The makeprg option and cfile

The :make command and quickfix list, despite its name, is not limited to Makefiles and their output. The makeprg option controls which command is invoked when :make is called. For example, the following will cause it to run cargo build:

:set makeprg=cargo\ build

Any arguments you provide to the :make command will be appended to the end of makeprg, unless the $* placeholder is used to specify where exactly the arguments should be inserted:

:set makeprg=make\ $*\ verilate
:make MYDEFINE=1 " -> make MYDEFINE=1 verilate

The % placeholder will be replaced with the path of the current file, which is particularly useful for single-file linters or scripts:

:set makeprg=shellcheck\ %
" or
:set makeprg=python\ %

For long-running compilations and programs, running them directly in vim can be a bit cumbersome. In such cases, the :cfile command can be used to read program output that has been written to a file:

:cfile program_output.log

The content will be used to populate the quickfix list just like the output of the :make command.

For more information see :h makeprg and :h cfile.

The errorformat option

The errorformat option, in turn, contains one or more regex-like parsing rules used to extract the source code location and other metadata from the :make output or :cfile.

For example, consider the output of the aforementioned verilator tool:

%Warning-DECLFILENAME: looong_path/tc_clk.sv:11:8: Filename 'tc_clk' does not match MODULE name: 'tc_clk_and2'
%Error-PINCONNECTEMPTY: other_path/trip_counter.sv:43:10: Instance pin connected by name with empty reference: 'overflow_o'

A parsing rule consists of literal text and %-prefixed placeholders. For the above, a basic rule might look something like this:

:set errorformat=%%%t%*[a-zA-Z]-%*[^:]:\ %f:%l:%c:\ %m

It consists of the following components:

ComponentFunction
%%Matches a single % character.
%tMatches a single character which determines the error type. (Error, Warning, Info, Note)
%*[a-zA-Z]Matches one or more lower or uppercase letters.
-Matches a single - character.
%*[^:]Matches one or more characters that are not a :.
:Matches a single : character.
Matches a single space ( ).
%fMatches a file path.
%lMatches a line number.
%cMatches a column number.
%mMatches an error message.

:h error-file-format contains documentation about how errorformat strings are interpreted, including many more placeholders and tools. Consider also having a look at existing compiler plugins as a reference.

The above errorformat does not cover all possible verilator warnings and errors. See below for a more complete implementation that handles warnings without error code and without column.

Compiler Plugins

(Neo)vim ships a set of pre-configured makeprg and errorformat values for a number of common compilers and tools. These take the form of so-called compiler plugins.

For a list of built-in compiler plugins, have a look at the runtime/compiler folder of the (neo)vim source tree. For a list of compiler plugins available in your current (neo)vim instance you can run:

:for f in globpath(&rtp, 'compiler/*.vim', 0, 1) | echo fnamemodify(f, ':t:r') | endfor

In typical vim fashion, the set of compiler plugins spans from different fortran flavours to slightly more modern tools such as gleam-build.

A few that might be of interest:

And even modelsim_vcom, which is the VHDL compiler in modelsim!

It is worth having a look at the content of the compiler plugin file for a tool you are intending to use. Many of them feature extra options and settings you can control via global variables. For example, to determine the exact flags with which cargo is called, the cargo compiler plugin considers the g:cargo_makeprg_params global:

" runtime/compiler/cargo.vim:
if exists('g:cargo_makeprg_params')
    execute 'CompilerSet makeprg=cargo\ '.escape(g:cargo_makeprg_params, ' \|"').'\ $*'
else
    CompilerSet makeprg=cargo\ $*
endif

Custom Compiler Plugins: Verilator

If you find yourself often parsing the output of a tool that is not yet included in (neo)vim's list of built-in compiler plugins, consider adding a custom compiler plugin in your configuration.

On linux, new compiler plugins should be placed in $XDG_CONFIG_HOME/nvim/compiler/, while overrides to existing compiler plugins go in $XDG_CONFIG_HOME/nvim/after/compiler/.

For example, on my machine, to create a new verilator compiler plugin, I create ~/.config/nvim/compiler/verilator.vim with the following content:

" ~/.config/nvim/compiler/verilator.vim:
" Verilator warnings always start with "%Error" or "%Warning", optionally directly
" followed by an error code. If a location is known, it may be given with or
" without column number. Examples of errors:
"
" "%Warning: ..msg.."
" "%Error-DECLFORMAT: myfile:1: ..msg.."
" "%Error-PINCONNECT: myfile:1:2: ..msg.."
CompilerSet errorformat=%%%t%*[a-zA-Z]-%*[^:]:\ %f:%l:%c:\ %m,
			\%%%t%*[a-zA-Z]-%*[^:]:\ %f:%l:\ %m,
			\%%%t%*[a-zA-Z]:\ %f:%l:%c:\ %m,
			\%%%t%*[a-zA-Z]:\ %f:%l:\ %m,

See :h write-compiler-plugin for more info.

Unfortunately, the %*[^:] pattern used to match the error code causes it to not be shown in the quickfix list. Parsing it as a module (%o) will make it visible in the quickfix list, but hide the file name:

CompilerSet errorformat=%%%t%*[a-zA-Z]-%o:\ %f:%l:%c:\ %m,
			\%%%t%*[a-zA-Z]-%o:\ %f:%l:\ %m,
			\%%%t%*[a-zA-Z]:\ %f:%l:%c:\ %m,
			\%%%t%*[a-zA-Z]:\ %f:%l:\ %m,

Diagnostics

If you instead prefer to display external warnings and errors as in-line diagnostics in the same style as LSP servers, that is also an option, but requires a bit of lua scripting.

The vim.diagnostic API

To display a set of diagnostics, first create a namespace to contain them:

local my_namespace = vim.api.nvim_create_namespace("my_namespace")

Next, configure how the diagnostics in this namespace should be displayed:

vim.diagnostic.config({
    virtual_text = true,
    signs = true,
    underline = true,
    update_in_insert = false,
}, my_namespace)

Collect all your diagnostics for a given buffer into a table, and use vim.diagnostic.set() to display them:

local bufnr = 0
local diagnostics = {
  {
    bufnr = bufnr, -- buffer
    lnum = 10, -- line number
    col = 0, -- column
    severity = vim.diagnostic.severity.WARN, -- severity
    message = "Something went wrong!", -- message
  }
}
vim.diagnostic.set(my_namespace, bufnr, diagnostics, {})

To remove all diagnostics, including before you re-apply a new set, use vim.diagnostic.reset():

vim.diagnostic.reset(my_namespace)

Implementation

With this, you can write a lua function that parses any external source and displays them as nvim diagnostics.

Have a look at the documentation of the tool you are using - many feature an option for generating the diagnostics information in a machine readable format. Verilator, for instance, can be instructed to write all errors and warnings to a JSON file with standard SARIF formatting:

verilator --diagnostics-sarif --diagnostics-sarif-output log.json

In addition to seeing the errors inline, I like to use the excellent trouble.nvim to also show a quickfix-like pane with all diagnostics. If you prefer to use the quickfix list, you can also programatically insert the diagnostics there.

This approach, while a little bit more work, has the advantage of being extremely flexible. For example, for my verilator output parsing, I added simple reload and filtering capabilities:

" Parse + display verilator diagnostics file:
:VerilatorDiag log.json

" Reload diagnostics file:
:VerilatorDiag

" Only display diagnostics that contain the string "frontend", and remove any
" "UNOPTFLAT" and "UNUSEDSIGNAL" diagnostics:
:VerilatorDiagFilter frontend -UNOPTFLAT -UNUSEDSIGNAL

" Reset/clear the diagnostics:
:VerilatorDiagReset

Notes & Resources

You can find my complete setup in my neovim configuration here.

A self-contained verilator sarif output parser and diagnostic generator with filtering as described above follows below:

local M = {}

M.verilator_namespace = vim.api.nvim_create_namespace("diag_verilator")
M.verilator_file = nil
M.verilator_exclude_filters = {}
M.verilator_include_filters = {}

function M.filter_check(file, msg, severity)
  local severity_lower = string.lower(severity)
  local diag = file .. " " .. msg

  -- Reject based on exclude filters:
  for _, filter in ipairs(M.verilator_exclude_filters) do
    local filter_lower = string.lower(filter)
    if string.find(diag, filter) or string.find(severity_lower, filter_lower) then
      return false
    end
  end

  -- If any include filters are defined, check if we match one:
  if #M.verilator_include_filters > 0 then
    local any_filter_matches = false
    for _, filter in ipairs(M.verilator_include_filters) do
      local filter_lower = string.lower(filter)
      if string.find(diag, filter) or string.find(severity_lower, filter_lower) then
        any_filter_matches = true
      end
    end

    if not any_filter_matches then
      return false
    end
  end

  return true
end

function M.generate_diags(log_file)
  local file = io.open(log_file, "r")
  if not file then
    error("Could not open verilator sarif file: " .. log_file)
    return
  end

  local content = file:read("*all")
  file:close()
  local success, data = pcall(vim.json.decode, content)
  if not success then
    error("Invalid JSON/SARIF in file: " .. log_file)
    return
  end

  if not data["runs"] or #data["runs"] ~= 1 then
    error("Invalid JSON/SARIF in file - not exactly one run: " .. log_file)
    return
  end
  data = data["runs"][1]

  if not data["results"] then
    error("Invalid JSON/SARIF in file - no results: " .. log_file)
    return
  end
  data = data["results"]

  -- per-bufnr diagnostics:
  local diagnostics = {}

  -- Iterate through data:
  for _, result in ipairs(data) do
    local rule_id = ""
    if result["ruleId"] then
      rule_id = result["ruleId"] .. ": "
    end
    local message = rule_id .. (result["message"]["text"] or "No message")

    local level = vim.diagnostic.severity.INFO
    if result["level"] == "error" then
      level = vim.diagnostic.severity.ERROR
    elseif result["level"] == "warning" then
      level = vim.diagnostic.severity.WARN
    end

    if not result["locations"] or #result["locations"] == 0 then
      goto continue
    end

    local loc = result["locations"][1]["physicalLocation"]

    if not loc or not loc["artifactLocation"] or not loc["region"] then
      goto continue
    end

    local file_path = loc["artifactLocation"]["uri"]:gsub("^file://", "")
    local start_line = loc["region"]["startLine"] or 1
    local start_column = (loc["region"]["startColumn"] or 1) - 1

    if not M.filter_check(file_path, message, result["level"]) then
      goto continue
    end

    local bufnr = vim.fn.bufadd(file_path)
    if not bufnr then
      error("Error")
      return
    end

    if not diagnostics[bufnr] then
      diagnostics[bufnr] = {}
    end

    table.insert(diagnostics[bufnr], {
      bufnr = bufnr,
      lnum = start_line - 1,
      col = start_column,
      message = message,
      severity = level,
      source = "verilator",
    })

    ::continue::
  end

  -- Set Diagnostics, per-buffer:
  local cnt = 0
  vim.diagnostic.reset(M.verilator_namespace)
  for bufnr, diags in pairs(diagnostics) do
    vim.diagnostic.set(M.verilator_namespace, bufnr, diags, {})
    cnt = cnt + #diags
  end

  -- Notification:
  local filters = {}
  for _, f in ipairs(M.verilator_include_filters) do
    table.insert(filters, f)
  end
  for _, f in ipairs(M.verilator_exclude_filters) do
    table.insert(filters, "-" .. f)
  end
  local filters_str = ""
  if #filters ~= 0 then
    -- all filters joined by spaces:
    filters_str = "\nFilters: " .. table.concat(filters, " ")
  end
  vim.notify("Found " .. cnt .. " verilator diagnostics." .. filters_str)
end

function M.reload()
  if M.verilator_file then
    M.generate_diags(M.verilator_file)
  end
end

function M.cmd_diags(args)
  if #args.fargs == 0 then
    if not M.verilator_file then
      error("No verilator log file set.")
      return
    end
    M.generate_diags(M.verilator_file)
  else
    local abs_path = vim.fn.fnamemodify(args.args, ":p")
    M.verilator_file = abs_path
    M.generate_diags(abs_path)
  end
end

function M.cmd_reset()
  M.verilator_file = nil
  M.verilator_exclude_filters = {}
  M.verilator_include_filters = {}
  vim.diagnostic.reset(M.verilator_namespace)
end

function M.cmd_clear()
  vim.diagnostic.reset(M.verilator_namespace)
end

function M.cmd_filter(args)
  M.verilator_exclude_filters = {}
  M.verilator_include_filters = {}
  for _, arg in ipairs(args.fargs) do
    if vim.startswith(arg, "-") then
      table.insert(M.verilator_exclude_filters, arg:sub(2))
    else
      table.insert(M.verilator_include_filters, arg)
    end
  end
  if M.verilator_file then
    M.generate_diags(M.verilator_file)
  end
end

function M.setup()
  vim.diagnostic.config({
    virtual_text = true,
    signs = true,
    underline = true,
    update_in_insert = false,
  }, M.verilator_namespace)
  vim.api.nvim_create_user_command("VerilatorDiagReset", M.cmd_reset, { nargs = 0 })
  vim.api.nvim_create_user_command("VerilatorDiagClear", M.cmd_clear, { nargs = 0 })
  vim.api.nvim_create_user_command("VerilatorDiag", M.cmd_diags, { nargs = "?", complete = "file" })
  vim.api.nvim_create_user_command("VerilatorDiagFilter", M.cmd_filter, { nargs = "*" })
end

return M