commit f5931102f8c8d859930eb205088d83cab3efee01
parent 6f733f4a9bf01d40235774e553f193facc33945f
Author: Justin M. Keyes <justinkz@gmail.com>
Date: Tue, 3 Feb 2026 09:46:37 -0500
Merge #37626 feat(lsp)!: textDocument/codeLens as decoration provider
Diffstat:
9 files changed, 743 insertions(+), 630 deletions(-)
diff --git a/runtime/doc/deprecated.txt b/runtime/doc/deprecated.txt
@@ -42,6 +42,11 @@ LSP
• *vim.lsp.get_buffers_by_client_id()* Use `vim.lsp.get_client_by_id(id).attached_buffers`
instead
• *vim.lsp.stop_client()* Use |Client:stop()| instead
+• *vim.lsp.codelens.refresh()* Use `vim.lsp.codelens.enable(true)` instead
+• *vim.lsp.codelens.clear()* Use `vim.lsp.codelens.enable(false)` instead
+• *vim.lsp.codelens.display()*
+• *vim.lsp.codelens.save()*
+• *vim.lsp.codelens.on_codelens()*
LUA
diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt
@@ -1904,64 +1904,58 @@ Client:supports_method({method}, {bufnr}) *Client:supports_method()*
==============================================================================
Lua module: vim.lsp.codelens *lsp-codelens*
-clear({client_id}, {bufnr}) *vim.lsp.codelens.clear()*
- Clear the lenses
+enable({enable}, {filter}) *vim.lsp.codelens.enable()*
+ Enables or disables code lens for the {filter}ed scope.
- Parameters: ~
- • {client_id} (`integer?`) filter by client_id. All clients if nil
- • {bufnr} (`integer?`) filter by buffer. All buffers if nil, 0 for
- current buffer
+ To "toggle", pass the inverse of `is_enabled()`: >lua
+ vim.lsp.codelens.enable(not vim.lsp.codelens.is_enabled())
+<
-display({lenses}, {bufnr}, {client_id}) *vim.lsp.codelens.display()*
- Display the lenses using virtual text
+ To run a code lens, see |vim.lsp.codelens.run()|.
Parameters: ~
- • {lenses} (`lsp.CodeLens[]?`) lenses to display
- • {bufnr} (`integer`)
- • {client_id} (`integer`)
+ • {enable} (`boolean?`) true/nil to enable, false to disable
+ • {filter} (`table?`) Optional filters |kwargs|,
+ • {bufnr}? (`integer`, default: all) Buffer number, or 0 for
+ current buffer, or nil for all.
+ • {client_id}? (`integer`, default: all) Client ID, or nil
+ for all.
-get({bufnr}) *vim.lsp.codelens.get()*
- Return all lenses for the given buffer
+get({filter}) *vim.lsp.codelens.get()*
+ Get all code lenses in the {filter}ed scope.
Parameters: ~
- • {bufnr} (`integer`) Buffer number. 0 can be used for the current
- buffer.
+ • {filter} (`table?`) Optional filters |kwargs|:
+ • {bufnr}? (`integer`, default: 0) Buffer handle, or 0 for
+ current.
+ • {client_id}? (`integer`, default: all) Client ID, or nil
+ for all.
Return: ~
- (`lsp.CodeLens[]`)
-
-on_codelens({err}, {result}, {ctx}) *vim.lsp.codelens.on_codelens()*
- |lsp-handler| for the method `textDocument/codeLens`
-
- Parameters: ~
- • {err} (`lsp.ResponseError?`)
- • {result} (`lsp.CodeLens[]`)
- • {ctx} (`lsp.HandlerContext`)
-
-refresh({opts}) *vim.lsp.codelens.refresh()*
- Refresh the lenses.
-
- It is recommended to trigger this using an autocmd or via keymap.
+ (`table[]`) A list of objects with the following fields:
+ • {client_id} (`integer`)
+ • {lens} (`lsp.CodeLens`)
- Example: >vim
- autocmd BufEnter,CursorHold,InsertLeave <buffer> lua vim.lsp.codelens.refresh({ bufnr = 0 })
-<
+is_enabled({filter}) *vim.lsp.codelens.is_enabled()*
+ Query whether code lens is enabled in the {filter}ed scope
Parameters: ~
- • {opts} (`table?`) Optional fields
- • {bufnr} (`integer?`) filter by buffer. All buffers if nil, 0
- for current buffer
+ • {filter} (`table?`) Optional filters |kwargs|,
+ • {bufnr}? (`integer`, default: all) Buffer number, or 0 for
+ current buffer, or nil for all.
+ • {client_id}? (`integer`, default: all) Client ID, or nil
+ for all.
-run() *vim.lsp.codelens.run()*
- Run the code lens available in the current line.
+ Return: ~
+ (`boolean`) whether code lens is enabled.
-save({lenses}, {bufnr}, {client_id}) *vim.lsp.codelens.save()*
- Store lenses for a specific buffer and client
+run({opts}) *vim.lsp.codelens.run()*
+ Run code lens above the current cursor position.
Parameters: ~
- • {lenses} (`lsp.CodeLens[]?`) lenses to store
- • {bufnr} (`integer`)
- • {client_id} (`integer`)
+ • {opts} (`table?`) Optional parameters |kwargs|:
+ • {client_id}? (`integer`, default: all) Client ID, or nil for
+ all.
==============================================================================
diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt
@@ -299,6 +299,10 @@ LSP
• Support for dynamic registration for `textDocument/diagnostic`
• |vim.lsp.buf.rename()| now highlights the symbol being renamed using the
|hl-LspReferenceTarget| highlight group.
+• Support for `textDocument/codeLens` |lsp-codelens| has been reimplemented:
+ https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_codeLens
+• Support for `workspace/codeLens/refresh`:
+ https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeLens_refresh
LUA
diff --git a/runtime/lua/vim/lsp/_capability.lua b/runtime/lua/vim/lsp/_capability.lua
@@ -1,6 +1,7 @@
local api = vim.api
---@alias vim.lsp.capability.Name
+---| 'codelens'
---| 'semantic_tokens'
---| 'folding_range'
---| 'linked_editing_range'
diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua
@@ -3,352 +3,488 @@ local log = require('vim.lsp.log')
local api = vim.api
local M = {}
---- bufnr → true|nil
---- to throttle refreshes to at most one at a time
-local active_refreshes = {} --- @type table<integer,true>
-
----@type table<integer, table<integer, lsp.CodeLens[]>>
---- bufnr -> client_id -> lenses
-local lens_cache_by_buf = setmetatable({}, {
- __index = function(t, b)
- local key = b > 0 and b or api.nvim_get_current_buf()
- return rawget(t, key)
- end,
-})
+local Capability = require('vim.lsp._capability')
----@type table<integer, integer>
----client_id -> namespace
-local namespaces = setmetatable({}, {
- __index = function(t, key)
- local value = api.nvim_create_namespace('nvim.lsp.codelens:' .. key)
- rawset(t, key, value)
- return value
- end,
-})
+---@class (private) vim.lsp.codelens.ClientState
+---@field row_lenses table<integer, lsp.CodeLens[]?> row -> lens
+---@field namespace integer
----@private
-M.__namespaces = namespaces
+---@class (private) vim.lsp.codelens.Provider : vim.lsp.Capability
+---@field active table<integer, vim.lsp.codelens.Provider?>
+---
+--- `TextDocument` version current state corresponds to.
+---@field version? integer
+---
+--- Last version of codelens applied to this line.
+---
+--- Index In the form of row -> true?
+---@field row_version table<integer, integer?>
+---
+--- Index In the form of client_id -> client_state
+---@field client_state? table<integer, vim.lsp.codelens.ClientState?>
+---
+--- Timer for debouncing automatic requests.
+---
+---@field timer? uv.uv_timer_t
+local Provider = {
+ name = 'codelens',
+ method = 'textDocument/codeLens',
+ active = {},
+}
+Provider.__index = Provider
+setmetatable(Provider, Capability)
+Capability.all[Provider.name] = Provider
+
+---@package
+---@param bufnr integer
+---@return vim.lsp.codelens.Provider
+function Provider:new(bufnr)
+ ---@type vim.lsp.codelens.Provider
+ self = Capability.new(self, bufnr)
+ self.client_state = {}
+ self.row_version = {}
+
+ api.nvim_buf_attach(bufnr, false, {
+ on_lines = function(_, buf)
+ local provider = Provider.active[buf]
+ if not provider then
+ return true
+ end
+ provider:automatic_request()
+ end,
+ on_reload = function(_, buf)
+ local provider = Provider.active[buf]
+ if provider then
+ provider:automatic_request()
+ end
+ end,
+ })
-local augroup = api.nvim_create_augroup('nvim.lsp.codelens', {})
+ return self
+end
-api.nvim_create_autocmd('LspDetach', {
- group = augroup,
- callback = function(ev)
- M.clear(ev.data.client_id, ev.buf)
- end,
-})
+---@package
+---@param client_id integer
+function Provider:on_attach(client_id)
+ local state = self.client_state[client_id]
+ if not state then
+ state = {
+ namespace = api.nvim_create_namespace('nvim.lsp.codelens:' .. client_id),
+ row_lenses = {},
+ }
+ self.client_state[client_id] = state
+ end
+ self:request(client_id)
+end
----@param lens lsp.CodeLens
----@param bufnr integer
+---@package
---@param client_id integer
-local function execute_lens(lens, bufnr, client_id)
- local line = lens.range.start.line
- api.nvim_buf_clear_namespace(bufnr, namespaces[client_id], line, line + 1)
-
- local client = vim.lsp.get_client_by_id(client_id)
- assert(client, 'Client is required to execute lens, client_id=' .. client_id)
- client:exec_cmd(lens.command, { bufnr = bufnr }, function(...)
- vim.lsp.handlers['workspace/executeCommand'](...)
- M.refresh()
- end)
+function Provider:on_detach(client_id)
+ local state = self.client_state[client_id]
+ if state then
+ api.nvim_buf_clear_namespace(self.bufnr, state.namespace, 0, -1)
+ self.client_state[client_id] = nil
+ end
end
---- Return all lenses for the given buffer
+--- `lsp.Handler` for `textDocument/codeLens`.
---
----@param bufnr integer Buffer number. 0 can be used for the current buffer.
----@return lsp.CodeLens[]
-function M.get(bufnr)
- local lenses_by_client = lens_cache_by_buf[bufnr or 0]
- if not lenses_by_client then
- return {}
+---@package
+---@param err? lsp.ResponseError
+---@param result? lsp.CodeLens[]
+---@param ctx lsp.HandlerContext
+function Provider:handler(err, result, ctx)
+ local state = self.client_state[ctx.client_id]
+ if not state then
+ return
+ end
+
+ if err then
+ log.error('codelens', err)
+ return
end
- local lenses = {}
- for _, client_lenses in pairs(lenses_by_client) do
- vim.list_extend(lenses, client_lenses)
+
+ if util.buf_versions[self.bufnr] ~= ctx.version then
+ return
+ end
+
+ ---@type table<integer, lsp.CodeLens[]>
+ local row_lenses = {}
+
+ -- Code lenses should only span a single line.
+ for _, lens in ipairs(result or {}) do
+ local row = lens.range.start.line
+ local lenses = row_lenses[row] or {}
+ table.insert(lenses, lens)
+ row_lenses[row] = lenses
end
- return lenses
+
+ state.row_lenses = row_lenses
+ self.version = ctx.version
end
---- Run the code lens available in the current line.
-function M.run()
- local line = api.nvim_win_get_cursor(0)[1] - 1
- local bufnr = api.nvim_get_current_buf()
- local options = {} --- @type {client: integer, lens: lsp.CodeLens}[]
- local lenses_by_client = lens_cache_by_buf[bufnr] or {}
- for client, lenses in pairs(lenses_by_client) do
- for _, lens in pairs(lenses) do
- if
- lens.command
- and lens.command.command ~= ''
- and lens.range.start.line <= line
- and lens.range['end'].line >= line
- then
- table.insert(options, { client = client, lens = lens })
- end
+---@package
+---@param client_id? integer
+---@param on_response? function
+function Provider:request(client_id, on_response)
+ ---@type lsp.CodeLensParams
+ local params = { textDocument = util.make_text_document_params(self.bufnr) }
+ for id in pairs(self.client_state) do
+ if not client_id or client_id == id then
+ local client = assert(vim.lsp.get_client_by_id(id))
+ client:request('textDocument/codeLens', params, function(...)
+ self:handler(...)
+
+ if on_response then
+ on_response()
+ end
+ end, self.bufnr)
end
end
- if #options == 0 then
- vim.notify('No executable codelens found at current line')
- elseif #options == 1 then
- local option = options[1]
- execute_lens(option.lens, bufnr, option.client)
- else
- vim.ui.select(options, {
- prompt = 'Code lenses:',
- kind = 'codelens',
- format_item = function(option)
- return option.lens.command.title
- end,
- }, function(option)
- if option then
- execute_lens(option.lens, bufnr, option.client)
- end
- end)
+end
+
+---@private
+function Provider:reset_timer()
+ local timer = self.timer
+ if timer then
+ self.timer = nil
+ if not timer:is_closing() then
+ timer:stop()
+ timer:close()
+ end
end
end
---- Clear the lenses
+--- Automatically request with debouncing, used as callbacks in autocmd events.
---
----@param client_id integer|nil filter by client_id. All clients if nil
----@param bufnr integer|nil filter by buffer. All buffers if nil, 0 for current buffer
-function M.clear(client_id, bufnr)
- bufnr = bufnr and vim._resolve_bufnr(bufnr)
- local buffers = bufnr and { bufnr }
- or vim.tbl_filter(api.nvim_buf_is_loaded, api.nvim_list_bufs())
- for _, iter_bufnr in pairs(buffers) do
- local client_ids = client_id and { client_id } or vim.tbl_keys(namespaces)
- for _, iter_client_id in pairs(client_ids) do
- local ns = namespaces[iter_client_id]
- -- there can be display()ed lenses, which are not stored in cache
- if lens_cache_by_buf[iter_bufnr] then
- lens_cache_by_buf[iter_bufnr][iter_client_id] = {}
+---@package
+function Provider:automatic_request()
+ self:reset_timer()
+ self.timer = vim.defer_fn(function()
+ self:request()
+ end, 200)
+end
+
+---@private
+---@param client vim.lsp.Client
+---@param unresolved_lens lsp.CodeLens
+function Provider:resolve(client, unresolved_lens)
+ ---@param resolved_lens lsp.CodeLens
+ client:request('codeLens/resolve', unresolved_lens, function(err, resolved_lens, ctx)
+ local state = self.client_state[client.id]
+ if not state then
+ return
+ end
+
+ if err then
+ log.error('codelens/resolve', err)
+ return
+ end
+
+ if util.buf_versions[self.bufnr] ~= ctx.version then
+ return
+ end
+
+ local row = unresolved_lens.range.start.line
+ local lenses = assert(state.row_lenses[row])
+ for i, lens in ipairs(lenses) do
+ if lens == unresolved_lens then
+ lenses[i] = resolved_lens
end
- api.nvim_buf_clear_namespace(iter_bufnr, ns, 0, -1)
end
- end
+
+ self.row_version[row] = nil
+ api.nvim__redraw({
+ buf = self.bufnr,
+ range = { row, row + 1 },
+ valid = true,
+ flush = false,
+ })
+ end, self.bufnr)
end
----@param lenses lsp.CodeLens[]
----@return table<integer, lsp.CodeLens[]>
-local function group_lenses_by_start_line(lenses)
- local lenses_by_lnum = {} ---@type table<integer, lsp.CodeLens[]>
- for _, lens in pairs(lenses) do
- local line_lenses = lenses_by_lnum[lens.range.start.line]
- if not line_lenses then
- line_lenses = {}
- lenses_by_lnum[lens.range.start.line] = line_lenses
+---@package
+---@param toprow integer
+---@param botrow integer
+function Provider:on_win(toprow, botrow)
+ for row = toprow, botrow do
+ if self.row_version[row] ~= self.version then
+ for client_id, state in pairs(self.client_state) do
+ local namespace = state.namespace
+
+ api.nvim_buf_clear_namespace(self.bufnr, namespace, row, row + 1)
+
+ local lenses = state.row_lenses[row]
+ if lenses then
+ table.sort(lenses, function(a, b)
+ return a.range.start.character < b.range.start.character
+ end)
+
+ ---@type [string, string][]
+ local virt_text = {}
+ for _, lens in ipairs(lenses) do
+ -- A code lens is unresolved when no command is associated to it.
+ if not lens.command then
+ local client = assert(vim.lsp.get_client_by_id(client_id))
+ self:resolve(client, lens)
+ else
+ vim.list_extend(virt_text, {
+ { lens.command.title, 'LspCodeLens' },
+ { ' | ', 'LspCodeLensSeparator' },
+ })
+ end
+ end
+ -- Remove trailing separator.
+ table.remove(virt_text)
+
+ api.nvim_buf_set_extmark(self.bufnr, namespace, row, 0, {
+ virt_text = virt_text,
+ hl_mode = 'combine',
+ })
+ end
+ self.row_version[row] = self.version
+ end
end
- table.insert(line_lenses, lens)
end
- return lenses_by_lnum
end
----@param bufnr integer
----@param ns integer
----@param line integer
----@param lenses lsp.CodeLens[] Lenses that start at `line`
-local function display_line_lenses(bufnr, ns, line, lenses)
- local chunks = {}
- local num_lenses = #lenses
- table.sort(lenses, function(a, b)
- return a.range.start.character < b.range.start.character
- end)
-
- local has_unresolved = false
- for i, lens in ipairs(lenses) do
- if lens.command then
- local text = lens.command.title:gsub('%s+', ' ')
- table.insert(chunks, { text, 'LspCodeLens' })
- if i < num_lenses then
- table.insert(chunks, { ' | ', 'LspCodeLensSeparator' })
- end
- else
- has_unresolved = true
+local namespace = api.nvim_create_namespace('nvim.lsp.codelens')
+api.nvim_set_decoration_provider(namespace, {
+ on_win = function(_, _, bufnr, toprow, botrow)
+ local provider = Provider.active[bufnr]
+ if provider then
+ provider:on_win(toprow, botrow)
end
- end
+ end,
+})
- -- If some lenses are not resolved yet, don't update the line's virtual text. Due to this, user
- -- may see outdated lenses or not see already resolved lenses. However, showing outdated lenses
- -- for short period of time is better than spamming user with virtual text updates.
- if has_unresolved then
- return
- end
+--- Query whether code lens is enabled in the {filter}ed scope
+---
+---@param filter? vim.lsp.capability.enable.Filter
+---@return boolean whether code lens is enabled.
+function M.is_enabled(filter)
+ return vim.lsp._capability.is_enabled('codelens', filter)
+end
- api.nvim_buf_clear_namespace(bufnr, ns, line, line + 1)
- if #chunks > 0 then
- api.nvim_buf_set_extmark(bufnr, ns, line, 0, {
- virt_text = chunks,
- hl_mode = 'combine',
- })
- end
+--- Enables or disables code lens for the {filter}ed scope.
+---
+--- To "toggle", pass the inverse of `is_enabled()`:
+---
+--- ```lua
+--- vim.lsp.codelens.enable(not vim.lsp.codelens.is_enabled())
+--- ```
+---
+--- To run a code lens, see |vim.lsp.codelens.run()|.
+---
+---@param enable? boolean true/nil to enable, false to disable
+---@param filter? vim.lsp.capability.enable.Filter
+function M.enable(enable, filter)
+ vim.lsp._capability.enable('codelens', enable, filter)
end
---- Display the lenses using virtual text
+--- Optional filters |kwargs|:
+---@class vim.lsp.codelens.get.Filter
+---@inlinedoc
---
----@param lenses? lsp.CodeLens[] lenses to display
----@param bufnr integer
----@param client_id integer
-function M.display(lenses, bufnr, client_id)
- if not api.nvim_buf_is_loaded(bufnr) then
- return
+--- Buffer handle, or 0 for current.
+--- (default: 0)
+---@field bufnr? integer
+---
+--- Client ID, or nil for all.
+--- (default: all)
+---@field client_id? integer
+
+---@class vim.lsp.codelens.get.Result
+---@inlinedoc
+---@field client_id integer
+---@field lens lsp.CodeLens
+
+--- Get all code lenses in the {filter}ed scope.
+---
+---@param filter? vim.lsp.codelens.get.Filter
+---@return vim.lsp.codelens.get.Result[]
+function M.get(filter)
+ if type(filter) == 'number' then
+ vim.deprecate(
+ 'vim.lsp.codelens.get(bufnr)',
+ 'vim.lsp.codelens.get({ bufnr = bufnr })',
+ '0.13.0'
+ )
+ local bufnr = vim._resolve_bufnr(filter)
+ local provider = Provider.active[bufnr]
+ if not provider then
+ return {}
+ end
+ ---@type lsp.CodeLens[]
+ local result = {}
+ for _, state in pairs(provider.client_state) do
+ for _, lenses in pairs(state.row_lenses) do
+ result = vim.list_extend(result, lenses)
+ end
+ end
+ return result
end
- local ns = namespaces[client_id]
- if not lenses or not next(lenses) then
- api.nvim_buf_clear_namespace(bufnr, ns, 0, -1)
- return
+ vim.validate('filter', filter, 'table', true)
+ filter = filter or {}
+
+ local bufnr = vim._resolve_bufnr(filter.bufnr)
+ local provider = Provider.active[bufnr]
+ if not provider then
+ return {}
end
- local lenses_by_lnum = group_lenses_by_start_line(lenses)
- local num_lines = api.nvim_buf_line_count(bufnr)
- for i = 0, num_lines do
- display_line_lenses(bufnr, ns, i, lenses_by_lnum[i] or {})
+ local result = {}
+ for client_id, state in pairs(provider.client_state) do
+ if not filter.client_id or filter.client_id == client_id then
+ for _, lenses in pairs(state.row_lenses) do
+ for _, lens in ipairs(lenses) do
+ table.insert(result, { client_id = client_id, lens = lens })
+ end
+ end
+ end
end
+ return result
end
---- Store lenses for a specific buffer and client
+--- Optional parameters |kwargs|:
+---@class vim.lsp.codelens.run.Opts
+---@inlinedoc
---
----@param lenses? lsp.CodeLens[] lenses to store
----@param bufnr integer
----@param client_id integer
-function M.save(lenses, bufnr, client_id)
- if not api.nvim_buf_is_loaded(bufnr) then
- return
- end
+--- Client ID, or nil for all.
+--- (default: all)
+---@field client_id? integer
- local lenses_by_client = lens_cache_by_buf[bufnr]
- if not lenses_by_client then
- lenses_by_client = {}
- lens_cache_by_buf[bufnr] = lenses_by_client
- local ns = namespaces[client_id]
- api.nvim_buf_attach(bufnr, false, {
- on_detach = function(_, b)
- lens_cache_by_buf[b] = nil
- end,
- on_lines = function(_, b, _, first_lnum, last_lnum)
- api.nvim_buf_clear_namespace(b, ns, first_lnum, last_lnum)
- end,
- })
- end
- lenses_by_client[client_id] = lenses
-end
+--- Run code lens above the current cursor position.
+---
+---@param opts? vim.lsp.codelens.run.Opts
+function M.run(opts)
+ vim.validate('opts', opts, 'table', true)
+ opts = opts or {}
----@param lenses? lsp.CodeLens[]
----@param bufnr integer
----@param client_id integer
----@param callback fun()
-local function resolve_lenses(lenses, bufnr, client_id, callback)
- lenses = lenses or {}
- local num_lens = vim.tbl_count(lenses)
- if num_lens == 0 then
- callback()
+ local winid = api.nvim_get_current_win()
+ local bufnr = api.nvim_win_get_buf(winid)
+ local pos = vim.pos.cursor(api.nvim_win_get_cursor(winid))
+ local provider = Provider.active[bufnr]
+ if not provider then
return
end
- ---@param n integer
- local function countdown(n)
- num_lens = num_lens - n
- if num_lens == 0 then
- callback()
- end
- end
-
- local ns = namespaces[client_id]
- local client = vim.lsp.get_client_by_id(client_id)
-
- -- Resolve all lenses in a line, then display them.
- local lenses_by_lnum = group_lenses_by_start_line(lenses)
- for line, line_lenses in pairs(lenses_by_lnum) do
- local num_resolved_line_lenses = 0
- local function display_line_countdown()
- num_resolved_line_lenses = num_resolved_line_lenses + 1
- if num_resolved_line_lenses == #line_lenses then
- if api.nvim_buf_is_valid(bufnr) and line <= api.nvim_buf_line_count(bufnr) then
- display_line_lenses(bufnr, ns, line, line_lenses)
+ ---@type [integer, lsp.CodeLens][]
+ local items = {}
+ for client_id, state in pairs(provider.client_state) do
+ if not opts.client_id or opts.client_id == client_id then
+ for _, lens in ipairs(state.row_lenses[pos.row] or {}) do
+ -- Ignore unresolved and empty command lenses.
+ if lens.command and lens.command.command ~= '' then
+ table.insert(items, { client_id, lens })
end
- countdown(#line_lenses)
end
end
+ end
- for _, lens in pairs(line_lenses) do
- if lens.command then
- display_line_countdown()
- else
- assert(client)
- client:request('codeLens/resolve', lens, function(_, result)
- if api.nvim_buf_is_loaded(bufnr) and result and result.command then
- lens.command = result.command
- end
- display_line_countdown()
- end, bufnr)
+ if #items == 0 then
+ vim.notify('No code lens avaliable')
+ return
+ elseif #items == 1 then
+ local client_id, lens = unpack(items[1])
+ local client = assert(vim.lsp.get_client_by_id(client_id))
+ client:exec_cmd(lens.command)
+ else
+ vim.ui.select(items, {
+ prompt = 'Code Lens',
+ ---@param item [integer, lsp.CodeLens]
+ format_item = function(item)
+ local client_id, lens = unpack(item)
+ local client = assert(vim.lsp.get_client_by_id(client_id))
+ return ('%s [%s]'):format(lens.command.title, client.name)
+ end,
+ }, function(item)
+ if item then
+ local client_id, lens = unpack(item)
+ local client = assert(vim.lsp.get_client_by_id(client_id))
+ client:exec_cmd(lens.command)
end
- end
+ end)
end
end
---- |lsp-handler| for the method `textDocument/codeLens`
+--- |lsp-handler| for the method `workspace/codeLens/refresh`
---
----@param err lsp.ResponseError?
----@param result lsp.CodeLens[]
----@param ctx lsp.HandlerContext
-function M.on_codelens(err, result, ctx)
- local bufnr = assert(ctx.bufnr)
-
+---@private
+---@type lsp.Handler
+function M.on_refresh(err, _, ctx)
if err then
- active_refreshes[bufnr] = nil
- log.error('codelens', err)
- return
+ return vim.NIL
end
- M.save(result, bufnr, ctx.client_id)
+ for bufnr, provider in pairs(Provider.active) do
+ for client_id in pairs(provider.client_state) do
+ if client_id == ctx.client_id then
+ provider:request(client_id, function()
+ provider.row_version = {}
+ vim.api.nvim__redraw({ buf = bufnr, valid = true, flush = false })
+ end)
+ end
+ end
+ end
+ return vim.NIL
+end
- -- Eager display for any resolved lenses and refresh them once resolved.
- M.display(result, bufnr, ctx.client_id)
- resolve_lenses(result, bufnr, ctx.client_id, function()
- active_refreshes[bufnr] = nil
- end)
+---@deprecated
+---@param client_id? integer
+---@param bufnr? integer
+function M.clear(client_id, bufnr)
+ vim.deprecate(
+ 'vim.lsp.codelens.clear(client_id, bufnr)',
+ 'vim.lsp.codelens.enable(false, { bufnr = bufnr, client_id = client_id })',
+ '0.13.0'
+ )
+ M.enable(false, { bufnr = bufnr, client_id = client_id })
end
---- @class vim.lsp.codelens.refresh.Opts
---- @inlinedoc
---- @field bufnr integer? filter by buffer. All buffers if nil, 0 for current buffer
+---@deprecated
+---@param lenses? lsp.CodeLens[] lenses to display
+---@param bufnr integer
+---@param client_id integer
+function M.display(lenses, bufnr, client_id)
+ vim.deprecate('vim.lsp.codelens.display()', nil, '0.13.0')
+ local _, _, _ = lenses, bufnr, client_id
+end
---- Refresh the lenses.
----
---- It is recommended to trigger this using an autocmd or via keymap.
----
---- Example:
----
---- ```vim
---- autocmd BufEnter,CursorHold,InsertLeave <buffer> lua vim.lsp.codelens.refresh({ bufnr = 0 })
---- ```
----
---- @param opts? vim.lsp.codelens.refresh.Opts Optional fields
+---@deprecated
+---@param lenses? lsp.CodeLens[] lenses to store
+---@param bufnr integer
+---@param client_id integer
+function M.save(lenses, bufnr, client_id)
+ vim.deprecate('vim.lsp.codelens.save()', nil, '0.13.0')
+ local _, _, _ = lenses, bufnr, client_id
+end
+
+---@deprecated
+---@param err? lsp.ResponseError
+---@param result lsp.CodeLens[]
+---@param ctx lsp.HandlerContext
+function M.on_codelens(err, result, ctx)
+ vim.deprecate('vim.lsp.codelens.on_codelens()', nil, '0.13.0')
+ local _, _, _ = err, result, ctx
+end
+
+---@class vim.lsp.codelens.refresh.Opts
+---@inlinedoc
+---@field bufnr? integer
+
+---@deprecated
+---@param opts? vim.lsp.codelens.refresh.Opts Optional fields
function M.refresh(opts)
- opts = opts or {}
- local bufnr = opts.bufnr and vim._resolve_bufnr(opts.bufnr)
- local buffers = bufnr and { bufnr }
- or vim.tbl_filter(api.nvim_buf_is_loaded, api.nvim_list_bufs())
-
- for _, buf in ipairs(buffers) do
- if not active_refreshes[buf] then
- local params = {
- textDocument = util.make_text_document_params(buf),
- }
- active_refreshes[buf] = true
-
- local request_ids = vim.lsp.buf_request(
- buf,
- 'textDocument/codeLens',
- params,
- M.on_codelens,
- function() end
- )
- if vim.tbl_isempty(request_ids) then
- active_refreshes[buf] = nil
- end
- end
- end
+ vim.deprecate(
+ 'vim.lsp.codelens.refresh({ bufnr = bufnr})',
+ 'vim.lsp.codelens.enable(true, { bufnr = bufnr })',
+ '0.13.0'
+ )
+
+ vim.validate('opts', opts, 'table', true)
+ M.enable(true, { bufnr = opts and opts.bufnr })
end
return M
diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua
@@ -255,11 +255,6 @@ RCS['textDocument/diagnostic'] = function(...)
end
--- @private
-RCS['textDocument/codeLens'] = function(...)
- return vim.lsp.codelens.on_codelens(...)
-end
-
---- @private
RCS['textDocument/inlayHint'] = function(...)
return vim.lsp.inlay_hint.on_inlayhint(...)
end
@@ -654,6 +649,11 @@ RSC['window/showDocument'] = function(_, params, ctx)
return { success = success or false }
end
+---@see https://microsoft.github.io/language-server-protocol/specifications/lsp/3.17/specification/#codeLens_refresh
+RSC['workspace/codeLens/refresh'] = function(err, result, ctx)
+ return vim.lsp.codelens.on_refresh(err, result, ctx)
+end
+
---@see https://microsoft.github.io/language-server-protocol/specification/#diagnostic_refresh
RSC['workspace/diagnostic/refresh'] = function(err, result, ctx)
return vim.lsp.diagnostic.on_refresh(err, result, ctx)
diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua
@@ -604,6 +604,9 @@ function protocol.make_client_capabilities()
dynamicRegistration = sysname == 'Darwin' or sysname == 'Windows_NT',
relativePatternSupport = true,
},
+ codeLens = {
+ refreshSupport = true,
+ },
inlayHint = {
refreshSupport = true,
},
diff --git a/test/functional/plugin/lsp/codelens_spec.lua b/test/functional/plugin/lsp/codelens_spec.lua
@@ -1,107 +1,301 @@
local t = require('test.testutil')
local n = require('test.functional.testnvim')()
+local t_lsp = require('test.functional.plugin.lsp.testutil')
+local Screen = require('test.functional.ui.screen')
-local exec_lua = n.exec_lua
+local dedent = t.dedent
local eq = t.eq
+local api = n.api
+local exec_lua = n.exec_lua
+local insert = n.insert
+local feed = n.feed
+
+local clear_notrace = t_lsp.clear_notrace
+local create_server_definition = t_lsp.create_server_definition
+
describe('vim.lsp.codelens', function()
+ local text = dedent([[
+ struct S {
+ a: i32,
+ b: String,
+ }
+
+ impl S {
+ fn new(a: i32, b: String) -> Self {
+ S { a, b }
+ }
+ }
+
+ fn main() {
+ let s = S::new(42, String::from("Hello, world!"));
+ println!("S.a: {}, S.b: {}", s.a, s.b);
+ }
+ ]])
+
+ local grid_with_lenses = dedent([[
+ struct S { {1:1 implementation} |
+ a: i32, |
+ b: String, |
+ } |
+ |
+ impl S { |
+ fn new(a: i32, b: String) -> Self { |
+ S { a, b } |
+ } |
+ } |
+ |
+ fn main() { {1:▶︎ Run } |
+ let s = S::new(42, String::from("Hello, world!"))|
+ ; |
+ println!("S.a: {}, S.b: {}", s.a, s.b); |
+ } |
+ ^ |
+ {1:~ }|*2
+ |
+ ]])
+
+ local grid_without_lenses = dedent([[
+ struct S { |
+ a: i32, |
+ b: String, |
+ } |
+ |
+ impl S { |
+ fn new(a: i32, b: String) -> Self { |
+ S { a, b } |
+ } |
+ } |
+ |
+ fn main() { |
+ let s = S::new(42, String::from("Hello, world!"))|
+ ; |
+ println!("S.a: {}, S.b: {}", s.a, s.b); |
+ } |
+ ^ |
+ {1:~ }|*2
+ |
+ ]])
+
+ --- @type test.functional.ui.screen
+ local screen
+
+ --- @type integer
+ local client_id
+
before_each(function()
- n.clear()
- exec_lua('require("vim.lsp")')
- end)
+ clear_notrace()
+ exec_lua(create_server_definition)
- it('on_codelens_stores_and_displays_lenses', function()
- local fake_uri = 'file:///fake/uri'
- local bufnr = exec_lua(function()
- local bufnr = vim.uri_to_bufnr(fake_uri)
- local lines = { 'So', 'many', 'lines' }
- vim.fn.bufload(bufnr)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
- return bufnr
- end)
+ screen = Screen.new(nil, 20)
- exec_lua(function()
- local lenses = {
- {
- range = {
- start = { line = 0, character = 0 },
- ['end'] = { line = 0, character = 0 },
+ client_id = exec_lua(function()
+ _G.server = _G._create_server({
+ capabilities = {
+ codeLensProvider = {
+ resolveProvider = true,
},
- command = { title = 'Lens1', command = 'Dummy' },
},
- }
- vim.lsp.codelens.on_codelens(
- nil,
- lenses,
- { method = 'textDocument/codeLens', client_id = 1, bufnr = bufnr }
- )
- end)
+ handlers = {
+ ['textDocument/codeLens'] = function(_, _, callback)
+ callback(nil, {
+ {
+ data = {
+ kind = {
+ impls = {
+ position = {
+ character = 7,
+ line = 0,
+ },
+ },
+ },
+ version = 0,
+ },
+ range = {
+ ['end'] = {
+ character = 8,
+ line = 0,
+ },
+ start = {
+ character = 7,
+ line = 0,
+ },
+ },
+ },
+ {
+ command = {
+ arguments = {},
+ command = 'rust-analyzer.runSingle',
+ title = '▶︎ Run ',
+ },
+ range = {
+ ['end'] = {
+ character = 7,
+ line = 11,
+ },
+ start = {
+ character = 3,
+ line = 11,
+ },
+ },
+ },
+ })
+ end,
+ ['codeLens/resolve'] = function(_, _, callback)
+ vim.schedule(function()
+ callback(nil, {
+ command = {
+ arguments = {},
+ command = 'rust-analyzer.showReferences',
+ title = '1 implementation',
+ },
+ range = {
+ ['end'] = {
+ character = 8,
+ line = 0,
+ },
+ start = {
+ character = 7,
+ line = 0,
+ },
+ },
+ })
+ end)
+ end,
+ },
+ })
- local stored_lenses = exec_lua(function()
- return vim.lsp.codelens.get(bufnr)
+ return vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd })
end)
- local expected = {
- {
- range = {
- start = { line = 0, character = 0 },
- ['end'] = { line = 0, character = 0 },
- },
- command = {
- title = 'Lens1',
- command = 'Dummy',
- },
- },
- }
- eq(expected, stored_lenses)
- local virtual_text_chunks = exec_lua(function()
- local ns = vim.lsp.codelens.__namespaces[1]
- local extmarks = vim.api.nvim_buf_get_extmarks(bufnr, ns, 0, -1, {})
- return vim.api.nvim_buf_get_extmark_by_id(bufnr, ns, extmarks[1][1], { details = true })[3].virt_text
+ insert(text)
+
+ exec_lua(function()
+ vim.lsp.codelens.enable()
end)
- eq({ [1] = { 'Lens1', 'LspCodeLens' } }, virtual_text_chunks)
+ screen:expect({ grid = grid_with_lenses })
end)
- it('can clear all lens', function()
- local fake_uri = 'file:///fake/uri'
- local bufnr = exec_lua(function()
- local bufnr = vim.uri_to_bufnr(fake_uri)
- local lines = { 'So', 'many', 'lines' }
- vim.fn.bufload(bufnr)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
- return bufnr
+ it('clears code lenses when disabled', function()
+ exec_lua(function()
+ vim.lsp.codelens.enable(false)
end)
+ screen:expect({ grid = grid_without_lenses })
+ end)
+
+ it('clears code lenses when sole client detaches', function()
exec_lua(function()
- local lenses = {
- {
+ vim.lsp.get_client_by_id(client_id):stop()
+ end)
+
+ screen:expect({ grid = grid_without_lenses })
+ end)
+
+ it('get code lenses in the current buffer', function()
+ local result = exec_lua(function()
+ vim.api.nvim_win_set_cursor(0, { 12, 3 })
+ return vim.lsp.codelens.get()
+ end)
+
+ eq({
+ {
+ client_id = 1,
+ lens = {
+ command = {
+ arguments = {},
+ command = 'rust-analyzer.showReferences',
+ title = '1 implementation',
+ },
range = {
- start = { line = 0, character = 0 },
- ['end'] = { line = 0, character = 0 },
+ ['end'] = {
+ character = 8,
+ line = 0,
+ },
+ start = {
+ character = 7,
+ line = 0,
+ },
},
- command = { title = 'Lens1', command = 'Dummy' },
},
- }
- vim.lsp.codelens.on_codelens(
- nil,
- lenses,
- { method = 'textDocument/codeLens', client_id = 1, bufnr = bufnr }
- )
- end)
+ },
+ {
+ client_id = 1,
+ lens = {
+ command = {
+ arguments = {},
+ command = 'rust-analyzer.runSingle',
+ title = '▶︎ Run ',
+ },
+ range = {
+ ['end'] = {
+ character = 7,
+ line = 11,
+ },
+ start = {
+ character = 3,
+ line = 11,
+ },
+ },
+ },
+ },
+ }, result)
+ end)
- local stored_lenses = exec_lua(function()
- return vim.lsp.codelens.get(bufnr)
- end)
- eq(1, #stored_lenses)
+ it('refreshes code lenses on request', function()
+ feed('ggdd')
+ screen:expect([[
+ ^a: i32, {1:1 implementation} |
+ b: String, |
+ } |
+ |
+ impl S { |
+ fn new(a: i32, b: String) -> Self { |
+ S { a, b } |
+ } |
+ } |
+ |
+ fn main() { {1:▶︎ Run } |
+ let s = S::new(42, String::from("Hello, world!"))|
+ ; |
+ println!("S.a: {}, S.b: {}", s.a, s.b); |
+ } |
+ |
+ {1:~ }|*3
+ |
+ ]])
exec_lua(function()
- vim.lsp.codelens.clear()
+ vim.lsp.codelens.on_refresh(
+ nil,
+ nil,
+ { method = 'workspace/codeLens/refresh', client_id = client_id }
+ )
end)
+ screen:expect([[
+ ^a: i32, {1:1 implementation} |
+ b: String, |
+ } |
+ |
+ impl S { |
+ fn new(a: i32, b: String) -> Self { |
+ S { a, b } |
+ } |
+ } |
+ |
+ fn main() { |
+ let s = S::new(42, String::from("Hello, world!"))|
+ ; {1:▶︎ Run } |
+ println!("S.a: {}, S.b: {}", s.a, s.b); |
+ } |
+ |
+ {1:~ }|*3
+ |
+ ]])
+ end)
- stored_lenses = exec_lua(function()
- return vim.lsp.codelens.get(bufnr)
- end)
- eq(0, #stored_lenses)
+ after_each(function()
+ api.nvim_exec_autocmds('VimLeavePre', { modeline = false })
end)
end)
diff --git a/test/functional/plugin/lsp_spec.lua b/test/functional/plugin/lsp_spec.lua
@@ -4944,230 +4944,6 @@ describe('LSP', function()
end)
end)
- describe('vim.lsp.codelens', function()
- it('uses client commands', function()
- local client --- @type vim.lsp.Client
- local expected_handlers = {
- { NIL, {}, { method = 'shutdown', client_id = 1 } },
- { NIL, {}, { method = 'start', client_id = 1 } },
- }
- test_rpc_server {
- test_name = 'clientside_commands',
- on_init = function(client_)
- client = client_
- end,
- on_setup = function() end,
- on_exit = function(code, signal)
- eq(0, code, 'exit code')
- eq(0, signal, 'exit signal')
- end,
- on_handler = function(err, result, ctx)
- eq(table.remove(expected_handlers), { err, result, ctx })
- if ctx.method == 'start' then
- local fake_uri = 'file:///fake/uri'
- local cmd = exec_lua(function()
- local bufnr = vim.uri_to_bufnr(fake_uri)
- vim.fn.bufload(bufnr)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'One line' })
- local lenses = {
- {
- range = {
- start = { line = 0, character = 0 },
- ['end'] = { line = 0, character = 8 },
- },
- command = { title = 'Lens1', command = 'Dummy' },
- },
- }
- vim.lsp.codelens.on_codelens(
- nil,
- lenses,
- { method = 'textDocument/codeLens', client_id = 1, bufnr = bufnr }
- )
- local cmd_called = nil
- vim.lsp.commands['Dummy'] = function(command0)
- cmd_called = command0
- end
- vim.api.nvim_set_current_buf(bufnr)
- vim.lsp.codelens.run()
- return cmd_called
- end)
- eq({ command = 'Dummy', title = 'Lens1' }, cmd)
- elseif ctx.method == 'shutdown' then
- client:stop()
- end
- end,
- }
- end)
-
- it('releases buffer refresh lock', function()
- local client --- @type vim.lsp.Client
- local expected_handlers = {
- { NIL, {}, { method = 'shutdown', client_id = 1 } },
- { NIL, {}, { method = 'start', client_id = 1 } },
- }
- test_rpc_server {
- test_name = 'codelens_refresh_lock',
- on_init = function(client_)
- client = client_
- end,
- on_setup = function()
- exec_lua(function()
- local bufnr = vim.api.nvim_get_current_buf()
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'One line' })
- vim.lsp.buf_attach_client(bufnr, _G.TEST_RPC_CLIENT_ID)
-
- _G.CALLED = false
- _G.RESPONSE = nil
- local on_codelens = vim.lsp.codelens.on_codelens
- vim.lsp.codelens.on_codelens = function(err, result, ...)
- _G.CALLED = true
- _G.RESPONSE = { err = err, result = result }
- return on_codelens(err, result, ...)
- end
- end)
- end,
- on_exit = function(code, signal)
- eq(0, code, 'exit code')
- eq(0, signal, 'exit signal')
- end,
- on_handler = function(err, result, ctx)
- eq(table.remove(expected_handlers), { err, result, ctx })
- if ctx.method == 'start' then
- -- 1. first codelens request errors
- local response = exec_lua(function()
- _G.CALLED = false
- vim.lsp.codelens.refresh()
- vim.wait(100, function()
- return _G.CALLED
- end)
- return _G.RESPONSE
- end)
- eq({ err = { code = -32002, message = 'ServerNotInitialized' } }, response)
-
- -- 2. second codelens request runs
- response = exec_lua(function()
- _G.CALLED = false
- local cmd_called --- @type string?
- vim.lsp.commands['Dummy'] = function(command0)
- cmd_called = command0
- end
- vim.lsp.codelens.refresh()
- vim.wait(100, function()
- return _G.CALLED
- end)
- vim.lsp.codelens.run()
- vim.wait(100, function()
- return cmd_called ~= nil
- end)
- return cmd_called
- end)
- eq({ command = 'Dummy', title = 'Lens1' }, response)
-
- -- 3. third codelens request runs
- response = exec_lua(function()
- _G.CALLED = false
- local cmd_called --- @type string?
- vim.lsp.commands['Dummy'] = function(command0)
- cmd_called = command0
- end
- vim.lsp.codelens.refresh()
- vim.wait(100, function()
- return _G.CALLED
- end)
- vim.lsp.codelens.run()
- vim.wait(100, function()
- return cmd_called ~= nil
- end)
- return cmd_called
- end)
- eq({ command = 'Dummy', title = 'Lens2' }, response)
- elseif ctx.method == 'shutdown' then
- client:stop()
- end
- end,
- }
- end)
-
- it('refresh multiple buffers', function()
- local lens_title_per_fake_uri = {
- ['file:///fake/uri1'] = 'Lens1',
- ['file:///fake/uri2'] = 'Lens2',
- }
- exec_lua(create_server_definition)
-
- -- setup lsp
- exec_lua(function()
- local server = _G._create_server({
- capabilities = {
- codeLensProvider = {
- resolveProvider = true,
- },
- },
- handlers = {
- ['textDocument/codeLens'] = function(_, params, callback)
- local lenses = {
- {
- range = {
- start = { line = 0, character = 0 },
- ['end'] = { line = 0, character = 0 },
- },
- command = {
- title = lens_title_per_fake_uri[params.textDocument.uri],
- command = 'Dummy',
- },
- },
- }
- callback(nil, lenses)
- end,
- },
- })
-
- _G.CLIENT_ID = vim.lsp.start({
- name = 'dummy',
- cmd = server.cmd,
- })
- end)
-
- -- create buffers and setup handler
- exec_lua(function()
- local default_buf = vim.api.nvim_get_current_buf()
- for fake_uri in pairs(lens_title_per_fake_uri) do
- local bufnr = vim.uri_to_bufnr(fake_uri)
- vim.api.nvim_set_current_buf(bufnr)
- vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { 'Some contents' })
- vim.lsp.buf_attach_client(bufnr, _G.CLIENT_ID)
- end
- vim.api.nvim_buf_delete(default_buf, { force = true })
-
- _G.REQUEST_COUNT = vim.tbl_count(lens_title_per_fake_uri)
- _G.RESPONSES = {}
- local on_codelens = vim.lsp.codelens.on_codelens
- vim.lsp.codelens.on_codelens = function(err, result, ctx, ...)
- table.insert(_G.RESPONSES, { err = err, result = result, ctx = ctx })
- return on_codelens(err, result, ctx, ...)
- end
- end)
-
- -- call codelens refresh
- local cmds = exec_lua(function()
- _G.RESPONSES = {}
- vim.lsp.codelens.refresh()
- vim.wait(100, function()
- return #_G.RESPONSES >= _G.REQUEST_COUNT
- end)
-
- local cmds = {}
- for _, resp in ipairs(_G.RESPONSES) do
- local uri = resp.ctx.params.textDocument.uri
- cmds[uri] = resp.result[1].command
- end
- return cmds
- end)
- eq({ command = 'Dummy', title = 'Lens1' }, cmds['file:///fake/uri1'])
- eq({ command = 'Dummy', title = 'Lens2' }, cmds['file:///fake/uri2'])
- end)
- end)
-
describe('vim.lsp.buf.format', function()
it('aborts with notify if no client matches filter', function()
local client --- @type vim.lsp.Client