Repository: nvim-lualine/lualine.nvim (lua)

Issue:
Feat: provide simple `lsp_status` component
### Requested feature

Lualine should provide a simple `lsp_status` component, which displays the list of active LSPs in the current buffer, and a simple spinner / done symbol to indicate that the LSP is working.

Example screenshots:
![lualine-bashls-no-progress](https://github.com/user-attachments/assets/da44e21a-2e1f-49dd-90aa-6c38f13c2c76)
![lualine-lua_ls-done](https://github.com/user-attachments/assets/16880b41-0577-4d9b-9722-67617f6bce87)
![lualine-lua_ls-progress](https://github.com/user-attachments/assets/e899aeb0-5093-4414-8262-82f61b6fa4b1)

### Motivation

There are various plugins that display verbose LSP status and progress in the status line:
- https://github.com/nvim-lua/lsp-status.nvim (deprecated, unmaintained)
- https://github.com/arkav/lualine-lsp-progress (deprecated, unmaintained)
- https://github.com/linrongbin16/lsp-progress.nvim

The only active plugin, `linrongbin16/lsp-progress.nvim`, displays overly-verbose information that is almost guaranteed to not fit on the status line. Configuration is very clunky, having to effectively rewrite the entire format function just to skip a component.

It's very useful to have the list of active LSPs (most oftenly just one), with a spinner to indicate active work / progress, but without the verbose LSP messages. No plugins provide this out-of-the-box, and configuration to achieve this is overly-complicated.

In line with the LSP diagnostics, Git branch, and other helpful, simple components, Lualine should provide an `lsp_status` component.

Edit: just discovered that https://github.com/echasnovski/mini.statusline supports an LSP status component, which lists the active LSPs (same as this PR but without the progress spinner). So this PR brings feature parity to Lualine.

Pull request:
Closes https://github.com/nvim-lualine/lualine.nvim/issues/1375.

Following an example of a simple LSP progress using notifications: https://github.com/folke/snacks.nvim/blob/main/docs/notifier.md#-examples, we implement a simple `lsp_status` component that:
- Defines an `autocmd` on `LspProgress` to update the spinner state and refreshes Lualine if the state changed
- Displays the list of active LSPs with the corresponding spinner state if supported by the LSP

Not all LSPs support progress (e.g., `bashls` does not). In that case, we simply skip the spinner and display only the LSP name.

LSPs are separated by a configurable delimiter. The progress state symbols are also configurable.

Screenshots:
![lualine-bashls-no-progress](https://github.com/user-attachments/assets/da44e21a-2e1f-49dd-90aa-6c38f13c2c76)
![lualine-lua_ls-done](https://github.com/user-attachments/assets/16880b41-0577-4d9b-9722-67617f6bce87)
![lualine-lua_ls-progress](https://github.com/user-attachments/assets/e899aeb0-5093-4414-8262-82f61b6fa4b1)

NOTE: I updated the GitHub actions to use Ubuntu 22.04. The 20.04 image is deprecated (see https://github.com/actions/runner-images/issues/11101), and `nvim-nightly` doesn't support it anymore (building `nvim` fails because the `glibc` version is too old).

Patch:
diff --git a/README.md b/README.md
index 74a4c3c73..cba0e5209 100644
--- a/README.md
+++ b/README.md
@@ -269,6 +269,7 @@ sections = {lualine_a = {'mode'}}
 - `selectioncount` (number of selected characters or lines)
 - `tabs` (shows currently available tabs)
 - `windows` (shows currently available windows)
+- `lsp_status` (shows active LSPs in the current buffer and a progress spinner)
 
 #### Custom components
 
@@ -793,6 +794,29 @@ sections = {
 }
 ```
 
+#### lsp status component options
+
+```lua
+sections = {
+  lualine_a = {
+    {
+      'lsp_status',
+      icon = '', -- f013
+      symbols = {
+        -- Standard unicode symbols to cycle through for LSP progress:
+        spinner = { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' },
+        -- Standard unicode symbol for when LSP is done:
+        done = '✓',
+        -- Delimiter inserted between LSP names:
+        separator = ' ',
+      },
+      -- List of LSP names to ignore (e.g., `null-ls`):
+      ignore_lsp = {},
+    }
+  }
+}
+```
+
 ---
 
 ### Tabline
diff --git a/lua/lualine/components/lsp_status.lua b/lua/lualine/components/lsp_status.lua
new file mode 100644
index 000000000..216e6abe8
--- /dev/null
+++ b/lua/lualine/components/lsp_status.lua
@@ -0,0 +1,103 @@
+local lualine_require = require('lualine_require')
+
+local M = lualine_require.require('lualine.component'):extend()
+
+local default_options = {
+  icon = '', -- f013
+  symbols = {
+    -- Use standard unicode characters for the spinner and done symbols:
+    spinner = { '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏' },
+    done = '✓',
+    separator = ' ',
+  },
+  -- List of LSP names to ignore (e.g., `null-ls`):
+  ignore_lsp = {},
+}
+
+---Advances the spinner every 80ms (or just once if precise time measurement is not supported) and returns the new
+---spinner symbol.
+function M:__advance_spinner()
+  if vim.uv then
+    self.__spinner_index = math.floor(vim.uv.hrtime() / (1e6 * 80))
+  else
+    self.__spinner_index = (self.__spinner_index or 0) + 1
+  end
+  self.__spinner_index = self.__spinner_index % #self.symbols.spinner
+  -- The spinner symbols table is 1-indexed.
+  return self.symbols.spinner[self.__spinner_index + 1]
+end
+
+function M:init(options)
+  -- Run `super()`.
+  M.super.init(self, options)
+
+  -- Apply default options.
+  self.options = vim.tbl_deep_extend('keep', self.options or {}, default_options)
+
+  -- Apply symbols.
+  self.symbols = self.options.symbols or {}
+
+  ---The difference between the `begin` and `end` progress events for each LSP.
+  ---
+  ---@type table<integer, integer>
+  self.lsp_work_by_client_id = {}
+
+  -- Listen to progress updates only if `nvim` supports the `LspProgress` event.
+  pcall(vim.api.nvim_create_autocmd, 'LspProgress', {
+    desc = 'Update the Lualine LSP status component with progress',
+    group = vim.api.nvim_create_augroup('lualine_lsp_progress', {}),
+    ---@param event {data: {client_id: integer, params: lsp.ProgressParams}}
+    callback = function(event)
+      local kind = event.data.params.value.kind
+      local client_id = event.data.client_id
+
+      local work = self.lsp_work_by_client_id[client_id] or 0
+      local work_change = kind == 'begin' and 1 or (kind == 'end' and -1 or 0)
+
+      self.lsp_work_by_client_id[client_id] = math.max(work + work_change, 0)
+
+      -- Refresh Lualine to update the LSP status symbol if it changed.
+      if (work == 0 and work_change > 0) or (work == 1 and work_change < 0) then
+        require('lualine').refresh()
+      end
+    end,
+  })
+end
+
+function M:update_status()
+  local bufnr = vim.api.nvim_get_current_buf()
+  local result = {}
+
+  -- Retrieve the active LSPs in the current buffer in a backwards-compatible way.
+  local clients
+  if vim.lsp.get_clients then
+    clients = vim.lsp.get_clients { bufnr = bufnr }
+  else
+    ---@diagnostic disable-next-line: deprecated
+    clients = vim.lsp.get_active_clients { bufnr = bufnr }
+  end
+
+  -- Advance the spinner only once, not for each client (otherwise the spinners will skip steps).
+  local spinner_symbol = self:__advance_spinner()
+
+  for _, client in ipairs(clients) do
+    local status
+    local work = self.lsp_work_by_client_id[client.id]
+    if work ~= nil and work > 0 then
+      status = spinner_symbol
+    elseif work ~= nil and work == 0 then
+      status = self.symbols.done
+    end
+
+    -- Backwards-compatible function to check if a list contains a value.
+    local list_contains = vim.list_contains or vim.tbl_contains
+
+    -- Append the status to the LSP only if it supports progress reporting and is not ignored.
+    if not list_contains(self.options.ignore_lsp, client.name) then
+      table.insert(result, client.name .. (status and ' ' .. status or ''))
+    end
+  end
+  return table.concat(result, self.symbols.separator)
+end
+
+return M


Test patch:
diff --git a/tests/spec/component_spec.lua b/tests/spec/component_spec.lua
index 3311b95b1..d25a5992e 100644
--- a/tests/spec/component_spec.lua
+++ b/tests/spec/component_spec.lua
@@ -872,3 +872,137 @@ describe('Branch component', function()
     assert_component('branch', opts, rev)
   end)
 end)
+
+describe('lsp_status component', function()
+  local assert_comp_ins = helpers.assert_component_instance
+  local tmpdir
+  local file
+  local opts = build_component_opts {
+    component_separators = { left = '', right = '' },
+    padding = 0,
+    ignore_lsp = { "null-ls" },
+  }
+  local lsp_status_comp
+
+  before_each(function()
+    require('lualine').setup {}
+    tmpdir = os.tmpname()
+    file = os.tmpname() .. '/test.lua'
+    vim.cmd([[
+      augroup lualine
+        autocmd!
+      augroup END
+    ]])
+    lsp_status_comp = helpers.init_component('lsp_status', opts)
+  end)
+
+  after_each(function()
+    os.remove(tmpdir)
+  end)
+
+  it('is empty when no LSP', function()
+    vim.cmd('edit ' .. file)
+    stub(vim.lsp, 'get_clients')
+    vim.lsp.get_clients.on_call_with({ bufnr = vim.api.nvim_get_current_buf() }).returns {}
+
+    assert_comp_ins(lsp_status_comp, '')
+  end)
+
+  it('is empty when LSP is ignored', function()
+    vim.cmd('edit ' .. file)
+    stub(vim.lsp, 'get_clients')
+    vim.lsp.get_clients.on_call_with({ bufnr = vim.api.nvim_get_current_buf() }).returns { { id = 2, name = 'null-ls' } }
+
+    assert_comp_ins(lsp_status_comp, '')
+  end)
+
+  it('shows LSP name and icon when attached', function()
+    vim.cmd('edit ' .. file)
+    stub(vim.lsp, 'get_clients')
+    vim.lsp.get_clients.on_call_with({ bufnr = vim.api.nvim_get_current_buf() }).returns { { id = 2, name = 'lua_ls' } }
+
+    vim.cmd('edit ' .. file)
+
+    assert_comp_ins(lsp_status_comp, ' lua_ls')
+  end)
+
+  it('shows LSP name and icon in older nvim version', function()
+    vim.cmd('edit ' .. file)
+    vim.lsp.get_clients = nil
+    stub(vim.lsp, 'get_active_clients')
+    ---@diagnostic disable-next-line: deprecated
+    vim.lsp.get_active_clients
+      .on_call_with({ bufnr = vim.api.nvim_get_current_buf() })
+      .returns { { id = 2, name = 'lua_ls' } }
+
+    assert_comp_ins(lsp_status_comp, ' lua_ls')
+  end)
+
+  it('shows LSP progress when supported with precise time measurement', function()
+    vim.cmd('edit ' .. file)
+    stub(vim.lsp, 'get_clients')
+    vim.lsp.get_clients.on_call_with({ bufnr = vim.api.nvim_get_current_buf() }).returns { { id = 2, name = 'lua_ls' } }
+    vim.uv = vim.uv or {}
+    stub(vim.uv, 'hrtime')
+    vim.uv.hrtime.on_call_with().returns(12 * 1e6 * 80)
+
+    local ok = pcall(vim.api.nvim_exec_autocmds, 'LspProgress', {
+      data = { client_id = 2, params = { value = { kind = 'begin' } } },
+    })
+
+    -- Skip assertion if LSP progress updates are not supported by the current `nvim` version.
+    if ok then
+      -- Use spinner symbol 12 (time) % 10 (symbol table size) = 2.
+      assert_comp_ins(lsp_status_comp, ' lua_ls ⠹')
+    end
+  end)
+
+  it('shows LSP progress when supported without precise time measurement', function()
+    vim.cmd('edit ' .. file)
+    stub(vim.lsp, 'get_clients')
+    vim.lsp.get_clients.on_call_with({ bufnr = vim.api.nvim_get_current_buf() }).returns { { id = 2, name = 'lua_ls' } }
+    vim.uv = nil
+
+    local ok = pcall(vim.api.nvim_exec_autocmds, 'LspProgress', {
+      data = { client_id = 2, params = { value = { kind = 'begin' } } },
+    })
+
+    -- Skip assertion if LSP progress updates are not supported by the current `nvim` version.
+    if ok then
+      -- We start from 0 and advanced by 1, so use spinner symbol 1.
+      assert_comp_ins(lsp_status_comp, ' lua_ls ⠙')
+    end
+  end)
+
+  it('shows LSP progress when supported with precise time measurement', function()
+    vim.cmd('edit ' .. file)
+    stub(vim.lsp, 'get_clients')
+    vim.lsp.get_clients.on_call_with({ bufnr = vim.api.nvim_get_current_buf() }).returns { { id = 2, name = 'lua_ls' } }
+
+    local ok = pcall(vim.api.nvim_exec_autocmds, 'LspProgress', {
+      data = { client_id = 2, params = { value = { kind = 'end' } } },
+    })
+
+    -- Skip assertion if LSP progress updates are not supported by the current `nvim` version.
+    if ok then
+      assert_comp_ins(lsp_status_comp, ' lua_ls ✓')
+    end
+  end)
+
+  it('shows LSP done when supported', function()
+    vim.cmd('edit ' .. file)
+    stub(vim.lsp, 'get_clients')
+    vim.lsp.get_clients.on_call_with({ bufnr = vim.api.nvim_get_current_buf() }).returns { { id = 2, name = 'lua_ls' } }
+
+    local ok = pcall(vim.api.nvim_exec_autocmds, 'LspProgress', {
+      data = { client_id = 2, params = { value = { kind = 'begin' } } },
+    }) and pcall(vim.api.nvim_exec_autocmds, 'LspProgress', {
+      data = { client_id = 2, params = { value = { kind = 'end' } } },
+    })
+
+    -- Skip assertion if LSP progress updates are not supported by the current `nvim` version.
+    if ok then
+      assert_comp_ins(lsp_status_comp, ' lua_ls ✓')
+    end
+  end)
+end)