Filtering Neovim Diagnostics

Nov 5, 2022

In this short tutorial I will show you how to have get fine-graied control over the level of noisiness of vim.diagnostics messages using your own defined lua functions. This will also showcase how easy it is to leverage neovim’s lua api to extend or modify the default behavior without modifying the parameteres of any instlled plugins.

This is how it will look in action:

Neovim provides a powerful diagnostics framework for displaying errors, warnings or anything provided from external tools such as LSP servers, linters, syntax checkers etc. Unfortunately the diagnostics api tends to get very noisy on some stacks or verbose LSP servers. And although I welcome its convenience I also prefer to keep my code view clean of any distraction.

Most of the time vim diagnostics are managed by third party plugins that take care of interacting with neovim’s API. For example, in my config I use ray-x/navigator.lua as the main LSP orchestrator which is also responsible of setting up diagnostics for all buffers automatically. For some languages I prefer using more targeted lsp and diagnostics handlers like rust-tools for Rust. Our solution needs to be independent of any particular plugin.

Neovim diagnostics are categorized in 4 levels of severity :

vim.diagnostic.severity.ERROR  -- of type int 1
vim.diagnostic.severity.WARN   -- 2
vim.diagnostic.severity.INFO   -- 3
vim.diagnostic.severity.HINT   -- 4

I will guide you step by step through the process of building your own diagnstics severity filter. Our function will override any other plugin that handles our diagnostics while still calling the plugin’s own logic.

Our first step is to define a function that will allow us to:

  1. Get all diagnostics for a specific buffer
  2. Filter them based on severity level
  3. Hide the diagnostics currently displayed in a buffer and replace them with our filtered list.

Let’s get going. If you are trying to learn neovim customization using lua, I recommend you follow along using this method:

We will create a file at NVIM_DIR/scratch/filter-diagnostics.lua.

One last note, you will need some diagnostics displayed for lua in order to do a live test. I recommend you setup an LSP server for lua development.

get all diagnostics in a buffer

We can use the function vim.diagnostic.get({bufnr}, {opts}) which takes a buffer number as an argument and an optional table of options which we will ignore. We get the number of our current buffer using nvim_get_current_buf. If you want the filter applied for a different buffer, you can replace bufnr with the number of your target buffer. You can get a list of buffers and their numbers using the :buffers command.

Our lua file will look like this:

local unused -- generates hint diagnostic
unused = nothing -- generates warning diagnostic

bufnr = vim.api.nvim_get_current_buf()

local set_diagnostics_level = function()
    local diagnostics = vim.diagnostic.get(bufnr)
    print(vim.inspect(diagnostics))
end

set_diagnostics_level()

First we define the function, then we make a call to it so that when we run the command :source % our function is executed. We keep the two first lines as a way to force the LSP server to generate a diagnostic for the unused variable.

Sourcing the previous code gives me the following result:

"scratch/filter-diagnostics.lua" 9L, 183B written
{ {
    bufnr = 1,
    code = "undefined-global",
    col = 9,
    end_col = 16,
    end_lnum = 1,
    lnum = 1,
    message = "Undefined global `nothing`.",
    namespace = 36,
    severity = 2,
    source = "Lua Diagnostics.",
    user_data = {
      lsp = {
        code = "undefined-global"
      }
    }
  }, {
    bufnr = 1,
    code = "unused-local",
    col = 6,
    end_col = 12,
    end_lnum = 0,
    lnum = 0,
    message = "Unused local `unused`.",
    namespace = 36,
    severity = 4,
    source = "Lua Diagnostics.",
    user_data = {
      lsp = {
        code = "unused-local",
        tags = { 1 }
      }
    }
  }, {
    bufnr = 1,
    code = "unused-local",
    col = 0,
    end_col = 6,
    end_lnum = 1,
    lnum = 1,
    message = "Unused local `unused`.",
    namespace = 36,
    severity = 4,
    source = "Lua Diagnostics.",
    user_data = {
      lsp = {
        code = "unused-local",
        tags = { 1 }
      }
    }
  } }

The result is an array of diagnostic-structure you can refer to neovim’s documentation for the details but what matters for us is the severity property that we will use to filter out the diagnostics based on a our defined level.

Let’s say we want to only display diagnostics that are of severity Warning (number 2) or more. Pay attention to the numbering, higher means less important.

local unused
unused = nothing

local filter_diagnostics = function(diagnostics, level)
    local filtered_diag = {}
    for _, d in ipairs(diagnostics) do
        if d.severity <= level then
            table.insert(filtered_diag, 1, d)
        end
    end
    return filtered_diag
end

local set_diagnostics_level = function()
    bufnr = vim.api.nvim_get_current_buf()
    local diagnostics = vim.diagnostic.get(bufnr)
    if #diagnostics > 0 then -- don't send an empty array
        local filtered = filter_diagnostics(diagnostics, vim.diagnostic.severity.WARN)
        print(vim.inspect(filtered))
    end
end

set_diagnostics_level()

We make sure we have some diagnostics before passing it to the filter function. Now our filter function outputs only one warning.

"scratch/filter-diagnostics.lua" 21L, 520B written
{
  {
    bufnr = 1,
    code = "undefined-global",
    col = 9,
    end_col = 16,
    end_lnum = 1,
    lnum = 1,
    message = "Undefined global `nothing`.",
    namespace = 36,
    severity = 2,
    source = "Lua Diagnostics.",
    user_data = {
      lsp = {
        code = "undefined-global"
      }
    }
  }
}

display the filtered diagnostics

There is no way yet to tell neovim to only hide a subset of the diagnostics. To hide all diagnostics we can call vim.diagnostic.hide(nil, bufnr) where we pass a nil namespace and bufnr buffer number.

Now in order to display only the filtered subset, we could directly call the vim.diagnostic.show function but it will display back all diagnostics, even if we pass it in our filtered list. What is going on ? When the show() method is called, a registered handler which manages the display of diagnostics does the acutal show. In my case it is navigator.lua plugin that is handling the show method.

If we read neovim’s doc, we are provided with a useful example that allows us to override the default diagnostic handler. Since we are using lua we can use monkey patching to modify the default behavior while still calling back the original handlers that were registered by other plugins. The plugins will be totally unaware that we are passing them a filtered list of diagnostics.

Neovim’s example overrides the signs handler. In our case let’s say we only want to filter the diagnostics displayed with a virtual text, which are handled by vim.diagnostic.handlers.virtual_text.

Take a look at the function below:

-- save the original diagnostics handler
local orig_diag_virt_handler = vim.diagnostic.handlers.virtual_text

-- define our custom diagnostics namespace
local ns = vim.api.nvim_create_namespace("my_diagnostics")

local set_diagnostics_level = function(level)
    -- Register our custom handler
    vim.diagnostic.handlers.virtual_text = {
        -- our custom show method
        show = function(_, bufnr, _, opts)
            -- get all diagnostics for local buffer
            local diagnostics = vim.diagnostic.get(bufnr)
            -- filter diags based on severity
            filtered = filter_diagnostics(diagnostics, level)
            orig_diag_virt_handler.show(ns, bufnr, filtered, opts)
        end,
        hide = function(_, bufnr)
            orig_diag_virt_handler.hide(ns, bufnr)
        end
    }

    bufnr = vim.api.nvim_get_current_buf()
    -- hide all diagnostics
    vim.diagnostic.hide(nil, bufnr) 
    local diags = vim.diagnostic.get(bufnr)
    -- it is important to make sure we don't pass an empty table
    if #diags > 0 then
        filtered = filter_diagnostics(diags, level)
        -- we display back the filtered diagnostics until the -- registered
        -- handler is called. If we don't, all diagnostics will disappear -- until
        -- the registered handler is executed, usually after a save or insert --
        -- event
        vim.diagnostic.show(ns, bufnr, filtered)
    end
end

set_diagnostics_level(vim.diagnostic.severity.WARN)

Sourcing the file should filter out any diagnostic below the Warning level.

As a last step we will save this file in it’s own module at a path under NVIM_DIR/lua/foo/filter-diagnostics.lua where foo is a custom name of your choice. This will avoid collision with other lua modules with the same name.

local M = {}

local orig_diag_virt_handler = vim.diagnostic.handlers.virtual_text
local ns = vim.api.nvim_create_namespace("my_diagnostics")

local filter_diagnostics = function(diagnostics, level)
    local filtered_diag = {}
    for _, d in ipairs(diagnostics) do
        if d.severity <= level then
            table.insert(filtered_diag, 1, d)
        end
    end
    return filtered_diag
end

M.set_level = function(level)

    -- hide all diagnostics
    vim.diagnostic.hide(nil, 0) 

    -- vim.diagnostic.reset()
    vim.diagnostic.handlers.virtual_text = {
        show = function(_, bufnr, _, opts)
            -- get all diagnostics for local buffer
            local diagnostics = vim.diagnostic.get(bufnr)
            filtered = filter_diagnostics(diagnostics, level)
            -- filter diags based on severity
            orig_diag_virt_handler.show(ns, bufnr, filtered, opts)
        end,
        hide = function(_, bufnr)
            orig_diag_virt_handler.hide(ns, bufnr)
        end
    }

    bufnr = vim.api.nvim_get_current_buf()
    local diags = vim.diagnostic.get(bufnr)
    if #diags > 0 then
        filtered = filter_diagnostics(diags, level)
        vim.diagnostic.show(ns, bufnr, filtered)
    end
end

return M

We can use our filter like this from the command line:

:lua require("foo.filter-diagnostics").set_level(vim.diagnostic.severity.ERROR)

We now can easilly create a few key mappings for each filter level adjust the severity of neovim’s diagnostics on any buffer with a few keystrokes. One neat trick: since diangostic levels start at 1 with higher meaning more details, we if we pass level = 0 to our filter function it will hide all diagnostics altogether.

For example you can add the following to your init.lua or wherever you define your keymaps:

local function set_diangnostics_keymap(lhs, level)
    local dfilter = require("foo.filter-diagnostics")
    vim.keymap.set('n', lhs, function()
        dfilter.set_level(level)
    end,
    { desc = "set diagnostic filter to " .. level }
    )
end

set_diangnostics_keymap("<leader>dge", vim.diagnostic.severity.ERROR)
set_diangnostics_keymap("<leader>dgw", vim.diagnostic.severity.WARN)
set_diangnostics_keymap("<leader>dgi", vim.diagnostic.severity.INFO)
set_diangnostics_keymap("<leader>dgh", vim.diagnostic.severity.HINT)
-- we can also add a mapping for level == 0 which will
-- effectively disable all diagnostics
set_diangnostics_keymap("<leader>dgH", 0)