Friday, January 21, 2022

A boring statusline for Neovim

There a dozen of statusline plugins for neovim: express_line, galaxyline, lualine, statusline, windline and possibly more. Each with different goals and feature sets. This article isn’t about any of them, instead we look at the built-in statusline option and how you can use it to create a boring but functional status line.

Boring and functional

What do I mean with boring and function?

The statusline isn’t there to impress people with a stunning visual. Instead it should show information when it’s relevant, and get out of the way otherwise.

I started out like many others, with a bloated statusline. It showed lots of information but over the years I trimmed it down to the bare essentials. I realized that most information on display wasn’t important enough to show all the time.

set statusline

In Vim and Neovim to configure the statusline you can use the set statusline= command. The argument is a string containing printf style symbols starting with % or plain text. The % items are placeholders and get replaced with other information. With what exactly depends on the placeholder you use.

If you always want to display the text Neovim you’d change the statusline like this: :set statusline=Neovim. That may fit the boring criteria, but it doesn’t make for a functional statusline. How do we go from there to something useful?

Show the filename

Neovim includes three placeholders to display the file name:

  • f - Shows the path to the file in the buffer, as typed or relative to the current directory.
  • F - Shows the full path to the file in the buffer.
  • t - Shows the tail of the path to the file in the buffer.

You can use it like this: :set statusline=%f.

This is a step up from a static Neovim display, but still not very functional. Let’s go step by step through my actual statusline configuration.

Dynamic statusline

First of all. I don’t have a static statusline configuration. I generate the statusline. You can use %! followed by an expression to define a function that evaluates to the actual statusline definition. This is what I use:

set statusline=%!v:lua.require'me'.statusline()

I have a me/init.lua file in ~/config/nvim/lua/. This lua module exports a statusline function:

local M = {}

function M.statusline()
  local parts = {}
  -- Parts aren't empty, see remainder of the post
  -- Instead of defining one long string I use the parts/table.concat pattern
  -- to split up the full definition into smaller more readable parts.
  -- It would also allow to add parts based on conditions
  return table.conat(parts, '')
end

return M

Language server status or filename

I’m using the built in language server client and want to see the status reports from language servers. The status updates help seeing if the language server is busy. But the status updates can be long and the width of the statusline is limited. I also want to see the file name of the active file in the buffer but folder structures can be deep and file names long. Fitting both into the statusline doesn’t always work.

I realized I don’t always need to see both at the same time. Language servers only sometimes show new status updates.

Here is what I came up with:

local M = {}

function M.statusline()
  local parts = {
    [[%<» %{luaeval("require'me'.file_or_lsp_status()")} %=]],
  }
  return table.conat(parts, '')
end

return M

Let’s walk through this definition one by one:

  • [[ Is lua syntax to start a string that can span many lines. It additionally allows to use ' and " within the string without extra escaping. That’s why I’m using it here.
  • % starts the item block
  • < indicates that if the text doesn’t fit, it should get truncated starting from the left side.
  • » has no special meaning. It’s literal text that I insert because I wanted to have some leading space instead of text starting at the border of the window.
  • %{luaeval(...)} evaluates a lua function each time the statusline gets redrawn.
  • %= is a separation point in the statusline. You can use these to separate different sections by an equal number of spaces.
  • ]] ends the lua string.

Now onto the file_or_lsp_status() function. I annotated the code with comments:

function M.file_or_lsp_status()
  -- Neovim keeps the messages send from the language server in a buffer and
  -- get_progress_messages polls the messages
  local messages = vim.lsp.util.get_progress_messages()
  local mode = api.nvim_get_mode().mode

  -- If neovim isn't in normal mode, or if there are no messages from the
  -- language server display the file name
  -- I'll show format_uri later on
  if mode ~= 'n' or vim.tbl_isempty(messages) then
    return M.format_uri(vim.uri_from_bufnr(api.nvim_get_current_buf()))
  end

  local percentage
  local result = {}
  -- Messages can have a `title`, `message` and `percentage` property
  -- The logic here renders all messages into a stringle string
  for _, msg in pairs(messages) do
    if msg.message then
      table.insert(result, msg.title .. ': ' .. msg.message)
    else
      table.insert(result, msg.title)
    end
    if msg.percentage then
      percentage = math.max(percentage or 0, msg.percentage)
    end
  end
  if percentage then
    return string.format('%03d: %s', percentage, table.concat(result, ', '))
  else
    return table.concat(result, ', ')
  end
end

I use nvim-jdtls for Java development and it allows you to open class files of the OpenJDK. It uses a special jdt:// URI scheme for that and the full URI is quite long and not exactly human readable. The format_uri function makes them readable. It turns something like jdt://contents/java.xml/[...]/ListDatatypeValidator.class into package.name::ClassName: [Class] ListDatatypeValidator and resolves regular file URIs into a relative path:

function M.format_uri(uri)
  if vim.startswith(uri, 'jdt://') then
    local package = uri:match('contents/[%a%d._-]+/([%a%d._-]+)') or ''
    local class = uri:match('contents/[%a%d._-]+/[%a%d._-]+/([%a%d$]+).class') or ''
    return string.format('%s::%s', package, class)
  else
    return vim.fn.fnamemodify(vim.uri_to_fname(uri), ':.')
  end
end

Unsaved changes and read-only

I find it useful to see whether a buffer has unsaved changes and if it’s read-only. There are placeholders available for that:

  • %m - Shows [+] if the buffer is modifiable and has unsaved changes. Shows [-] if modifiable is off.
  • %r - Shows [RO] for read-only buffers.

I add those within the first part definition:

local M = {}

function M.statusline()
  local parts = {
    [[%<» %{luaeval("require'me'.file_or_lsp_status()")} %m%r%=]],
    --                                                   ^^^^
    --                                                   this is new
  }
  return table.conat(parts, '')
end

return M

File format and file encoding

Most of the time I edit files which are UTF-8 encoded and have a unix file format. I don’t need to see this information if it fits the default, but I want to know if I’m editing a file where this isn’t the case.

Because of that, I show this information with some conditionals:

local M = {}

function M.statusline()
  local parts = {
    -- This is the same as before
    [[%<» %{luaeval("require'me'.file_or_lsp_status()")} %m%r%=]],

    -- New things below:

    -- %# starts a highlight group; Another # indicates the end of the highlight group name
    -- This causes the next content to display in colors (depending on the color scheme)
    "%#warningmsg#",

    -- vimL expressions can be placed into `%{ ... }` blocks
    -- The expression uses a conditional (ternary) operator: <condition> ? <truthy> : <falsy>
    -- If the current file format is not 'unix', display it surrounded by [], otherwise show nothing
    "%{&ff!='unix'?'['.&ff.'] ':''}",

    -- Resets the highlight group
    "%*",

    "%#warningmsg#",
    -- Same as before with the file format, except for the file encoding and checking for `utf-8`
    "%{(&fenc!='utf-8'&&&fenc!='')?'['.&fenc.'] ':''}",
    "%*",
  }
  return table.conat(parts, '')
end

return M

nvim-dap status

I use nvim-dap for debugging and I like to see if there is an active debug session. It requires another luaeval item:

function M.statusline()
  local parts = {
    -- here goes the parts from before

    [[%{luaeval("require'dap'.status()")} %=]],
  }
  return table.concat(parts, '')
end

Diagnostic errors and warnings

The last part of my statusline definition. I like to see an indicator of how many errors and warnings are within a buffer. It requires another luaeval expression:

function M.statusline()
  local parts = {
    -- here goes the parts from before

    [[%{luaeval("require'me'.diagnostic_status()")}]],
  }
  return table.concat(parts, '')
end

function M.diagnostic_status()
  -- count the number of diagnostics with severity warning
  local num_errors = #vim.diagnostic.get(0, { severity = vim.diagnostic.severity.ERROR })
  -- If there are any errors only show the error count, don't include the number of warnings
  if num_errors > 0 then
    return ' 💀 ' .. num_errors .. ' '
  end
  -- Otherwise show amount of warnings, or nothing if there aren't any.
  local num_warnings = #vim.diagnostic.get(0, { severity = vim.diagnostic.severity.WARN })
  if num_warnings > 0 then
    return ' 💩' .. num_warnings .. ' '
  end
  return ''
end

Wrap up

That’s it. I hope it gave you some inspiration on how you could customize your own statusline.

Don’t forget to take a look at the documentation of the statusline in :help statusline to see all available placeholders.