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 description:
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)