Barbarian Meets Coding

WebDev, UX & a Pinch of Fantasy


Neovim Diagnostics

In its journey to provide a superb developer experience Neovim has extended the Vim concept of the quickfix list with an improved, modern version called diagnostics (term I suspect comes from the world of LSPs). Neovim diagnostics are however independent from LSPs, they are a framework for displaying errors and warnings from any external tools (linters, LSPs, etc) or on demand with user defined errors and/or warnings.

Table of Contents

Getting Started

Anything that reports diagnostics to neovim is referred to as a diagnostic producer. In order to hook a producer of diagnostics into neovim’s diagnostics one needs to:

-- 1. Create a namespace that identifies the producer:
local ns_id = vim.api.nvim_create_namespace("My diagnostics producer")

-- 2. (Optionally) configure options for the diagnostic namespace:

-- 3. Generate diagnostics (this would happen based on whichever business logic
--    generates these diagnostics)

-- 4. Set the diagnostics for the buffer:

Diagnostic Message

The diagnostic itself, the error or warning one wants to have appear in neovim is a lua table that has a number of fields to describe the message. Some of the most interesting are:

  • lnum: Starting line number (required)
  • col: Starting column (required)
  • message: Message (required)
  • bufnr: Buffer number
  • severity: Severity of the diagnostic - Error, Warning, Info or Hint (:h vim.diagnostic serverity)
  • end_lnum: Ending line number
  • end_col: Ending column

See :h diagnostic-structure for additional fields.

Showing Diagnostics

You can show diagnostics to the user using the method.

The display of diagnostics is normally managed through the use of handlers. A handler is a table with a “show” and (optionally) a “hide” methods:

show = function(namespace, bufnr, diagnostics, opts)
hide = function(namespace, bufnr)

Handlers can be added by creating a new key in vim.diagnostic.handlers and configured using the vim.diagnostic.config() method:

-- This example comes from :h diagnostic-handlers
-- It's good practice to namespace custom handlers to avoid collisions
vim.diagnostic.handlers["my/notify"] = {
  show = function(namespace, bufnr, diagnostics, opts)
    -- The opts table passed to a handler contains the handler configuration
    -- that a user can configure via vim.diagnostic.config.
    -- In our example, the opts table has a "log_level" option
    local level = opts["my/notify"].log_level

    local name = vim.diagnostic.get_namespace(namespace).name
    local msg = string.format("%d diagnostics in buffer %d from %s",

    -- The call to vim.notify notifies the user of diagnostics
    -- which is similar to `:echo "hello diagnostic"`. This doesn't 
    -- show a diagnostic. So there's no need to implement hide.
    vim.notify(msg, level)

-- Users can configure the handler
  ["my/notify"] = {
    -- This table here are the *opts* parameter sent to the handler
    log_level = vim.log.levels.INFO

Neovim provides a number of handlers by default: “virtual_text”, “signs” and “underline”. These and any other handler can be overriden:

-- Create a custom namespace. This will aggregate signs from all other
-- namespaces and only show the one with the highest severity on a
-- given line
local ns = vim.api.nvim_create_namespace("my_namespace")

-- Get a reference to the original signs handler
local orig_signs_handler = vim.diagnostic.handlers.signs

-- Override the built-in signs handler
vim.diagnostic.handlers.signs = {
  show = function(_, bufnr, _, opts)
    -- Get all diagnostics from the whole buffer rather than just the
    -- diagnostics passed to the handler
    local diagnostics = vim.diagnostic.get(bufnr)

    -- Find the "worst" diagnostic per line
    local max_severity_per_line = {}
    for _, d in pairs(diagnostics) do
      local m = max_severity_per_line[d.lnum]
      if not m or d.severity < m.severity then
        max_severity_per_line[d.lnum] = d

    -- Pass the filtered diagnostics (with our custom namespace) to
    -- the original handler
    local filtered_diagnostics = vim.tbl_values(max_severity_per_line)
    -- This will result in showing diagnostics for real, bufnr, filtered_diagnostics, opts)
  hide = function(_, bufnr)
    orig_signs_handler.hide(ns, bufnr)

Diagnostic Highlights

The highlights defined for diagnotics begin with Diagnostic followed by the type of highlight and severity. For example:

  • DiagnosticSignError
  • DiagnosticUnderlineWarn

You can access these highlights via the :highlight ex command:

# show highlight
:highlight DiagnosticError
# clear highlight
:highlight clear DiagnosticError
# set highlight (see :h highlight-args for actual
# key-value pairs that are available)
:highlight DiagnosticError {key}={arg} ...
:hi DiagnosticError guifg=#db4b4b

Diagnostic Severity

:h vim.diagnostic.severity

Diagnostic signs

Neovim diagnostics defines signs for each type of diagnostic serverity. The default text for each sign is the first letter of the severity name: E, W, I, H.

Signs can be customized using the :sign ex-command:

sign define DiagnosticSignError text=E texthl=DiagnosticSignError linehl= numhl=

When the severity-sort option is set the priority of each sign depends on the severity of the diagnostic (otherwise all signs have the same priority).

Diagnostic Events

Diagnostic events can be used to configure autocommands:

  • DiagnosticChanged: diagnostics have changed
vim.api.nvim_create_autocmd('DiagnosticChanged', {
  callback = function(args)
    local diagnostics =
    -- print diagnostics as a message


The vim.diagnostic api lives under the vim.diagnostic namespace. So all methods before should be prepended with vim.diagnostic e.g. vim.diagnostic.config.


  • config(opts, namespace): Config diagnostics globally or for a given namespace

Diagnostics config can be provided globally, per namespace or for a single call to Each of these has more priority than the last.

The opts table contains the following properties:

  • underline: (defaults to true) Use underline for diagnostics. Alternative provide a specific severity to underline.
  • signs: (defaults to true) Use signs for diagnostics. Alternative specify severity or priority.
  • virtual_text: (default true) Use virtual text for diagnostics. There’s lots of config options for how the virtual text looks like, take a look at :h vim.diagnostic.config for more info.
  • float: Options for floating windows. See :h vim.diagnostic.open_float().
  • update_in_insert: (default false) Update diagnostics in Insert mode (if false, diagnostics are updated on InsertLeave)
  • severity_sort: (default false) Sort diagnostics by severity. This affects the order in which signs and virtual text are displayed. When true, higher severities are displayed before lower severities. You can reverse the priority with reverse.
-- The `virtual_text` config allows you to define a `format` function that
-- takes a diagnostic as input and returns a string. The return value is the
-- text used to display the diagnostic.

 if diagnostic.severity == vim.diagnostic.severity.ERROR then
   return string.format("E: %s", diagnostic.message)
 return diagnostic.message

You can call vim.diagnostic.config() to get the current global config, or vim.diagnostic.config(nil, my_namespace) to get the config for a given namespace.

Enable and Disable

  • disable(bufnr, namespace): disable diagnostics globally, in a current buffer (0) or a given buffer, and optionally for a given namespace.
  • enable(bufnr, namespace): like above but enable

Quickfix Integration

  • fromqflist(list): convert a list of quickfix items to a list of diagnostics. The list can be retrieved using getqflist() or getloclist().

Get diagnostics

  • get(bufnr, {namespace, lnum, severity}): Get current diagnostics
  • get_next(opts): Get next diagnostic closes to cursor
  • get_next_pos(opts): Get position of the next diagnostic in the current buffer (row, col).
  • get_prev(opts): Get previous diagnostic closest to the cursor.
  • get_prev_pos(opts): Get position of the previous diagnostic (row, col).
  • goto_next(opts): Move to the next diagnostic. Where some interesting properties in the opts table are:
    • namespace
    • cursor_position as (row, col) tuple
    • wrap whether to wrap around file
    • severity
    • float open float after moving
    • win_id window id
  • goto_prev(opts): Like above but move to previous diagnostic.

Interact with diagnostics

  • hide(namespace, bufrn): hide currently displayed diagnostic

Utilities to produce diagnostics

  • match(str, pat, groups, severity_map, defaults): parse a diagnostic from a string. This is something that you could use to integrate third party linters or other diagnostic producing tools
-- this example comes from :h vim.diagnostics.match.
-- You can appreciate how it uses a pattern regex to
-- extract all the portions needed to create a
-- diagnostic
local s = "WARNING filename:27:3: Variable 'foo' does not exist"
local pattern = "^(%w+) %w+:(%d+):(%d+): (.+)$"
local groups = { "severity", "lnum", "col", "message" }
vim.diagnostic.match(s, pattern, groups, { WARNING = vim.diagnostic.WARN })

Get metadata

  • diagnostic.get_namespace(): Get namespace metadata
  • diagnostic.get_namespaces(): Get current diagnostics namespaces.

Jaime González García

Written by Jaime González García , dad, husband, software engineer, ux designer, amateur pixel artist, tinkerer and master of the arcane arts. You can also find him on Twitter jabbering about random stuff.Jaime González García