From fe23168e2bc1a985b8206c3c9049d6f94233bf2d Mon Sep 17 00:00:00 2001 From: Yi Ming Date: Fri, 30 Jan 2026 15:12:33 +0800 Subject: [PATCH] feat(lsp)!: reimplement `textDocument/codeLens` as decoration provider --- runtime/doc/deprecated.txt | 5 + runtime/doc/lsp.txt | 100 ++- runtime/doc/news.txt | 2 + runtime/lua/vim/lsp/_capability.lua | 1 + runtime/lua/vim/lsp/codelens.lua | 717 +++++++++++-------- runtime/lua/vim/lsp/handlers.lua | 5 - test/functional/plugin/lsp/codelens_spec.lua | 317 +++++--- test/functional/plugin/lsp_spec.lua | 224 ------ 8 files changed, 697 insertions(+), 674 deletions(-) diff --git a/runtime/doc/deprecated.txt b/runtime/doc/deprecated.txt index 30dbe5aa29..c27a7c018d 100644 --- 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 index f715a795c2..d459f73708 100644 --- 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 - -display({lenses}, {bufnr}, {client_id}) *vim.lsp.codelens.display()* - Display the lenses using virtual text - - Parameters: ~ - • {lenses} (`lsp.CodeLens[]?`) lenses to display - • {bufnr} (`integer`) - • {client_id} (`integer`) - -get({bufnr}) *vim.lsp.codelens.get()* - Return all lenses for the given buffer - - Parameters: ~ - • {bufnr} (`integer`) Buffer number. 0 can be used for the current - buffer. - - 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. - - Example: >vim - autocmd BufEnter,CursorHold,InsertLeave lua vim.lsp.codelens.refresh({ bufnr = 0 }) + To "toggle", pass the inverse of `is_enabled()`: >lua + vim.lsp.codelens.enable(not vim.lsp.codelens.is_enabled()) < - Parameters: ~ - • {opts} (`table?`) Optional fields - • {bufnr} (`integer?`) filter by buffer. All buffers if nil, 0 - for current buffer - -run() *vim.lsp.codelens.run()* - Run the code lens available in the current line. - -save({lenses}, {bufnr}, {client_id}) *vim.lsp.codelens.save()* - Store lenses for a specific buffer and client + To run a code lens, see |vim.lsp.codelens.run()|. Parameters: ~ - • {lenses} (`lsp.CodeLens[]?`) lenses to store - • {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({filter}) *vim.lsp.codelens.get()* + Get all code lenses in the {filter}ed scope. + + Parameters: ~ + • {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: ~ + (`table[]`) A list of objects with the following fields: + • {client_id} (`integer`) + • {lens} (`lsp.CodeLens`) + +is_enabled({filter}) *vim.lsp.codelens.is_enabled()* + Query whether code lens is enabled in the {filter}ed scope + + Parameters: ~ + • {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. + + Return: ~ + (`boolean`) whether code lens is enabled. + +run({opts}) *vim.lsp.codelens.run()* + Run code lens above the current cursor position. + + Parameters: ~ + • {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 index f4f6ff8fdc..7d362da576 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -298,6 +298,8 @@ 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 LUA diff --git a/runtime/lua/vim/lsp/_capability.lua b/runtime/lua/vim/lsp/_capability.lua index 967fcd00d8..c5a63fdfeb 100644 --- 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 index 8ca7871917..1baba2a06a 100644 --- a/runtime/lua/vim/lsp/codelens.lua +++ b/runtime/lua/vim/lsp/codelens.lua @@ -3,352 +3,461 @@ 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 +local Capability = require('vim.lsp._capability') ----@type table> ---- 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, -}) +---@class (private) vim.lsp.codelens.ClientState +---@field row_lenses table row -> lens +---@field namespace integer ----@type table ----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.Provider : vim.lsp.Capability +---@field active table +--- +--- `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 +--- +--- Index In the form of client_id -> client_state +---@field client_state? table +--- +--- 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, + }) + + return self +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 + +---@package +---@param client_id integer +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 + +--- `lsp.Handler` for `textDocument/codeLens`. +--- +---@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 + + if util.buf_versions[self.bufnr] ~= ctx.version then + return + end + + ---@type table + 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 + + state.row_lenses = row_lenses + self.version = ctx.version +end ---@private -M.__namespaces = namespaces - -local augroup = api.nvim_create_augroup('nvim.lsp.codelens', {}) - -api.nvim_create_autocmd('LspDetach', { - group = augroup, - callback = function(ev) - M.clear(ev.data.client_id, ev.buf) - end, -}) - ----@param lens lsp.CodeLens ----@param bufnr integer ----@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) +---@param client_id? integer +function Provider:request(client_id) + ---@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(...) + end, self.bufnr) + end + end end ---- Return all lenses for the given buffer +---@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 + +--- Automatically request with debouncing, used as callbacks in autocmd events. --- ----@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 {} - end - local lenses = {} - for _, client_lenses in pairs(lenses_by_client) do - vim.list_extend(lenses, client_lenses) - end - return lenses +---@package +function Provider:automatic_request() + self:reset_timer() + self.timer = vim.defer_fn(function() + self:request() + end, 200) 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 }) +---@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 + end + + self.row_version[row] = nil + api.nvim__redraw({ + buf = self.bufnr, + range = { row, row + 1 }, + valid = true, + flush = false, + }) + end, self.bufnr) +end + +---@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 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) +end + +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, +}) + +--- 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 + +--- 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 + +--- Optional filters |kwargs|: +---@class vim.lsp.codelens.get.Filter +---@inlinedoc +--- +--- 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 + + 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 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 + +--- Optional parameters |kwargs|: +---@class vim.lsp.codelens.run.Opts +---@inlinedoc +--- +--- Client ID, or nil for all. +--- (default: all) +---@field client_id? integer + +--- 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 {} + + 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 + + ---@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 + end + end + end + + 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(options, { - prompt = 'Code lenses:', - kind = 'codelens', - format_item = function(option) - return option.lens.command.title + 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(option) - if option then - execute_lens(option.lens, bufnr, option.client) + }, 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 ---- Clear the lenses ---- ----@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 +---@deprecated +---@param client_id? integer +---@param bufnr? integer 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] = {} - end - api.nvim_buf_clear_namespace(iter_bufnr, ns, 0, -1) - end - end + 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 ----@param lenses lsp.CodeLens[] ----@return table -local function group_lenses_by_start_line(lenses) - local lenses_by_lnum = {} ---@type table - 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 - 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 - 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 - - 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 -end - ---- Display the lenses using virtual text ---- +---@deprecated ---@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 - end - - local ns = namespaces[client_id] - if not lenses or not next(lenses) then - api.nvim_buf_clear_namespace(bufnr, ns, 0, -1) - 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 {}) - end + vim.deprecate('vim.lsp.codelens.display()', nil, '0.13.0') + local _, _, _ = lenses, bufnr, client_id end ---- Store lenses for a specific buffer and client ---- +---@deprecated ---@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 - - 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 + vim.deprecate('vim.lsp.codelens.save()', nil, '0.13.0') + local _, _, _ = lenses, bufnr, client_id end ----@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() - 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) - end - countdown(#line_lenses) - 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) - end - end - end -end - ---- |lsp-handler| for the method `textDocument/codeLens` ---- ----@param err lsp.ResponseError? +---@deprecated +---@param err? lsp.ResponseError ---@param result lsp.CodeLens[] ---@param ctx lsp.HandlerContext function M.on_codelens(err, result, ctx) - local bufnr = assert(ctx.bufnr) - - if err then - active_refreshes[bufnr] = nil - log.error('codelens', err) - return - end - - M.save(result, bufnr, ctx.client_id) - - -- 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) + 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? filter by buffer. All buffers if nil, 0 for current buffer +---@class vim.lsp.codelens.refresh.Opts +---@inlinedoc +---@field bufnr? integer ---- Refresh the lenses. ---- ---- It is recommended to trigger this using an autocmd or via keymap. ---- ---- Example: ---- ---- ```vim ---- autocmd BufEnter,CursorHold,InsertLeave lua vim.lsp.codelens.refresh({ bufnr = 0 }) ---- ``` ---- ---- @param opts? vim.lsp.codelens.refresh.Opts Optional fields +---@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()) + vim.deprecate( + 'vim.lsp.codelens.refresh({ bufnr = bufnr})', + 'vim.lsp.codelens.enable(true, { bufnr = bufnr })', + '0.13.0' + ) - 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.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 index 0f0a6bac15..722e3a17c3 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -254,11 +254,6 @@ RCS['textDocument/diagnostic'] = function(...) return vim.lsp.diagnostic.on_diagnostic(...) 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(...) diff --git a/test/functional/plugin/lsp/codelens_spec.lua b/test/functional/plugin/lsp/codelens_spec.lua index 63c14419ad..ee9b81fb53 100644 --- a/test/functional/plugin/lsp/codelens_spec.lua +++ b/test/functional/plugin/lsp/codelens_spec.lua @@ -1,107 +1,248 @@ 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 -describe('vim.lsp.codelens', function() - before_each(function() - n.clear() - exec_lua('require("vim.lsp")') - end) +local api = n.api +local exec_lua = n.exec_lua +local insert = n.insert - 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 +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() + clear_notrace() + exec_lua(create_server_definition) + + screen = Screen.new(nil, 20) + + client_id = exec_lua(function() + _G.server = _G._create_server({ + capabilities = { + codeLensProvider = { + resolveProvider = true, + }, + }, + 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, + }, + }) + + return vim.lsp.start({ name = 'dummy', cmd = _G.server.cmd }) end) + insert(text) + exec_lua(function() - local lenses = { - { - range = { - start = { line = 0, character = 0 }, - ['end'] = { line = 0, character = 0 }, - }, - command = { title = 'Lens1', command = 'Dummy' }, - }, - } - vim.lsp.codelens.on_codelens( - nil, - lenses, - { method = 'textDocument/codeLens', client_id = 1, bufnr = bufnr } - ) + vim.lsp.codelens.enable() end) - local stored_lenses = exec_lua(function() - return vim.lsp.codelens.get(bufnr) + screen:expect({ grid = grid_with_lenses }) + end) + + it('clears code lenses when disabled', function() + exec_lua(function() + vim.lsp.codelens.enable(false) end) - local expected = { + + screen:expect({ grid = grid_without_lenses }) + end) + + it('clears code lenses when sole client detaches', function() + exec_lua(function() + 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({ { - range = { - start = { line = 0, character = 0 }, - ['end'] = { line = 0, character = 0 }, - }, - command = { - title = 'Lens1', - command = 'Dummy', + client_id = 1, + lens = { + command = { + arguments = {}, + command = 'rust-analyzer.showReferences', + title = '1 implementation', + }, + range = { + ['end'] = { + character = 8, + line = 0, + }, + start = { + character = 7, + line = 0, + }, + }, }, }, - } - 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 - end) - - eq({ [1] = { 'Lens1', 'LspCodeLens' } }, virtual_text_chunks) + { + 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) - 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 - end) - - exec_lua(function() - local lenses = { - { - range = { - start = { line = 0, character = 0 }, - ['end'] = { line = 0, character = 0 }, - }, - command = { title = 'Lens1', command = 'Dummy' }, - }, - } - vim.lsp.codelens.on_codelens( - nil, - lenses, - { method = 'textDocument/codeLens', client_id = 1, bufnr = bufnr } - ) - end) - - local stored_lenses = exec_lua(function() - return vim.lsp.codelens.get(bufnr) - end) - eq(1, #stored_lenses) - - exec_lua(function() - vim.lsp.codelens.clear() - 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 index 829e756588..af5f9d7b5c 100644 --- 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