Neovim: Logging utilities

When writing a Neovim configuration or plugin, it sometimes can become annoying when trying to debug an issue. The available debug utilities are either print() or vim.notify(). In both cases (by default vim.notify() is the same as print()), the log messes are written to :messages, which does not allow to further select or process the printed text (as far as I'm aware).

Setup

However, thanks to the flexibility of Neovim we can write our own logging function, which writes the output to a temporary logging buffer. A rough outline of such a function could be

--- @class LogOptions
--- @field namespace string namespace the log belongs to


local M = {}

--- @param msg string|table<string> log message (either single line or array
---                                 of lines to accept vim.inspect() output)
--- @param level integer|nil log level defined in vim.log.levels
--- @param options LogOptions allows us to set different logging namespaces
M.log = function(msg, level, options)

    -- extracting a namespace to determine which buffer to log to
    local opts = options or {}
    local ns = opts.namespace or "default"

    -- find the corresponding buffer and if there is no such buffer, create one
    local buffer_name = M.buffer_name(ns)
    local buffer = M.find_log_buffer(buffer_name)
    if buffer == nil then
        buffer = vim.api.nvim_create_buf(true, true)
        vim.api.nvim_buf_set_name(buffer, buffer_name)
    end

    -- transform the integer log level to its string representation.
    local level_str = "INFO"
    for l, i in pairs(vim.log.levels) do
        if level == i  then
            level_str = l
        end
    end


    -- ensure `msg` is always a table to make processing simpler
    if type(msg) == "string" then
        msg = {msg}
    end

    -- Split `msg` on newlines, since nvim_buf_set_lines() does not like them
    msg = vim.tbl_map(function(line)
        return vim.split(line, "\n")
    end, msg)
    msg = vim.iter(msg):flatten(1):totable()

    -- for each line add the log level
    local complete_msg = vim.tbl_map(function (line)
        return "[" .. level_str .. "] " .. line
    end, msg)

    -- actually add the lines to the buffer
    vim.api.nvim_buf_set_lines(buffer, -1, -1, true, complete_msg)
end


--- @param namespace string
M.buffer_name = function (namespace)
    return "LOG-" .. namespace
end


--- @param buffer_name string
M.find_log_buffer = function(buffer_name)
    local buffer_list = vim.api.nvim_list_bufs()
    for _, buf_num in ipairs(buffer_list) do
        local name = vim.fn.bufname(buf_num)
        if name == buffer_name then
            return buf_num
        end
    end
    return nil
end


--- @param namespace string
M.create_logger = function(namespace)
    local opts = {namespace = namespace}
    local logging_func_for = function(level)
        return function(msg)
            M.log(msg, level, opts)
        end
    end

    return {
        trace = logging_func_for(vim.log.levels.TRACE),
        debug = logging_func_for(vim.log.levels.DEBUG),
        info = logging_func_for(vim.log.levels.INFO),
        warn = logging_func_for(vim.log.levels.WARN),
        error = logging_func_for(vim.log.levels.ERROR),
    }
end

This can directly be tested via

:lua require("myFileName").log("Some log message", vim.log.levels.INFO, {namespace="myNameSpace"})

which should create a new buffer called LOG-myNameSpace with the content [INFO] Some log message.

The above is just a rough idea, and could be customized further. I.e. rather than having different buffers per namespace, it could log to a single one and prefix the messages with the namespace. Or use print() if a namespace is not set. You get the idea.

How to use

There are two ways on how to use this:

  1. use the log function directly in our plugin
  2. or replace the existing vim.notify() function with our logging function.

The first option is straight forward and ensures that no other plugin will use the custom logging function:

    -- create a namespaced logger
    local log = require("myFileName").create_logger("myNameSpace")

    -- use it
    log.info("Hello World")
    log.error("That's not right")
    log.debug(vim.inspect(vim.lsp.buf))

    -- content of the corresponding LOG-myNameSpace buffer
    -- [INFO] Hello World
    -- [ERROR] That's not right
    -- [DEBUG] {
    -- [DEBUG]   add_workspace_folder = <function 1>,
    -- [DEBUG]   clear_references = <function 2>,
    -- [DEBUG]   code_action = <function 3>,
    -- [DEBUG]   completion = <function 4>,
    -- [DEBUG]   declaration = <function 5>,
    -- [DEBUG]   definition = <function 6>,
    -- [DEBUG]   document_highlight = <function 7>,
    -- [DEBUG]   document_symbol = <function 8>,
    -- [DEBUG]   execute_command = <function 9>,
    -- [DEBUG]   format = <function 10>,
    -- [DEBUG]   hover = <function 11>,
    -- [DEBUG]   implementation = <function 12>,
    -- [DEBUG]   incoming_calls = <function 13>,
    -- [DEBUG]   list_workspace_folders = <function 14>,
    -- [DEBUG]   outgoing_calls = <function 15>,
    -- [DEBUG]   references = <function 16>,
    -- [DEBUG]   remove_workspace_folder = <function 17>,
    -- [DEBUG]   rename = <function 18>,
    -- [DEBUG]   signature_help = <function 19>,
    -- [DEBUG]   type_definition = <function 20>,
    -- [DEBUG]   typehierarchy = <function 21>,
    -- [DEBUG]   workspace_symbol = <function 22>
    -- [DEBUG] }

The second option can be done by assigning the log function

vim.notify = require("myFileName").log

somewhere during the initialization of your lua dotfiles. It has the interesting side effect that other plugins using vim.notify() now also log into the temporary buffer.