diff options
Diffstat (limited to 'runtime/lua/vim/lsp')
-rw-r--r-- | runtime/lua/vim/lsp/_snippet.lua | 399 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/buf.lua | 281 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/codelens.lua | 57 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/diagnostic.lua | 1448 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/handlers.lua | 287 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/health.lua | 27 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/log.lua | 46 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/protocol.lua | 24 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 168 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 674 |
10 files changed, 1745 insertions, 1666 deletions
diff --git a/runtime/lua/vim/lsp/_snippet.lua b/runtime/lua/vim/lsp/_snippet.lua new file mode 100644 index 0000000000..0140b0aee3 --- /dev/null +++ b/runtime/lua/vim/lsp/_snippet.lua @@ -0,0 +1,399 @@ +local P = {} + +---Take characters until the target characters (The escape sequence is '\' + char) +---@param targets string[] The character list for stop consuming text. +---@param specials string[] If the character isn't contained in targets/specials, '\' will be left. +P.take_until = function(targets, specials) + targets = targets or {} + specials = specials or {} + + return function(input, pos) + local new_pos = pos + local raw = {} + local esc = {} + while new_pos <= #input do + local c = string.sub(input, new_pos, new_pos) + if c == '\\' then + table.insert(raw, '\\') + new_pos = new_pos + 1 + c = string.sub(input, new_pos, new_pos) + if not vim.tbl_contains(targets, c) and not vim.tbl_contains(specials, c) then + table.insert(esc, '\\') + end + table.insert(raw, c) + table.insert(esc, c) + new_pos = new_pos + 1 + else + if vim.tbl_contains(targets, c) then + break + end + table.insert(raw, c) + table.insert(esc, c) + new_pos = new_pos + 1 + end + end + + if new_pos == pos then + return P.unmatch(pos) + end + + return { + parsed = true, + value = { + raw = table.concat(raw, ''), + esc = table.concat(esc, '') + }, + pos = new_pos, + } + end +end + +P.unmatch = function(pos) + return { + parsed = false, + value = nil, + pos = pos, + } +end + +P.map = function(parser, map) + return function(input, pos) + local result = parser(input, pos) + if result.parsed then + return { + parsed = true, + value = map(result.value), + pos = result.pos, + } + end + return P.unmatch(pos) + end +end + +P.lazy = function(factory) + return function(input, pos) + return factory()(input, pos) + end +end + +P.token = function(token) + return function(input, pos) + local maybe_token = string.sub(input, pos, pos + #token - 1) + if token == maybe_token then + return { + parsed = true, + value = maybe_token, + pos = pos + #token, + } + end + return P.unmatch(pos) + end +end + +P.pattern = function(p) + return function(input, pos) + local maybe_match = string.match(string.sub(input, pos), '^' .. p) + if maybe_match then + return { + parsed = true, + value = maybe_match, + pos = pos + #maybe_match, + } + end + return P.unmatch(pos) + end +end + +P.many = function(parser) + return function(input, pos) + local values = {} + local new_pos = pos + while new_pos <= #input do + local result = parser(input, new_pos) + if not result.parsed then + break + end + table.insert(values, result.value) + new_pos = result.pos + end + if #values > 0 then + return { + parsed = true, + value = values, + pos = new_pos, + } + end + return P.unmatch(pos) + end +end + +P.any = function(...) + local parsers = { ... } + return function(input, pos) + for _, parser in ipairs(parsers) do + local result = parser(input, pos) + if result.parsed then + return result + end + end + return P.unmatch(pos) + end +end + +P.opt = function(parser) + return function(input, pos) + local result = parser(input, pos) + return { + parsed = true, + value = result.value, + pos = result.pos, + } + end +end + +P.seq = function(...) + local parsers = { ... } + return function(input, pos) + local values = {} + local new_pos = pos + for _, parser in ipairs(parsers) do + local result = parser(input, new_pos) + if result.parsed then + table.insert(values, result.value) + new_pos = result.pos + else + return P.unmatch(pos) + end + end + return { + parsed = true, + value = values, + pos = new_pos, + } + end +end + +local Node = {} + +Node.Type = { + SNIPPET = 0, + TABSTOP = 1, + PLACEHOLDER = 2, + VARIABLE = 3, + CHOICE = 4, + TRANSFORM = 5, + FORMAT = 6, + TEXT = 7, +} + +function Node:__tostring() + local insert_text = {} + if self.type == Node.Type.SNIPPET then + for _, c in ipairs(self.children) do + table.insert(insert_text, tostring(c)) + end + elseif self.type == Node.Type.CHOICE then + table.insert(insert_text, self.items[1]) + elseif self.type == Node.Type.PLACEHOLDER then + for _, c in ipairs(self.children or {}) do + table.insert(insert_text, tostring(c)) + end + elseif self.type == Node.Type.TEXT then + table.insert(insert_text, self.esc) + end + return table.concat(insert_text, '') +end + +--@see https://code.visualstudio.com/docs/editor/userdefinedsnippets#_grammar + +local S = {} +S.dollar = P.token('$') +S.open = P.token('{') +S.close = P.token('}') +S.colon = P.token(':') +S.slash = P.token('/') +S.comma = P.token(',') +S.pipe = P.token('|') +S.plus = P.token('+') +S.minus = P.token('-') +S.question = P.token('?') +S.int = P.map(P.pattern('[0-9]+'), function(value) + return tonumber(value, 10) +end) +S.var = P.pattern('[%a_][%w_]+') +S.text = function(targets, specials) + return P.map(P.take_until(targets, specials), function(value) + return setmetatable({ + type = Node.Type.TEXT, + raw = value.raw, + esc = value.esc, + }, Node) + end) +end + +S.toplevel = P.lazy(function() + return P.any(S.placeholder, S.tabstop, S.variable, S.choice) +end) + +S.format = P.any( + P.map(P.seq(S.dollar, S.int), function(values) + return setmetatable({ + type = Node.Type.FORMAT, + capture_index = values[2], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.int, S.close), function(values) + return setmetatable({ + type = Node.Type.FORMAT, + capture_index = values[3], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.int, S.colon, S.slash, P.any( + P.token('upcase'), + P.token('downcase'), + P.token('capitalize'), + P.token('camelcase'), + P.token('pascalcase') + ), S.close), function(values) + return setmetatable({ + type = Node.Type.FORMAT, + capture_index = values[3], + modifier = values[6], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.int, S.colon, P.any( + P.seq(S.question, P.take_until({ ':' }, { '\\' }), S.colon, P.take_until({ '}' }, { '\\' })), + P.seq(S.plus, P.take_until({ '}' }, { '\\' })), + P.seq(S.minus, P.take_until({ '}' }, { '\\' })) + ), S.close), function(values) + return setmetatable({ + type = Node.Type.FORMAT, + capture_index = values[3], + if_text = values[5][2].esc, + else_text = (values[5][4] or {}).esc, + }, Node) + end) +) + +S.transform = P.map(P.seq( + S.slash, + P.take_until({ '/' }, { '\\' }), + S.slash, + P.many(P.any(S.format, S.text({ '$', '/' }, { '\\' }))), + S.slash, + P.opt(P.pattern('[ig]+')) +), function(values) + return setmetatable({ + type = Node.Type.TRANSFORM, + pattern = values[2].raw, + format = values[4], + option = values[6], + }, Node) +end) + +S.tabstop = P.any( + P.map(P.seq(S.dollar, S.int), function(values) + return setmetatable({ + type = Node.Type.TABSTOP, + tabstop = values[2], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.int, S.close), function(values) + return setmetatable({ + type = Node.Type.TABSTOP, + tabstop = values[3], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.int, S.transform, S.close), function(values) + return setmetatable({ + type = Node.Type.TABSTOP, + tabstop = values[3], + transform = values[4], + }, Node) + end) +) + +S.placeholder = P.any( + P.map(P.seq(S.dollar, S.open, S.int, S.colon, P.many(P.any(S.toplevel, S.text({ '$', '}' }, { '\\' }))), S.close), function(values) + return setmetatable({ + type = Node.Type.PLACEHOLDER, + tabstop = values[3], + children = values[5], + }, Node) + end) +) + +S.choice = P.map(P.seq( + S.dollar, + S.open, + S.int, + S.pipe, + P.many( + P.map(P.seq(S.text({ ',', '|' }), P.opt(S.comma)), function(values) + return values[1].esc + end) + ), + S.pipe, + S.close +), function(values) + return setmetatable({ + type = Node.Type.CHOICE, + tabstop = values[3], + items = values[5], + }, Node) +end) + +S.variable = P.any( + P.map(P.seq(S.dollar, S.var), function(values) + return setmetatable({ + type = Node.Type.VARIABLE, + name = values[2], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.var, S.close), function(values) + return setmetatable({ + type = Node.Type.VARIABLE, + name = values[3], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.var, S.transform, S.close), function(values) + return setmetatable({ + type = Node.Type.VARIABLE, + name = values[3], + transform = values[4], + }, Node) + end), + P.map(P.seq(S.dollar, S.open, S.var, S.colon, P.many(P.any(S.toplevel, S.text({ '$', '}' }, { '\\' }))), S.close), function(values) + return setmetatable({ + type = Node.Type.VARIABLE, + name = values[3], + children = values[5], + }, Node) + end) +) + +S.snippet = P.map(P.many(P.any(S.toplevel, S.text({ '$' }, { '}', '\\' }))), function(values) + return setmetatable({ + type = Node.Type.SNIPPET, + children = values, + }, Node) +end) + +local M = {} + +---The snippet node type enum +---@types table<string, number> +M.NodeType = Node.Type + +---Parse snippet string and returns the AST +---@param input string +---@return table +function M.parse(input) + local result = S.snippet(input, 1) + if not result.parsed then + error('snippet parsing failed.') + end + return result.value +end + +return M diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index b13d662ccb..245f29943e 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -5,7 +5,7 @@ local util = require 'vim.lsp.util' local M = {} ---@private +---@private --- Returns nil if {status} is false or nil, otherwise returns the rest of the --- arguments. local function ok_or_nil(status, ...) @@ -13,31 +13,31 @@ local function ok_or_nil(status, ...) return ... end ---@private +---@private --- Swallows errors. --- ---@param fn Function to run ---@param ... Function arguments ---@returns Result of `fn(...)` if there are no errors, otherwise nil. +---@param fn Function to run +---@param ... Function arguments +---@returns Result of `fn(...)` if there are no errors, otherwise nil. --- Returns nil if errors occur during {fn}, otherwise returns local function npcall(fn, ...) return ok_or_nil(pcall(fn, ...)) end ---@private +---@private --- Sends an async request to all active clients attached to the current --- buffer. --- ---@param method (string) LSP method name ---@param params (optional, table) Parameters to send to the server ---@param handler (optional, functionnil) See |lsp-handler|. Follows |lsp-handler-resolution| +---@param method (string) LSP method name +---@param params (optional, table) Parameters to send to the server +---@param handler (optional, functionnil) See |lsp-handler|. Follows |lsp-handler-resolution| -- ---@returns 2-tuple: +---@returns 2-tuple: --- - Map of client-id:request-id pairs for all successful requests. --- - Function which can be used to cancel all the requests. You could instead --- iterate all clients and call their `cancel_request()` methods. --- ---@see |vim.lsp.buf_request()| +---@see |vim.lsp.buf_request()| local function request(method, params, handler) validate { method = {method, 's'}; @@ -49,7 +49,7 @@ end --- Checks whether the language servers attached to the current buffer are --- ready. --- ---@returns `true` if server responds. +---@returns `true` if server responds. function M.server_ready() return not not vim.lsp.buf_notify(0, "window/progress", {}) end @@ -62,7 +62,7 @@ function M.hover() end --- Jumps to the declaration of the symbol under the cursor. ---@note Many servers do not implement this method. Generally, see |vim.lsp.buf.definition()| instead. +---@note Many servers do not implement this method. Generally, see |vim.lsp.buf.definition()| instead. --- function M.declaration() local params = util.make_position_params() @@ -100,22 +100,22 @@ end --- Retrieves the completion items at the current cursor position. Can only be --- called in Insert mode. --- ---@param context (context support not yet implemented) Additional information +---@param context (context support not yet implemented) Additional information --- about the context in which a completion was triggered (how it was triggered, --- and by which trigger character, if applicable) --- ---@see |vim.lsp.protocol.constants.CompletionTriggerKind| +---@see |vim.lsp.protocol.constants.CompletionTriggerKind| function M.completion(context) local params = util.make_position_params() params.context = context return request('textDocument/completion', params) end ---@private +---@private --- If there is more than one client that supports the given method, --- asks the user to select one. -- ---@returns The client that the user selected or nil +---@returns The client that the user selected or nil local function select_client(method) local clients = vim.tbl_values(vim.lsp.buf_get_clients()); clients = vim.tbl_filter(function (client) @@ -126,7 +126,7 @@ local function select_client(method) if #clients > 1 then local choices = {} - for k,v in ipairs(clients) do + for k,v in pairs(clients) do table.insert(choices, string.format("%d %s", k, v.name)) end local user_choice = vim.fn.confirm( @@ -146,11 +146,11 @@ end --- Formats the current buffer. --- ---@param options (optional, table) Can be used to specify FormattingOptions. +---@param options (optional, table) Can be used to specify FormattingOptions. --- Some unspecified options will be automatically derived from the current --- Neovim options. -- ---@see https://microsoft.github.io/language-server-protocol/specification#textDocument_formatting +---@see https://microsoft.github.io/language-server-protocol/specification#textDocument_formatting function M.formatting(options) local client = select_client("textDocument/formatting") if client == nil then return end @@ -168,9 +168,9 @@ end --- vim.api.nvim_command[[autocmd BufWritePre <buffer> lua vim.lsp.buf.formatting_sync()]] --- </pre> --- ---@param options Table with valid `FormattingOptions` entries ---@param timeout_ms (number) Request timeout ---@see |vim.lsp.buf.formatting_seq_sync| +---@param options Table with valid `FormattingOptions` entries +---@param timeout_ms (number) Request timeout +---@see |vim.lsp.buf.formatting_seq_sync| function M.formatting_sync(options, timeout_ms) local client = select_client("textDocument/formatting") if client == nil then return end @@ -195,18 +195,18 @@ end --- vim.api.nvim_command[[autocmd BufWritePre <buffer> lua vim.lsp.buf.formatting_seq_sync()]] --- </pre> --- ---@param options (optional, table) `FormattingOptions` entries ---@param timeout_ms (optional, number) Request timeout ---@param order (optional, table) List of client names. Formatting is requested from clients +---@param options (optional, table) `FormattingOptions` entries +---@param timeout_ms (optional, number) Request timeout +---@param order (optional, table) List of client names. Formatting is requested from clients ---in the following order: first all clients that are not in the `order` list, then ---the remaining clients in the order as they occur in the `order` list. function M.formatting_seq_sync(options, timeout_ms, order) local clients = vim.tbl_values(vim.lsp.buf_get_clients()); -- sort the clients according to `order` - for _, client_name in ipairs(order or {}) do + for _, client_name in pairs(order or {}) do -- if the client exists, move to the end of the list - for i, client in ipairs(clients) do + for i, client in pairs(clients) do if client.name == client_name then table.insert(clients, table.remove(clients, i)) break @@ -215,7 +215,7 @@ function M.formatting_seq_sync(options, timeout_ms, order) end -- loop through the clients and make synchronous formatting requests - for _, client in ipairs(clients) do + for _, client in pairs(clients) do if client.resolved_capabilities.document_formatting then local params = util.make_formatting_params(options) local result, err = client.request_sync("textDocument/formatting", params, timeout_ms, vim.api.nvim_get_current_buf()) @@ -230,10 +230,10 @@ end --- Formats a given range. --- ---@param options Table with valid `FormattingOptions` entries. ---@param start_pos ({number, number}, optional) mark-indexed position. +---@param options Table with valid `FormattingOptions` entries. +---@param start_pos ({number, number}, optional) mark-indexed position. ---Defaults to the start of the last visual selection. ---@param end_pos ({number, number}, optional) mark-indexed position. +---@param end_pos ({number, number}, optional) mark-indexed position. ---Defaults to the end of the last visual selection. function M.range_formatting(options, start_pos, end_pos) local client = select_client("textDocument/rangeFormatting") @@ -246,29 +246,49 @@ end --- Renames all references to the symbol under the cursor. --- ---@param new_name (string) If not provided, the user will be prompted for a new +---@param new_name (string) If not provided, the user will be prompted for a new ---name using |input()|. function M.rename(new_name) - -- TODO(ashkan) use prepareRename - -- * result: [`Range`](#range) \| `{ range: Range, placeholder: string }` \| `null` describing the range of the string to rename and optionally a placeholder text of the string content to be renamed. If `null` is returned then it is deemed that a 'textDocument/rename' request is not valid at the given position. local params = util.make_position_params() - new_name = new_name or npcall(vfn.input, "New Name: ", vfn.expand('<cword>')) - if not (new_name and #new_name > 0) then return end - params.newName = new_name - request('textDocument/rename', params) + local function prepare_rename(err, result) + if err == nil and result == nil then + vim.notify('nothing to rename', vim.log.levels.INFO) + return + end + if result and result.placeholder then + new_name = new_name or npcall(vfn.input, "New Name: ", result.placeholder) + elseif result and result.start and result['end'] and + result.start.line == result['end'].line then + local line = vfn.getline(result.start.line+1) + local start_char = result.start.character+1 + local end_char = result['end'].character + new_name = new_name or npcall(vfn.input, "New Name: ", string.sub(line, start_char, end_char)) + else + -- fallback to guessing symbol using <cword> + -- + -- this can happen if the language server does not support prepareRename, + -- returns an unexpected response, or requests for "default behavior" + -- + -- see https://microsoft.github.io/language-server-protocol/specification#textDocument_prepareRename + new_name = new_name or npcall(vfn.input, "New Name: ", vfn.expand('<cword>')) + end + if not (new_name and #new_name > 0) then return end + params.newName = new_name + request('textDocument/rename', params) + end + request('textDocument/prepareRename', params, prepare_rename) end --- Lists all the references to the symbol under the cursor in the quickfix window. --- ---@param context (table) Context for the request ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references +---@param context (table) Context for the request +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references function M.references(context) validate { context = { context, 't', true } } local params = util.make_position_params() params.context = context or { includeDeclaration = true; } - params[vim.type_idx] = vim.types.dictionary request('textDocument/references', params) end @@ -279,14 +299,14 @@ function M.document_symbol() request('textDocument/documentSymbol', params) end ---@private +---@private local function pick_call_hierarchy_item(call_hierarchy_items) if not call_hierarchy_items then return end if #call_hierarchy_items == 1 then return call_hierarchy_items[1] end local items = {} - for i, item in ipairs(call_hierarchy_items) do + for i, item in pairs(call_hierarchy_items) do local entry = item.detail or item.name table.insert(items, string.format("%d. %s", i, entry)) end @@ -297,16 +317,24 @@ local function pick_call_hierarchy_item(call_hierarchy_items) return choice end ---@private +---@private local function call_hierarchy(method) local params = util.make_position_params() - request('textDocument/prepareCallHierarchy', params, function(err, _, result) + request('textDocument/prepareCallHierarchy', params, function(err, result, ctx) if err then vim.notify(err.message, vim.log.levels.WARN) return end local call_hierarchy_item = pick_call_hierarchy_item(result) - vim.lsp.buf_request(0, method, { item = call_hierarchy_item }) + local client = vim.lsp.get_client_by_id(ctx.client_id) + if client then + client.request(method, { item = call_hierarchy_item }, nil, ctx.bufnr) + else + vim.notify(string.format( + 'Client with id=%d disappeared during call hierarchy request', ctx.client_id), + vim.log.levels.WARN + ) + end end) end @@ -328,8 +356,8 @@ end --- function M.list_workspace_folders() local workspace_folders = {} - for _, client in ipairs(vim.lsp.buf_get_clients()) do - for _, folder in ipairs(client.workspaceFolders) do + for _, client in pairs(vim.lsp.buf_get_clients()) do + for _, folder in pairs(client.workspaceFolders) do table.insert(workspace_folders, folder.name) end end @@ -347,9 +375,9 @@ function M.add_workspace_folder(workspace_folder) return end local params = util.make_workspace_params({{uri = vim.uri_from_fname(workspace_folder); name = workspace_folder}}, {{}}) - for _, client in ipairs(vim.lsp.buf_get_clients()) do + for _, client in pairs(vim.lsp.buf_get_clients()) do local found = false - for _, folder in ipairs(client.workspaceFolders) do + for _, folder in pairs(client.workspaceFolders) do if folder.name == workspace_folder then found = true print(workspace_folder, "is already part of this workspace") @@ -371,8 +399,8 @@ function M.remove_workspace_folder(workspace_folder) vim.api.nvim_command("redraw") if not (workspace_folder and #workspace_folder > 0) then return end local params = util.make_workspace_params({{}}, {{uri = vim.uri_from_fname(workspace_folder); name = workspace_folder}}) - for _, client in ipairs(vim.lsp.buf_get_clients()) do - for idx, folder in ipairs(client.workspaceFolders) do + for _, client in pairs(vim.lsp.buf_get_clients()) do + for idx, folder in pairs(client.workspaceFolders) do if folder.name == workspace_folder then vim.lsp.buf_notify(0, 'workspace/didChangeWorkspaceFolders', params) client.workspaceFolders[idx] = nil @@ -389,7 +417,7 @@ end --- call, the user is prompted to enter a string on the command line. An empty --- string means no filtering is done. --- ---@param query (string, optional) +---@param query (string, optional) function M.workspace_symbol(query) query = query or npcall(vfn.input, "Query: ") local params = {query = query} @@ -422,38 +450,157 @@ function M.clear_references() util.buf_clear_references() end ---- Selects a code action from the input list that is available at the current ---- cursor position. + +---@private -- ---@param context: (table, optional) Valid `CodeActionContext` object ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction +--- This is not public because the main extension point is +--- vim.ui.select which can be overridden independently. +--- +--- Can't call/use vim.lsp.handlers['textDocument/codeAction'] because it expects +--- `(err, CodeAction[] | Command[], ctx)`, but we want to aggregate the results +--- from multiple clients to have 1 single UI prompt for the user, yet we still +--- need to be able to link a `CodeAction|Command` to the right client for +--- `codeAction/resolve` +local function on_code_action_results(results, ctx) + local action_tuples = {} + for client_id, result in pairs(results) do + for _, action in pairs(result.result or {}) do + table.insert(action_tuples, { client_id, action }) + end + end + if #action_tuples == 0 then + vim.notify('No code actions available', vim.log.levels.INFO) + return + end + + ---@private + local function apply_action(action, client) + if action.edit then + util.apply_workspace_edit(action.edit) + end + if action.command then + local command = type(action.command) == 'table' and action.command or action + local fn = vim.lsp.commands[command.command] + if fn then + local enriched_ctx = vim.deepcopy(ctx) + enriched_ctx.client_id = client.id + fn(command, ctx) + else + M.execute_command(command) + end + end + end + + ---@private + local function on_user_choice(action_tuple) + if not action_tuple then + return + end + -- textDocument/codeAction can return either Command[] or CodeAction[] + -- + -- CodeAction + -- ... + -- edit?: WorkspaceEdit -- <- must be applied before command + -- command?: Command + -- + -- Command: + -- title: string + -- command: string + -- arguments?: any[] + -- + local client = vim.lsp.get_client_by_id(action_tuple[1]) + local action = action_tuple[2] + if not action.edit + and client + and type(client.resolved_capabilities.code_action) == 'table' + and client.resolved_capabilities.code_action.resolveProvider then + + client.request('codeAction/resolve', action, function(err, resolved_action) + if err then + vim.notify(err.code .. ': ' .. err.message, vim.log.levels.ERROR) + return + end + apply_action(resolved_action, client) + end) + else + apply_action(action, client) + end + end + + vim.ui.select(action_tuples, { + prompt = 'Code actions:', + format_item = function(action_tuple) + local title = action_tuple[2].title:gsub('\r\n', '\\r\\n') + return title:gsub('\n', '\\n') + end, + }, on_user_choice) +end + + +--- Requests code actions from all clients and calls the handler exactly once +--- with all aggregated results +---@private +local function code_action_request(params) + local bufnr = vim.api.nvim_get_current_buf() + local method = 'textDocument/codeAction' + vim.lsp.buf_request_all(bufnr, method, params, function(results) + on_code_action_results(results, { bufnr = bufnr, method = method, params = params }) + end) +end + +--- Selects a code action available at the current +--- cursor position. +--- +---@param context table|nil `CodeActionContext` of the LSP specification: +--- - diagnostics: (table|nil) +--- LSP `Diagnostic[]`. Inferred from the current +--- position if not provided. +--- - only: (string|nil) +--- LSP `CodeActionKind` used to filter the code actions. +--- Most language servers support values like `refactor` +--- or `quickfix`. +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction function M.code_action(context) validate { context = { context, 't', true } } - context = context or { diagnostics = vim.lsp.diagnostic.get_line_diagnostics() } + context = context or {} + if not context.diagnostics then + context.diagnostics = vim.lsp.diagnostic.get_line_diagnostics() + end local params = util.make_range_params() params.context = context - request('textDocument/codeAction', params) + code_action_request(params) end --- Performs |vim.lsp.buf.code_action()| for a given range. --- ---@param context: (table, optional) Valid `CodeActionContext` object ---@param start_pos ({number, number}, optional) mark-indexed position. +--- +---@param context table|nil `CodeActionContext` of the LSP specification: +--- - diagnostics: (table|nil) +--- LSP `Diagnostic[]`. Inferred from the current +--- position if not provided. +--- - only: (string|nil) +--- LSP `CodeActionKind` used to filter the code actions. +--- Most language servers support values like `refactor` +--- or `quickfix`. +---@param start_pos ({number, number}, optional) mark-indexed position. ---Defaults to the start of the last visual selection. ---@param end_pos ({number, number}, optional) mark-indexed position. +---@param end_pos ({number, number}, optional) mark-indexed position. ---Defaults to the end of the last visual selection. function M.range_code_action(context, start_pos, end_pos) validate { context = { context, 't', true } } - context = context or { diagnostics = vim.lsp.diagnostic.get_line_diagnostics() } + context = context or {} + if not context.diagnostics then + context.diagnostics = vim.lsp.diagnostic.get_line_diagnostics() + end local params = util.make_given_range_params(start_pos, end_pos) params.context = context - request('textDocument/codeAction', params) + code_action_request(params) end --- Executes an LSP server command. --- ---@param command A valid `ExecuteCommandParams` object ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_executeCommand +---@param command A valid `ExecuteCommandParams` object +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_executeCommand function M.execute_command(command) validate { command = { command.command, 's' }, diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua index fbd37e3830..20b203fe99 100644 --- a/runtime/lua/vim/lsp/codelens.lua +++ b/runtime/lua/vim/lsp/codelens.lua @@ -22,19 +22,33 @@ local namespaces = setmetatable({}, { end; }) ---@private +---@private M.__namespaces = namespaces ---@private +---@private 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 command = lens.command + local fn = vim.lsp.commands[command.command] + if fn then + fn(command, { bufnr = bufnr, client_id = client_id }) + return + end -- Need to use the client that returned the lens → must not use buf_request local client = vim.lsp.get_client_by_id(client_id) assert(client, 'Client is required to execute lens, client_id=' .. client_id) - client.request('workspace/executeCommand', lens.command, function(...) + local command_provider = client.server_capabilities.executeCommandProvider + local commands = type(command_provider) == 'table' and command_provider.commands or {} + if not vim.tbl_contains(commands, command.command) then + vim.notify(string.format( + "Language server does not support command `%s`. This command may require a client extension.", command.command), + vim.log.levels.WARN) + return + end + client.request('workspace/executeCommand', command, function(...) local result = vim.lsp.handlers['workspace/executeCommand'](...) M.refresh() return result @@ -44,9 +58,10 @@ end --- Return all lenses for the given buffer --- +---@param bufnr number Buffer number. 0 can be used for the current buffer. ---@return table (`CodeLens[]`) function M.get(bufnr) - local lenses_by_client = lens_cache_by_buf[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 @@ -111,15 +126,19 @@ function M.display(lenses, bufnr, client_id) local ns = namespaces[client_id] local num_lines = api.nvim_buf_line_count(bufnr) for i = 0, num_lines do - local line_lenses = lenses_by_lnum[i] + local line_lenses = lenses_by_lnum[i] or {} api.nvim_buf_clear_namespace(bufnr, ns, i, i + 1) local chunks = {} - for _, lens in pairs(line_lenses or {}) do + local num_line_lenses = #line_lenses + for j, lens in ipairs(line_lenses) do local text = lens.command and lens.command.title or 'Unresolved lens ...' table.insert(chunks, {text, 'LspCodeLens' }) + if j < num_line_lenses then + table.insert(chunks, {' | ', 'LspCodeLensSeparator' }) + end end if #chunks > 0 then - api.nvim_buf_set_virtual_text(bufnr, ns, i, chunks, {}) + api.nvim_buf_set_extmark(bufnr, ns, i, 0, { virt_text = chunks }) end end end @@ -147,7 +166,7 @@ function M.save(lenses, bufnr, client_id) end ---@private +---@private local function resolve_lenses(lenses, bufnr, client_id, callback) lenses = lenses or {} local num_lens = vim.tbl_count(lenses) @@ -156,7 +175,7 @@ local function resolve_lenses(lenses, bufnr, client_id, callback) return end - --@private + ---@private local function countdown() num_lens = num_lens - 1 if num_lens == 0 then @@ -169,18 +188,18 @@ local function resolve_lenses(lenses, bufnr, client_id, callback) if lens.command then countdown() else - client.request('codeLens/resolve', lens, function(_, _, result) + client.request('codeLens/resolve', lens, function(_, result) if result and result.command then lens.command = result.command -- Eager display to have some sort of incremental feedback -- Once all lenses got resolved there will be a full redraw for all lenses -- So that multiple lens per line are properly displayed - api.nvim_buf_set_virtual_text( + api.nvim_buf_set_extmark( bufnr, ns, lens.range.start.line, - {{ lens.command.title, 'LspCodeLens' },}, - {} + 0, + { virt_text = {{ lens.command.title, 'LspCodeLens' }} } ) end countdown() @@ -192,17 +211,17 @@ end --- |lsp-handler| for the method `textDocument/codeLens` --- -function M.on_codelens(err, _, result, client_id, bufnr) +function M.on_codelens(err, result, ctx, _) assert(not err, vim.inspect(err)) - M.save(result, bufnr, client_id) + M.save(result, ctx.bufnr, ctx.client_id) -- Eager display for any resolved (and unresolved) lenses and refresh them -- once resolved. - M.display(result, bufnr, client_id) - resolve_lenses(result, bufnr, client_id, function() - M.display(result, bufnr, client_id) - active_refreshes[bufnr] = nil + M.display(result, ctx.bufnr, ctx.client_id) + resolve_lenses(result, ctx.bufnr, ctx.client_id, function() + M.display(result, ctx.bufnr, ctx.client_id) + active_refreshes[ctx.bufnr] = nil end) end diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 64dde78f17..c6c08a15d3 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -1,126 +1,19 @@ -local api = vim.api -local validate = vim.validate - -local highlight = vim.highlight -local log = require('vim.lsp.log') -local protocol = require('vim.lsp.protocol') -local util = require('vim.lsp.util') - -local if_nil = vim.F.if_nil - ---@class DiagnosticSeverity -local DiagnosticSeverity = protocol.DiagnosticSeverity - -local to_severity = function(severity) - if not severity then return nil end - return type(severity) == 'string' and DiagnosticSeverity[severity] or severity -end - -local filter_to_severity_limit = function(severity, diagnostics) - local filter_level = to_severity(severity) - if not filter_level then - return diagnostics - end - - return vim.tbl_filter(function(t) return t.severity == filter_level end, diagnostics) -end - -local filter_by_severity_limit = function(severity_limit, diagnostics) - local filter_level = to_severity(severity_limit) - if not filter_level then - return diagnostics - end - - return vim.tbl_filter(function(t) return t.severity <= filter_level end, diagnostics) -end - -local to_position = function(position, bufnr) - vim.validate { position = {position, 't'} } - - return { - position.line, - util._get_line_byte_from_position(bufnr, position) - } -end - - ---@brief lsp-diagnostic --- ---@class Diagnostic ---@field range Range ---@field message string ---@field severity DiagnosticSeverity|nil ---@field code number | string ---@field source string ---@field tags DiagnosticTag[] ---@field relatedInformation DiagnosticRelatedInformation[] +---@class Diagnostic +---@field range Range +---@field message string +---@field severity DiagnosticSeverity|nil +---@field code number | string +---@field source string +---@field tags DiagnosticTag[] +---@field relatedInformation DiagnosticRelatedInformation[] local M = {} --- Diagnostic Highlights {{{ - --- TODO(tjdevries): Determine how to generate documentation for these --- and how to configure them to be easy for users. --- --- For now, just use the following script. It should work pretty good. ---[[ -local levels = {"Error", "Warning", "Information", "Hint" } - -local all_info = { - { "Default", "Used as the base highlight group, other highlight groups link to", }, - { "VirtualText", 'Used for "%s" diagnostic virtual text.\n See |vim.lsp.diagnostic.set_virtual_text()|', }, - { "Underline", 'Used to underline "%s" diagnostics.\n See |vim.lsp.diagnostic.set_underline()|', }, - { "Floating", 'Used to color "%s" diagnostic messages in diagnostics float.\n See |vim.lsp.diagnostic.show_line_diagnostics()|', }, - { "Sign", 'Used for "%s" signs in sing column.\n See |vim.lsp.diagnostic.set_signs()|', }, -} - -local results = {} -for _, info in ipairs(all_info) do - for _, level in ipairs(levels) do - local name = info[1] - local description = info[2] - local fullname = string.format("Lsp%s%s", name, level) - table.insert(results, string.format( - "%78s", string.format("*hl-%s*", fullname)) - ) - - table.insert(results, fullname) - table.insert(results, string.format(" %s", description)) - table.insert(results, "") - end -end - --- print(table.concat(results, '\n')) -vim.fn.setreg("*", table.concat(results, '\n')) ---]] - -local diagnostic_severities = { - [DiagnosticSeverity.Error] = { guifg = "Red" }; - [DiagnosticSeverity.Warning] = { guifg = "Orange" }; - [DiagnosticSeverity.Information] = { guifg = "LightBlue" }; - [DiagnosticSeverity.Hint] = { guifg = "LightGrey" }; -} - --- Make a map from DiagnosticSeverity -> Highlight Name -local make_highlight_map = function(base_name) - local result = {} - for k, _ in pairs(diagnostic_severities) do - result[k] = "LspDiagnostics" .. base_name .. DiagnosticSeverity[k] - end - - return result -end - -local default_highlight_map = make_highlight_map("Default") -local virtual_text_highlight_map = make_highlight_map("VirtualText") -local underline_highlight_map = make_highlight_map("Underline") -local floating_highlight_map = make_highlight_map("Floating") -local sign_highlight_map = make_highlight_map("Sign") - --- }}} --- Diagnostic Namespaces {{{ local DEFAULT_CLIENT_ID = -1 -local get_client_id = function(client_id) +---@private +local function get_client_id(client_id) if client_id == nil then client_id = DEFAULT_CLIENT_ID end @@ -128,472 +21,414 @@ local get_client_id = function(client_id) return client_id end -local get_bufnr = function(bufnr) +---@private +local function get_bufnr(bufnr) if not bufnr then - return api.nvim_get_current_buf() + return vim.api.nvim_get_current_buf() elseif bufnr == 0 then - return api.nvim_get_current_buf() + return vim.api.nvim_get_current_buf() end return bufnr end - ---- Create a namespace table, used to track a client's buffer local items -local _make_namespace_table = function(namespace, api_namespace) - vim.validate { namespace = { namespace, 's' } } - - return setmetatable({ - [DEFAULT_CLIENT_ID] = api.nvim_create_namespace(namespace) - }, { - __index = function(t, client_id) - client_id = get_client_id(client_id) - - if rawget(t, client_id) == nil then - local value = string.format("%s:%s", namespace, client_id) - - if api_namespace then - value = api.nvim_create_namespace(value) - end - - rawset(t, client_id, value) - end - - return rawget(t, client_id) - end - }) +---@private +local function severity_lsp_to_vim(severity) + if type(severity) == 'string' then + severity = vim.lsp.protocol.DiagnosticSeverity[severity] + end + return severity end -local _diagnostic_namespaces = _make_namespace_table("vim_lsp_diagnostics", true) -local _sign_namespaces = _make_namespace_table("vim_lsp_signs", false) - ---@private -function M._get_diagnostic_namespace(client_id) - return _diagnostic_namespaces[client_id] +---@private +local function severity_vim_to_lsp(severity) + if type(severity) == 'string' then + severity = vim.diagnostic.severity[severity] + end + return severity end ---@private -function M._get_sign_namespace(client_id) - return _sign_namespaces[client_id] -end --- }}} --- Diagnostic Buffer & Client metatables {{{ -local bufnr_and_client_cacher_mt = { - __index = function(t, bufnr) - if bufnr == 0 or bufnr == nil then - bufnr = vim.api.nvim_get_current_buf() - end +---@private +local function line_byte_from_position(lines, lnum, col, offset_encoding) + if not lines or offset_encoding == "utf-8" then + return col + end - if rawget(t, bufnr) == nil then - rawset(t, bufnr, {}) - end + local line = lines[lnum + 1] + local ok, result = pcall(vim.str_byteindex, line, col, offset_encoding == "utf-16") + if ok then + return result + end - return rawget(t, bufnr) - end, + return col +end - __newindex = function(t, bufnr, v) - if bufnr == 0 or bufnr == nil then - bufnr = vim.api.nvim_get_current_buf() - end +---@private +local function get_buf_lines(bufnr) + if vim.api.nvim_buf_is_loaded(bufnr) then + return vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + end - rawset(t, bufnr, v) - end, -} --- }}} --- Diagnostic Saving & Caching {{{ -local _diagnostic_cleanup = setmetatable({}, bufnr_and_client_cacher_mt) -local diagnostic_cache = setmetatable({}, bufnr_and_client_cacher_mt) -local diagnostic_cache_lines = setmetatable({}, bufnr_and_client_cacher_mt) -local diagnostic_cache_counts = setmetatable({}, bufnr_and_client_cacher_mt) + local filename = vim.api.nvim_buf_get_name(bufnr) + local f = io.open(filename) + if not f then + return + end -local _bufs_waiting_to_update = setmetatable({}, bufnr_and_client_cacher_mt) + local content = f:read("*a") + if not content then + -- Some LSP servers report diagnostics at a directory level, in which case + -- io.read() returns nil + f:close() + return + end ---- Store Diagnostic[] by line ---- ----@param diagnostics Diagnostic[] ----@return table<number, Diagnostic[]> -local _diagnostic_lines = function(diagnostics) - if not diagnostics then return end + local lines = vim.split(content, "\n") + f:close() + return lines +end - local diagnostics_by_line = {} - for _, diagnostic in ipairs(diagnostics) do +---@private +local function diagnostic_lsp_to_vim(diagnostics, bufnr, client_id) + local buf_lines = get_buf_lines(bufnr) + local client = vim.lsp.get_client_by_id(client_id) + local offset_encoding = client and client.offset_encoding or "utf-16" + return vim.tbl_map(function(diagnostic) local start = diagnostic.range.start - local line_diagnostics = diagnostics_by_line[start.line] - if not line_diagnostics then - line_diagnostics = {} - diagnostics_by_line[start.line] = line_diagnostics - end - table.insert(line_diagnostics, diagnostic) + local _end = diagnostic.range["end"] + return { + lnum = start.line, + col = line_byte_from_position(buf_lines, start.line, start.character, offset_encoding), + end_lnum = _end.line, + end_col = line_byte_from_position(buf_lines, _end.line, _end.character, offset_encoding), + severity = severity_lsp_to_vim(diagnostic.severity), + message = diagnostic.message, + source = diagnostic.source, + user_data = { + lsp = { + code = diagnostic.code, + codeDescription = diagnostic.codeDescription, + tags = diagnostic.tags, + relatedInformation = diagnostic.relatedInformation, + data = diagnostic.data, + }, + }, + } + end, diagnostics) +end + +---@private +local function diagnostic_vim_to_lsp(diagnostics) + return vim.tbl_map(function(diagnostic) + return vim.tbl_extend("error", { + range = { + start = { + line = diagnostic.lnum, + character = diagnostic.col, + }, + ["end"] = { + line = diagnostic.end_lnum, + character = diagnostic.end_col, + }, + }, + severity = severity_vim_to_lsp(diagnostic.severity), + message = diagnostic.message, + source = diagnostic.source, + }, diagnostic.user_data and (diagnostic.user_data.lsp or {}) or {}) + end, diagnostics) +end + +local _client_namespaces = {} + +--- Get the diagnostic namespace associated with an LSP client |vim.diagnostic|. +--- +---@param client_id number The id of the LSP client +function M.get_namespace(client_id) + vim.validate { client_id = { client_id, 'n' } } + if not _client_namespaces[client_id] then + local name = string.format("vim.lsp.client-%d", client_id) + _client_namespaces[client_id] = vim.api.nvim_create_namespace(name) end - return diagnostics_by_line + return _client_namespaces[client_id] end ---- Get the count of M by Severity +--- Save diagnostics to the current buffer. --- +--- Handles saving diagnostics from multiple clients in the same buffer. ---@param diagnostics Diagnostic[] ----@return table<DiagnosticSeverity, number> -local _diagnostic_counts = function(diagnostics) - if not diagnostics then return end +---@param bufnr number +---@param client_id number +---@private +function M.save(diagnostics, bufnr, client_id) + local namespace = M.get_namespace(client_id) + vim.diagnostic.set(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id)) +end +-- }}} - local counts = {} - for _, diagnostic in pairs(diagnostics) do - if diagnostic.severity then - local val = counts[diagnostic.severity] - if val == nil then - val = 0 - end +--- |lsp-handler| for the method "textDocument/publishDiagnostics" +--- +--- See |vim.diagnostic.config()| for configuration options. Handler-specific +--- configuration can be set using |vim.lsp.with()|: +--- <pre> +--- vim.lsp.handlers["textDocument/publishDiagnostics"] = vim.lsp.with( +--- vim.lsp.diagnostic.on_publish_diagnostics, { +--- -- Enable underline, use default values +--- underline = true, +--- -- Enable virtual text, override spacing to 4 +--- virtual_text = { +--- spacing = 4, +--- }, +--- -- Use a function to dynamically turn signs off +--- -- and on, using buffer local variables +--- signs = function(bufnr, client_id) +--- return vim.bo[bufnr].show_signs == false +--- end, +--- -- Disable a feature +--- update_in_insert = false, +--- } +--- ) +--- </pre> +--- +---@param config table Configuration table (see |vim.diagnostic.config()|). +function M.on_publish_diagnostics(_, result, ctx, config) + local client_id = ctx.client_id + local uri = result.uri + local bufnr = vim.uri_to_bufnr(uri) - counts[diagnostic.severity] = val + 1 - end + if not bufnr then + return end - return counts -end - ---@private ---- Set the different diagnostic cache after `textDocument/publishDiagnostics` ----@param diagnostics Diagnostic[] ----@param bufnr number ----@param client_id number ----@return nil -local function set_diagnostic_cache(diagnostics, bufnr, client_id) client_id = get_client_id(client_id) - - -- https://microsoft.github.io/language-server-protocol/specifications/specification-current/#diagnostic - -- - -- The diagnostic's severity. Can be omitted. If omitted it is up to the - -- client to interpret diagnostics as error, warning, info or hint. - -- TODO: Replace this with server-specific heuristics to infer severity. - local buf_line_count = vim.api.nvim_buf_line_count(bufnr) - for _, diagnostic in ipairs(diagnostics) do - if diagnostic.severity == nil then - diagnostic.severity = DiagnosticSeverity.Error - end - -- Account for servers that place diagnostics on terminating newline - if buf_line_count > 0 then - diagnostic.range.start.line = math.max(math.min( - diagnostic.range.start.line, buf_line_count - 1 - ), 0) - diagnostic.range["end"].line = math.max(math.min( - diagnostic.range["end"].line, buf_line_count - 1 - ), 0) + local namespace = M.get_namespace(client_id) + local diagnostics = result.diagnostics + + if config then + for _, opt in pairs(config) do + if type(opt) == 'table' then + if not opt.severity and opt.severity_limit then + opt.severity = {min=severity_lsp_to_vim(opt.severity_limit)} + end + end end end - diagnostic_cache[bufnr][client_id] = diagnostics - diagnostic_cache_lines[bufnr][client_id] = _diagnostic_lines(diagnostics) - diagnostic_cache_counts[bufnr][client_id] = _diagnostic_counts(diagnostics) -end - + vim.diagnostic.set(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id), config) ---@private ---- Clear the cached diagnostics ----@param bufnr number ----@param client_id number -local function clear_diagnostic_cache(bufnr, client_id) - client_id = get_client_id(client_id) - - diagnostic_cache[bufnr][client_id] = nil - diagnostic_cache_lines[bufnr][client_id] = nil - diagnostic_cache_counts[bufnr][client_id] = nil + -- Keep old autocmd for back compat. This should eventually be removed. + vim.api.nvim_command("doautocmd <nomodeline> User LspDiagnosticsChanged") end ---- Save diagnostics to the current buffer. +--- Clear diagnotics and diagnostic cache. +--- +--- Diagnostic producers should prefer |vim.diagnostic.reset()|. However, +--- this method signature is still used internally in some parts of the LSP +--- implementation so it's simply marked @private rather than @deprecated. --- ---- Handles saving diagnostics from multiple clients in the same buffer. ----@param diagnostics Diagnostic[] ----@param bufnr number ---@param client_id number -function M.save(diagnostics, bufnr, client_id) - validate { - diagnostics = {diagnostics, 't'}, - bufnr = {bufnr, 'n'}, - client_id = {client_id, 'n', true}, - } - - if not diagnostics then return end - - bufnr = get_bufnr(bufnr) - client_id = get_client_id(client_id) - - if not _diagnostic_cleanup[bufnr][client_id] then - _diagnostic_cleanup[bufnr][client_id] = true - - -- Clean up our data when the buffer unloads. - api.nvim_buf_attach(bufnr, false, { - on_detach = function(_, b) - clear_diagnostic_cache(b, client_id) - _diagnostic_cleanup[b][client_id] = nil +---@param buffer_client_map table map of buffers to active clients +---@private +function M.reset(client_id, buffer_client_map) + buffer_client_map = vim.deepcopy(buffer_client_map) + vim.schedule(function() + for bufnr, client_ids in pairs(buffer_client_map) do + if client_ids[client_id] then + local namespace = M.get_namespace(client_id) + vim.diagnostic.reset(namespace, bufnr) end - }) - end - - set_diagnostic_cache(diagnostics, bufnr, client_id) + end + end) end --- }}} --- Diagnostic Retrieval {{{ +-- Deprecated Functions {{{ --- Get all diagnostics for clients --- +---@deprecated Prefer |vim.diagnostic.get()| +--- ---@param client_id number Restrict included diagnostics to the client --- If nil, diagnostics of all clients are included. ---@return table with diagnostics grouped by bufnr (bufnr: Diagnostic[]) function M.get_all(client_id) - local diagnostics_by_bufnr = {} - for bufnr, buf_diagnostics in pairs(diagnostic_cache) do - diagnostics_by_bufnr[bufnr] = {} - for cid, client_diagnostics in pairs(buf_diagnostics) do - if client_id == nil or cid == client_id then - vim.list_extend(diagnostics_by_bufnr[bufnr], client_diagnostics) - end - end + local result = {} + local namespace + if client_id then + namespace = M.get_namespace(client_id) end - return diagnostics_by_bufnr + for _, bufnr in ipairs(vim.api.nvim_list_bufs()) do + local diagnostics = diagnostic_vim_to_lsp(vim.diagnostic.get(bufnr, {namespace = namespace})) + result[bufnr] = diagnostics + end + return result end --- Return associated diagnostics for bufnr --- +---@deprecated Prefer |vim.diagnostic.get()| +--- ---@param bufnr number ---@param client_id number|nil If nil, then return all of the diagnostics. --- Else, return just the diagnostics associated with the client_id. -function M.get(bufnr, client_id) +---@param predicate function|nil Optional function for filtering diagnostics +function M.get(bufnr, client_id, predicate) + predicate = predicate or function() return true end if client_id == nil then local all_diagnostics = {} - for iter_client_id, _ in pairs(diagnostic_cache[bufnr]) do - local iter_diagnostics = M.get(bufnr, iter_client_id) - + vim.lsp.for_each_buffer_client(bufnr, function(_, iter_client_id, _) + local iter_diagnostics = vim.tbl_filter(predicate, M.get(bufnr, iter_client_id)) for _, diagnostic in ipairs(iter_diagnostics) do table.insert(all_diagnostics, diagnostic) end - end - + end) return all_diagnostics end - return diagnostic_cache[bufnr][client_id] or {} + local namespace = M.get_namespace(client_id) + return diagnostic_vim_to_lsp(vim.tbl_filter(predicate, vim.diagnostic.get(bufnr, {namespace=namespace}))) end --- Get the diagnostics by line --- ----@param bufnr number The buffer number ----@param line_nr number The line number +--- Marked private as this is used internally by the LSP subsystem, but +--- most users should instead prefer |vim.diagnostic.get()|. +--- +---@param bufnr number|nil The buffer number +---@param line_nr number|nil The line number ---@param opts table|nil Configuration keys --- - severity: (DiagnosticSeverity, default nil) --- - Only return diagnostics with this severity. Overrides severity_limit --- - severity_limit: (DiagnosticSeverity, default nil) --- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. ----@param client_id number the client id +---@param client_id|nil number the client id ---@return table Table with map of line number to list of diagnostics. --- Structured: { [1] = {...}, [5] = {.... } } +--- Structured: { [1] = {...}, [5] = {.... } } +---@private function M.get_line_diagnostics(bufnr, line_nr, opts, client_id) opts = opts or {} - - bufnr = bufnr or vim.api.nvim_get_current_buf() - line_nr = line_nr or vim.api.nvim_win_get_cursor(0)[1] - 1 - - local client_get_diags = function(iter_client_id) - return (diagnostic_cache_lines[bufnr][iter_client_id] or {})[line_nr] or {} + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end - local line_diagnostics - if client_id == nil then - line_diagnostics = {} - for iter_client_id, _ in pairs(diagnostic_cache_lines[bufnr]) do - for _, diagnostic in ipairs(client_get_diags(iter_client_id)) do - table.insert(line_diagnostics, diagnostic) - end - end - else - line_diagnostics = vim.deepcopy(client_get_diags(client_id)) + if client_id then + opts.namespace = M.get_namespace(client_id) end - if opts.severity then - line_diagnostics = filter_to_severity_limit(opts.severity, line_diagnostics) - elseif opts.severity_limit then - line_diagnostics = filter_by_severity_limit(opts.severity_limit, line_diagnostics) + if not line_nr then + line_nr = vim.api.nvim_win_get_cursor(0)[1] - 1 end - table.sort(line_diagnostics, function(a, b) return a.severity < b.severity end) + opts.lnum = line_nr - return line_diagnostics + return diagnostic_vim_to_lsp(vim.diagnostic.get(bufnr, opts)) end --- Get the counts for a particular severity --- ---- Useful for showing diagnostic counts in statusline. eg: ---- ---- <pre> ---- function! LspStatus() abort ---- let sl = '' ---- if luaeval('not vim.tbl_isempty(vim.lsp.buf_get_clients(0))') ---- let sl.='%#MyStatuslineLSP#E:' ---- let sl.='%#MyStatuslineLSPErrors#%{luaeval("vim.lsp.diagnostic.get_count(0, [[Error]])")}' ---- let sl.='%#MyStatuslineLSP# W:' ---- let sl.='%#MyStatuslineLSPWarnings#%{luaeval("vim.lsp.diagnostic.get_count(0, [[Warning]])")}' ---- else ---- let sl.='%#MyStatuslineLSPErrors#off' ---- endif ---- return sl ---- endfunction ---- let &l:statusline = '%#MyStatuslineLSP#LSP '.LspStatus() ---- </pre> +---@deprecated Prefer |vim.diagnostic.get_count()| --- ---@param bufnr number The buffer number ---@param severity DiagnosticSeverity ---@param client_id number the client id function M.get_count(bufnr, severity, client_id) - if client_id == nil then - local total = 0 - for iter_client_id, _ in pairs(diagnostic_cache_counts[bufnr]) do - total = total + M.get_count(bufnr, severity, iter_client_id) - end - - return total + severity = severity_lsp_to_vim(severity) + local opts = { severity = severity } + if client_id ~= nil then + opts.namespace = M.get_namespace(client_id) end - return (diagnostic_cache_counts[bufnr][client_id] or {})[DiagnosticSeverity[severity]] or 0 -end - - --- }}} --- Diagnostic Movements {{{ - ---- Helper function to iterate through all of the diagnostic lines ----@return table list of diagnostics -local _iter_diagnostic_lines = function(start, finish, step, bufnr, opts, client_id) - if bufnr == nil then - bufnr = vim.api.nvim_get_current_buf() - end - - local wrap = if_nil(opts.wrap, true) - - local search = function(search_start, search_finish, search_step) - for line_nr = search_start, search_finish, search_step do - local line_diagnostics = M.get_line_diagnostics(bufnr, line_nr, opts, client_id) - if line_diagnostics and not vim.tbl_isempty(line_diagnostics) then - return line_diagnostics - end - end - end - - local result = search(start, finish, step) - - if wrap then - local wrap_start, wrap_finish - if step == 1 then - wrap_start, wrap_finish = 1, start - else - wrap_start, wrap_finish = vim.api.nvim_buf_line_count(bufnr), start - end - - if not result then - result = search(wrap_start, wrap_finish, step) - end - end - - return result -end - ---@private ---- Helper function to ierate through diagnostic lines and return a position ---- ----@return table {row, col} -local function _iter_diagnostic_lines_pos(opts, line_diagnostics) - opts = opts or {} - - local win_id = opts.win_id or vim.api.nvim_get_current_win() - local bufnr = vim.api.nvim_win_get_buf(win_id) - - if line_diagnostics == nil or vim.tbl_isempty(line_diagnostics) then - return false - end - - local iter_diagnostic = line_diagnostics[1] - return to_position(iter_diagnostic.range.start, bufnr) -end - ---@private --- Move to the diagnostic position -local function _iter_diagnostic_move_pos(name, opts, pos) - opts = opts or {} - - local enable_popup = if_nil(opts.enable_popup, true) - local win_id = opts.win_id or vim.api.nvim_get_current_win() - - if not pos then - print(string.format("%s: No more valid diagnostics to move to.", name)) - return - end - - vim.api.nvim_win_set_cursor(win_id, {pos[1] + 1, pos[2]}) - - if enable_popup then - -- This is a bit weird... I'm surprised that we need to wait til the next tick to do this. - vim.schedule(function() - M.show_line_diagnostics(opts.popup_opts, vim.api.nvim_win_get_buf(win_id)) - end) - end + return #vim.diagnostic.get(bufnr, opts) end --- Get the previous diagnostic closest to the cursor_position --- +---@deprecated Prefer |vim.diagnostic.get_prev()| +--- ---@param opts table See |vim.lsp.diagnostic.goto_next()| ---@return table Previous diagnostic function M.get_prev(opts) - opts = opts or {} - - local win_id = opts.win_id or vim.api.nvim_get_current_win() - local bufnr = vim.api.nvim_win_get_buf(win_id) - local cursor_position = opts.cursor_position or vim.api.nvim_win_get_cursor(win_id) - - return _iter_diagnostic_lines(cursor_position[1] - 2, 0, -1, bufnr, opts, opts.client_id) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return diagnostic_vim_to_lsp({vim.diagnostic.get_prev(opts)})[1] end --- Return the pos, {row, col}, for the prev diagnostic in the current buffer. +--- +---@deprecated Prefer |vim.diagnostic.get_prev_pos()| +--- ---@param opts table See |vim.lsp.diagnostic.goto_next()| ---@return table Previous diagnostic position function M.get_prev_pos(opts) - return _iter_diagnostic_lines_pos( - opts, - M.get_prev(opts) - ) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return vim.diagnostic.get_prev_pos(opts) end --- Move to the previous diagnostic +--- +---@deprecated Prefer |vim.diagnostic.goto_prev()| +--- ---@param opts table See |vim.lsp.diagnostic.goto_next()| function M.goto_prev(opts) - return _iter_diagnostic_move_pos( - "DiagnosticPrevious", - opts, - M.get_prev_pos(opts) - ) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return vim.diagnostic.goto_prev(opts) end --- Get the next diagnostic closest to the cursor_position +--- +---@deprecated Prefer |vim.diagnostic.get_next()| +--- ---@param opts table See |vim.lsp.diagnostic.goto_next()| ---@return table Next diagnostic function M.get_next(opts) - opts = opts or {} - - local win_id = opts.win_id or vim.api.nvim_get_current_win() - local bufnr = vim.api.nvim_win_get_buf(win_id) - local cursor_position = opts.cursor_position or vim.api.nvim_win_get_cursor(win_id) - - return _iter_diagnostic_lines(cursor_position[1], vim.api.nvim_buf_line_count(bufnr), 1, bufnr, opts, opts.client_id) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return diagnostic_vim_to_lsp({vim.diagnostic.get_next(opts)})[1] end --- Return the pos, {row, col}, for the next diagnostic in the current buffer. +--- +---@deprecated Prefer |vim.diagnostic.get_next_pos()| +--- ---@param opts table See |vim.lsp.diagnostic.goto_next()| ---@return table Next diagnostic position function M.get_next_pos(opts) - return _iter_diagnostic_lines_pos( - opts, - M.get_next(opts) - ) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return vim.diagnostic.get_next_pos(opts) end --- Move to the next diagnostic +--- +---@deprecated Prefer |vim.diagnostic.goto_next()| +--- ---@param opts table|nil Configuration table. Keys: --- - {client_id}: (number) --- - If nil, will consider all clients attached to buffer. @@ -612,25 +447,20 @@ end --- - {win_id}: (number, default 0) --- - Window ID function M.goto_next(opts) - return _iter_diagnostic_move_pos( - "DiagnosticNext", - opts, - M.get_next_pos(opts) - ) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} + end + end + return vim.diagnostic.goto_next(opts) end --- }}} --- Diagnostic Setters {{{ --- Set signs for given diagnostics --- ---- Sign characters can be customized with the following commands: +---@deprecated Prefer |vim.diagnostic._set_signs()| --- ---- <pre> ---- sign define LspDiagnosticsSignError text=E texthl=LspDiagnosticsSignError linehl= numhl= ---- sign define LspDiagnosticsSignWarning text=W texthl=LspDiagnosticsSignWarning linehl= numhl= ---- sign define LspDiagnosticsSignInformation text=I texthl=LspDiagnosticsSignInformation linehl= numhl= ---- sign define LspDiagnosticsSignHint text=H texthl=LspDiagnosticsSignHint linehl= numhl= ---- </pre> ---@param diagnostics Diagnostic[] ---@param bufnr number The buffer number ---@param client_id number the client id @@ -639,51 +469,18 @@ end --- - priority: Set the priority of the signs. --- - severity_limit (DiagnosticSeverity): --- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. -function M.set_signs(diagnostics, bufnr, client_id, sign_ns, opts) - opts = opts or {} - sign_ns = sign_ns or M._get_sign_namespace(client_id) - - if not diagnostics then - diagnostics = diagnostic_cache[bufnr][client_id] - end - - if not diagnostics then - return +function M.set_signs(diagnostics, bufnr, client_id, _, opts) + local namespace = M.get_namespace(client_id) + if opts and not opts.severity and opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end - bufnr = get_bufnr(bufnr) - diagnostics = filter_by_severity_limit(opts.severity_limit, diagnostics) - - local ok = true - for _, diagnostic in ipairs(diagnostics) do - - ok = ok and pcall(vim.fn.sign_place, - 0, - sign_ns, - sign_highlight_map[diagnostic.severity], - bufnr, - { - priority = opts.priority, - lnum = diagnostic.range.start.line + 1 - } - ) - end - - if not ok then - log.debug("Failed to place signs:", diagnostics) - end + vim.diagnostic._set_signs(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id), opts) end --- Set underline for given diagnostics --- ---- Underline highlights can be customized by changing the following |:highlight| groups. ---- ---- <pre> ---- LspDiagnosticsUnderlineError ---- LspDiagnosticsUnderlineWarning ---- LspDiagnosticsUnderlineInformation ---- LspDiagnosticsUnderlineHint ---- </pre> +---@deprecated Prefer |vim.diagnostic._set_underline()| --- ---@param diagnostics Diagnostic[] ---@param bufnr number: The buffer number @@ -692,43 +489,17 @@ end ---@param opts table: Configuration table: --- - severity_limit (DiagnosticSeverity): --- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. -function M.set_underline(diagnostics, bufnr, client_id, diagnostic_ns, opts) - opts = opts or {} - - diagnostic_ns = diagnostic_ns or M._get_diagnostic_namespace(client_id) - diagnostics = filter_by_severity_limit(opts.severity_limit, diagnostics) - - for _, diagnostic in ipairs(diagnostics) do - local start = diagnostic.range["start"] - local finish = diagnostic.range["end"] - local higroup = underline_highlight_map[diagnostic.severity] - - if higroup == nil then - -- Default to error if we don't have a highlight associated - higroup = underline_highlight_map[DiagnosticSeverity.Error] - end - - highlight.range( - bufnr, - diagnostic_ns, - higroup, - to_position(start, bufnr), - to_position(finish, bufnr) - ) +function M.set_underline(diagnostics, bufnr, client_id, _, opts) + local namespace = M.get_namespace(client_id) + if opts and not opts.severity and opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end + return vim.diagnostic._set_underline(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id), opts) end --- Virtual Text {{{ --- Set virtual text given diagnostics --- ---- Virtual text highlights can be customized by changing the following |:highlight| groups. ---- ---- <pre> ---- LspDiagnosticsVirtualTextError ---- LspDiagnosticsVirtualTextWarning ---- LspDiagnosticsVirtualTextInformation ---- LspDiagnosticsVirtualTextHint ---- </pre> +---@deprecated Prefer |vim.diagnostic._set_virtual_text()| --- ---@param diagnostics Diagnostic[] ---@param bufnr number @@ -739,460 +510,133 @@ end --- - spacing (number): Number of spaces to insert before virtual text --- - severity_limit (DiagnosticSeverity): --- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. -function M.set_virtual_text(diagnostics, bufnr, client_id, diagnostic_ns, opts) - opts = opts or {} - - client_id = get_client_id(client_id) - diagnostic_ns = diagnostic_ns or M._get_diagnostic_namespace(client_id) - - local buffer_line_diagnostics - if diagnostics then - buffer_line_diagnostics = _diagnostic_lines(diagnostics) - else - buffer_line_diagnostics = diagnostic_cache_lines[bufnr][client_id] - end - - if not buffer_line_diagnostics then - return nil - end - - for line, line_diagnostics in pairs(buffer_line_diagnostics) do - line_diagnostics = filter_by_severity_limit(opts.severity_limit, line_diagnostics) - local virt_texts = M.get_virtual_text_chunks_for_line(bufnr, line, line_diagnostics, opts) - - if virt_texts then - api.nvim_buf_set_virtual_text(bufnr, diagnostic_ns, line, virt_texts, {}) - end +function M.set_virtual_text(diagnostics, bufnr, client_id, _, opts) + local namespace = M.get_namespace(client_id) + if opts and not opts.severity and opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end + return vim.diagnostic._set_virtual_text(namespace, bufnr, diagnostic_lsp_to_vim(diagnostics, bufnr, client_id), opts) end ---- Default function to get text chunks to display using `nvim_buf_set_virtual_text`. +--- Default function to get text chunks to display using |nvim_buf_set_extmark()|. +--- +---@deprecated Prefer |vim.diagnostic.get_virt_text_chunks()| +--- ---@param bufnr number The buffer to display the virtual text in ---@param line number The line number to display the virtual text on ---@param line_diags Diagnostic[] The diagnostics associated with the line ---@param opts table See {opts} from |vim.lsp.diagnostic.set_virtual_text()| ----@return table chunks, as defined by |nvim_buf_set_virtual_text()| -function M.get_virtual_text_chunks_for_line(bufnr, line, line_diags, opts) - assert(bufnr or line) - - if #line_diags == 0 then - return nil - end - - opts = opts or {} - local prefix = opts.prefix or "■" - local spacing = opts.spacing or 4 - - -- Create a little more space between virtual text and contents - local virt_texts = {{string.rep(" ", spacing)}} - - for i = 1, #line_diags - 1 do - table.insert(virt_texts, {prefix, virtual_text_highlight_map[line_diags[i].severity]}) - end - local last = line_diags[#line_diags] - - -- TODO(tjdevries): Allow different servers to be shown first somehow? - -- TODO(tjdevries): Display server name associated with these? - if last.message then - table.insert( - virt_texts, - { - string.format("%s %s", prefix, last.message:gsub("\r", ""):gsub("\n", " ")), - virtual_text_highlight_map[last.severity] - } - ) - - return virt_texts - end +---@return an array of [text, hl_group] arrays. This can be passed directly to +--- the {virt_text} option of |nvim_buf_set_extmark()|. +function M.get_virtual_text_chunks_for_line(bufnr, _, line_diags, opts) + return vim.diagnostic._get_virt_text_chunks(diagnostic_lsp_to_vim(line_diags, bufnr), opts) end --- }}} --- }}} --- Diagnostic Clear {{{ ---- Clears the currently displayed diagnostics ----@param bufnr number The buffer number ----@param client_id number the client id ----@param diagnostic_ns number|nil Associated diagnostic namespace ----@param sign_ns number|nil Associated sign namespace -function M.clear(bufnr, client_id, diagnostic_ns, sign_ns) - validate { bufnr = { bufnr, 'n' } } - - bufnr = (bufnr == 0 and api.nvim_get_current_buf()) or bufnr - - if client_id == nil then - return vim.lsp.for_each_buffer_client(bufnr, function(_, iter_client_id, _) - return M.clear(bufnr, iter_client_id) - end) - end - - diagnostic_ns = diagnostic_ns or M._get_diagnostic_namespace(client_id) - sign_ns = sign_ns or M._get_sign_namespace(client_id) - - assert(bufnr, "bufnr is required") - assert(diagnostic_ns, "Need diagnostic_ns, got nil") - assert(sign_ns, string.format("Need sign_ns, got nil %s", sign_ns)) - -- clear sign group - vim.fn.sign_unplace(sign_ns, {buffer=bufnr}) - - -- clear virtual text namespace - api.nvim_buf_clear_namespace(bufnr, diagnostic_ns, 0, -1) -end --- }}} --- Diagnostic Insert Leave Handler {{{ - ---- Callback scheduled for after leaving insert mode +--- Open a floating window with the diagnostics from {position} --- ---- Used to handle ---@private -function M._execute_scheduled_display(bufnr, client_id) - local args = _bufs_waiting_to_update[bufnr][client_id] - if not args then - return - end - - -- Clear the args so we don't display unnecessarily. - _bufs_waiting_to_update[bufnr][client_id] = nil - - M.display(nil, bufnr, client_id, args) -end - -local registered = {} - -local make_augroup_key = function(bufnr, client_id) - return string.format("LspDiagnosticInsertLeave:%s:%s", bufnr, client_id) -end - ---- Table of autocmd events to fire the update for displaying new diagnostic information -M.insert_leave_auto_cmds = { "InsertLeave", "CursorHoldI" } - ---- Used to schedule diagnostic updates upon leaving insert mode. +---@deprecated Prefer |vim.diagnostic.show_position_diagnostics()| --- ---- For parameter description, see |M.display()| -function M._schedule_display(bufnr, client_id, args) - _bufs_waiting_to_update[bufnr][client_id] = args - - local key = make_augroup_key(bufnr, client_id) - if not registered[key] then - vim.cmd(string.format("augroup %s", key)) - vim.cmd(" au!") - vim.cmd( - string.format( - [[autocmd %s <buffer=%s> :lua vim.lsp.diagnostic._execute_scheduled_display(%s, %s)]], - table.concat(M.insert_leave_auto_cmds, ","), - bufnr, - bufnr, - client_id - ) - ) - vim.cmd("augroup END") - - registered[key] = true - end -end - - ---- Used in tandem with ---- ---- For parameter description, see |M.display()| -function M._clear_scheduled_display(bufnr, client_id) - local key = make_augroup_key(bufnr, client_id) - - if registered[key] then - vim.cmd(string.format("augroup %s", key)) - vim.cmd(" au!") - vim.cmd("augroup END") - - registered[key] = nil - end -end --- }}} - --- Diagnostic Private Highlight Utilies {{{ ---- Get the severity highlight name ---@private -function M._get_severity_highlight_name(severity) - return virtual_text_highlight_map[severity] -end - ---- Get floating severity highlight name ---@private -function M._get_floating_severity_highlight_name(severity) - return floating_highlight_map[severity] -end - ---- This should be called to update the highlights for the LSP client. -function M._define_default_signs_and_highlights() - --@private - local function define_default_sign(name, properties) - if vim.tbl_isempty(vim.fn.sign_getdefined(name)) then - vim.fn.sign_define(name, properties) +---@param opts table|nil Configuration keys +--- - severity: (DiagnosticSeverity, default nil) +--- - Only return diagnostics with this severity. Overrides severity_limit +--- - severity_limit: (DiagnosticSeverity, default nil) +--- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. +--- - all opts for |show_diagnostics()| can be used here +---@param buf_nr number|nil The buffer number +---@param position table|nil The (0,0)-indexed position +---@return table {popup_bufnr, win_id} +function M.show_position_diagnostics(opts, buf_nr, position) + if opts then + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end end - - -- Initialize default diagnostic highlights - for severity, hi_info in pairs(diagnostic_severities) do - local default_highlight_name = default_highlight_map[severity] - highlight.create(default_highlight_name, hi_info, true) - - -- Default link all corresponding highlights to the default highlight - highlight.link(virtual_text_highlight_map[severity], default_highlight_name, false) - highlight.link(floating_highlight_map[severity], default_highlight_name, false) - highlight.link(sign_highlight_map[severity], default_highlight_name, false) - end - - -- Create all signs - for severity, sign_hl_name in pairs(sign_highlight_map) do - local severity_name = DiagnosticSeverity[severity] - - define_default_sign(sign_hl_name, { - text = (severity_name or 'U'):sub(1, 1), - texthl = sign_hl_name, - linehl = '', - numhl = '', - }) - end - - -- Initialize Underline highlights - for severity, underline_highlight_name in pairs(underline_highlight_map) do - highlight.create(underline_highlight_name, { - cterm = 'underline', - gui = 'underline', - guisp = diagnostic_severities[severity].guifg - }, true) - end + return vim.diagnostic.show_position_diagnostics(opts, buf_nr, position) end --- }}} --- Diagnostic Display {{{ ---- |lsp-handler| for the method "textDocument/publishDiagnostics" +--- Open a floating window with the diagnostics from {line_nr} --- ----@note Each of the configuration options accepts: ---- - `false`: Disable this feature ---- - `true`: Enable this feature, use default settings. ---- - `table`: Enable this feature, use overrides. ---- - `function`: Function with signature (bufnr, client_id) that returns any of the above. ---- <pre> ---- vim.lsp.handlers["textDocument/publishDiagnostics"] = vim.lsp.with( ---- vim.lsp.diagnostic.on_publish_diagnostics, { ---- -- Enable underline, use default values ---- underline = true, ---- -- Enable virtual text, override spacing to 4 ---- virtual_text = { ---- spacing = 4, ---- }, ---- -- Use a function to dynamically turn signs off ---- -- and on, using buffer local variables ---- signs = function(bufnr, client_id) ---- return vim.bo[bufnr].show_signs == false ---- end, ---- -- Disable a feature ---- update_in_insert = false, ---- } ---- ) ---- </pre> +---@deprecated Prefer |vim.diagnostic.show_line_diagnostics()| --- ----@param config table Configuration table. ---- - underline: (default=true) ---- - Apply underlines to diagnostics. ---- - See |vim.lsp.diagnostic.set_underline()| ---- - virtual_text: (default=true) ---- - Apply virtual text to line endings. ---- - See |vim.lsp.diagnostic.set_virtual_text()| ---- - signs: (default=true) ---- - Apply signs for diagnostics. ---- - See |vim.lsp.diagnostic.set_signs()| ---- - update_in_insert: (default=false) ---- - Update diagnostics in InsertMode or wait until InsertLeave ---- - severity_sort: (default=false) ---- - Sort diagnostics (and thus signs and virtual text) -function M.on_publish_diagnostics(_, _, params, client_id, _, config) - local uri = params.uri - local bufnr = vim.uri_to_bufnr(uri) - - if not bufnr then - return - end - - local diagnostics = params.diagnostics - - if config and if_nil(config.severity_sort, false) then - table.sort(diagnostics, function(a, b) return a.severity > b.severity end) - end - - -- Always save the diagnostics, even if the buf is not loaded. - -- Language servers may report compile or build errors via diagnostics - -- Users should be able to find these, even if they're in files which - -- are not loaded. - M.save(diagnostics, bufnr, client_id) - - -- Unloaded buffers should not handle diagnostics. - -- When the buffer is loaded, we'll call on_attach, which sends textDocument/didOpen. - -- This should trigger another publish of the diagnostics. - -- - -- In particular, this stops a ton of spam when first starting a server for current - -- unloaded buffers. - if not api.nvim_buf_is_loaded(bufnr) then - return +---@param opts table Configuration table +--- - all opts for |vim.lsp.diagnostic.get_line_diagnostics()| and +--- |show_diagnostics()| can be used here +---@param buf_nr number|nil The buffer number +---@param line_nr number|nil The line number +---@param client_id number|nil the client id +---@return table {popup_bufnr, win_id} +function M.show_line_diagnostics(opts, buf_nr, line_nr, client_id) + if client_id then + opts = opts or {} + opts.namespace = M.get_namespace(client_id) end - - M.display(diagnostics, bufnr, client_id, config) + return vim.diagnostic.show_line_diagnostics(opts, buf_nr, line_nr) end ---@private ---- Display diagnostics for the buffer, given a configuration. -function M.display(diagnostics, bufnr, client_id, config) - config = vim.lsp._with_extend('vim.lsp.diagnostic.on_publish_diagnostics', { - signs = true, - underline = true, - virtual_text = true, - update_in_insert = false, - severity_sort = false, - }, config) - - -- TODO(tjdevries): Consider how we can make this a "standardized" kind of thing for |lsp-handlers|. - -- It seems like we would probably want to do this more often as we expose more of them. - -- It provides a very nice functional interface for people to override configuration. - local resolve_optional_value = function(option) - local enabled_val = {} - - if not option then - return false - elseif option == true then - return enabled_val - elseif type(option) == 'function' then - local val = option(bufnr, client_id) - if val == true then - return enabled_val - else - return val - end - elseif type(option) == 'table' then - return option - else - error("Unexpected option type: " .. vim.inspect(option)) - end - end - - if resolve_optional_value(config.update_in_insert) then - M._clear_scheduled_display(bufnr, client_id) - else - local mode = vim.api.nvim_get_mode() - - if string.sub(mode.mode, 1, 1) == 'i' then - M._schedule_display(bufnr, client_id, config) - return - end - end - - M.clear(bufnr, client_id) - - diagnostics = diagnostics or M.get(bufnr, client_id) - - vim.api.nvim_command("doautocmd <nomodeline> User LspDiagnosticsChanged") - - if not diagnostics or vim.tbl_isempty(diagnostics) then - return - end - - local underline_opts = resolve_optional_value(config.underline) - if underline_opts then - M.set_underline(diagnostics, bufnr, client_id, nil, underline_opts) - end - - local virtual_text_opts = resolve_optional_value(config.virtual_text) - if virtual_text_opts then - M.set_virtual_text(diagnostics, bufnr, client_id, nil, virtual_text_opts) +--- Redraw diagnostics for the given buffer and client +--- +---@deprecated Prefer |vim.diagnostic.redraw()| +--- +--- This calls the "textDocument/publishDiagnostics" handler manually using +--- the cached diagnostics already received from the server. This can be useful +--- for redrawing diagnostics after making changes in diagnostics +--- configuration. |lsp-handler-configuration| +--- +---@param bufnr (optional, number): Buffer handle, defaults to current +---@param client_id (optional, number): Redraw diagnostics for the given +--- client. The default is to redraw diagnostics for all attached +--- clients. +function M.redraw(bufnr, client_id) + bufnr = get_bufnr(bufnr) + if not client_id then + return vim.lsp.for_each_buffer_client(bufnr, function(client) + M.redraw(bufnr, client.id) + end) end - local signs_opts = resolve_optional_value(config.signs) - if signs_opts then - M.set_signs(diagnostics, bufnr, client_id, nil, signs_opts) - end + local namespace = M.get_namespace(client_id) + return vim.diagnostic.show(namespace, bufnr) end --- }}} --- Diagnostic User Functions {{{ ---- Open a floating window with the diagnostics from {line_nr} +--- Sets the quickfix list --- ---- The floating window can be customized with the following highlight groups: ---- <pre> ---- LspDiagnosticsFloatingError ---- LspDiagnosticsFloatingWarning ---- LspDiagnosticsFloatingInformation ---- LspDiagnosticsFloatingHint ---- </pre> ----@param opts table Configuration table ---- - show_header (boolean, default true): Show "Diagnostics:" header. ---- - Plus all the opts for |vim.lsp.diagnostic.get_line_diagnostics()| ---- and |vim.lsp.util.open_floating_preview()| can be used here. ----@param bufnr number The buffer number ----@param line_nr number The line number ----@param client_id number|nil the client id ----@return table {popup_bufnr, win_id} -function M.show_line_diagnostics(opts, bufnr, line_nr, client_id) +---@deprecated Prefer |vim.diagnostic.setqflist()| +--- +---@param opts table|nil Configuration table. Keys: +--- - {open}: (boolean, default true) +--- - Open quickfix list after set +--- - {client_id}: (number) +--- - If nil, will consider all clients attached to buffer. +--- - {severity}: (DiagnosticSeverity) +--- - Exclusive severity to consider. Overrides {severity_limit} +--- - {severity_limit}: (DiagnosticSeverity) +--- - Limit severity of diagnostics found. E.g. "Warning" means { "Error", "Warning" } will be valid. +--- - {workspace}: (boolean, default true) +--- - Set the list with workspace diagnostics +function M.set_qflist(opts) opts = opts or {} - - local show_header = if_nil(opts.show_header, true) - - bufnr = bufnr or 0 - line_nr = line_nr or (vim.api.nvim_win_get_cursor(0)[1] - 1) - - local lines = {} - local highlights = {} - if show_header then - table.insert(lines, "Diagnostics:") - table.insert(highlights, {0, "Bold"}) - end - - local line_diagnostics = M.get_line_diagnostics(bufnr, line_nr, opts, client_id) - if vim.tbl_isempty(line_diagnostics) then return end - - for i, diagnostic in ipairs(line_diagnostics) do - local prefix = string.format("%d. ", i) - local hiname = M._get_floating_severity_highlight_name(diagnostic.severity) - assert(hiname, 'unknown severity: ' .. tostring(diagnostic.severity)) - - local message_lines = vim.split(diagnostic.message, '\n', true) - table.insert(lines, prefix..message_lines[1]) - table.insert(highlights, {#prefix, hiname}) - for j = 2, #message_lines do - table.insert(lines, message_lines[j]) - table.insert(highlights, {0, hiname}) - end + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end - - opts.focus_id = "line_diagnostics" - local popup_bufnr, winnr = util.open_floating_preview(lines, 'plaintext', opts) - for i, hi in ipairs(highlights) do - local prefixlen, hiname = unpack(hi) - -- Start highlight after the prefix - api.nvim_buf_add_highlight(popup_bufnr, -1, hiname, i-1, prefixlen, -1) + if opts.client_id then + opts.client_id = nil + opts.namespace = M.get_namespace(opts.client_id) end - - return popup_bufnr, winnr -end - - ---- Clear diagnotics and diagnostic cache ---- ---- Handles saving diagnostics from multiple clients in the same buffer. ----@param client_id number ----@param buffer_client_map table map of buffers to active clients -function M.reset(client_id, buffer_client_map) - buffer_client_map = vim.deepcopy(buffer_client_map) - vim.schedule(function() - for bufnr, client_ids in pairs(buffer_client_map) do - if client_ids[client_id] then - clear_diagnostic_cache(bufnr, client_id) - M.clear(bufnr, client_id) - end - end - end) + local workspace = vim.F.if_nil(opts.workspace, true) + opts.bufnr = not workspace and 0 + return vim.diagnostic.setqflist(opts) end --- Sets the location list +--- +---@deprecated Prefer |vim.diagnostic.setloclist()| +--- ---@param opts table|nil Configuration table. Keys: ---- - {open_loclist}: (boolean, default true) +--- - {open}: (boolean, default true) --- - Open loclist after set --- - {client_id}: (number) --- - If nil, will consider all clients attached to buffer. @@ -1204,29 +648,65 @@ end --- - Set the list with workspace diagnostics function M.set_loclist(opts) opts = opts or {} - local open_loclist = if_nil(opts.open_loclist, true) - local current_bufnr = api.nvim_get_current_buf() - local diags = opts.workspace and M.get_all(opts.client_id) or { - [current_bufnr] = M.get(current_bufnr, opts.client_id) - } - local predicate = function(d) - local severity = to_severity(opts.severity) - if severity then - return d.severity == severity - end - local severity_limit = to_severity(opts.severity_limit) - if severity_limit then - return d.severity <= severity_limit - end - return true + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end - local items = util.diagnostics_to_items(diags, predicate) - local win_id = vim.api.nvim_get_current_win() - util.set_loclist(items, win_id) - if open_loclist then - vim.cmd [[lopen]] + if opts.client_id then + opts.client_id = nil + opts.namespace = M.get_namespace(opts.client_id) end + local workspace = vim.F.if_nil(opts.workspace, false) + opts.bufnr = not workspace and 0 + return vim.diagnostic.setloclist(opts) end + +--- Disable diagnostics for the given buffer and client +--- +---@deprecated Prefer |vim.diagnostic.disable()| +--- +---@param bufnr (optional, number): Buffer handle, defaults to current +---@param client_id (optional, number): Disable diagnostics for the given +--- client. The default is to disable diagnostics for all attached +--- clients. +-- Note that when diagnostics are disabled for a buffer, the server will still +-- send diagnostic information and the client will still process it. The +-- diagnostics are simply not displayed to the user. +function M.disable(bufnr, client_id) + if not client_id then + return vim.lsp.for_each_buffer_client(bufnr, function(client) + M.disable(bufnr, client.id) + end) + end + + bufnr = get_bufnr(bufnr) + local namespace = M.get_namespace(client_id) + return vim.diagnostic.disable(bufnr, namespace) +end + +--- Enable diagnostics for the given buffer and client +--- +---@deprecated Prefer |vim.diagnostic.enable()| +--- +---@param bufnr (optional, number): Buffer handle, defaults to current +---@param client_id (optional, number): Enable diagnostics for the given +--- client. The default is to enable diagnostics for all attached +--- clients. +function M.enable(bufnr, client_id) + if not client_id then + return vim.lsp.for_each_buffer_client(bufnr, function(client) + M.enable(bufnr, client.id) + end) + end + + bufnr = get_bufnr(bufnr) + local namespace = M.get_namespace(client_id) + return vim.diagnostic.enable(bufnr, namespace) +end + -- }}} return M + +-- vim: fdm=marker diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua index 41852b9d88..eff27807be 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -3,37 +3,34 @@ local protocol = require 'vim.lsp.protocol' local util = require 'vim.lsp.util' local vim = vim local api = vim.api -local buf = require 'vim.lsp.buf' local M = {} -- FIXME: DOC: Expose in vimdocs ---@private +---@private --- Writes to error buffer. ---@param ... (table of strings) Will be concatenated before being written +---@param ... (table of strings) Will be concatenated before being written local function err_message(...) vim.notify(table.concat(vim.tbl_flatten{...}), vim.log.levels.ERROR) api.nvim_command("redraw") end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_executeCommand -M['workspace/executeCommand'] = function(err, _) - if err then - error("Could not execute code action: "..err.message) - end +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_executeCommand +M['workspace/executeCommand'] = function(_, _, _, _) + -- Error handling is done implicitly by wrapping all handlers; see end of this file end --- @msg of type ProgressParams --- Basically a token of type number/string -local function progress_handler(_, _, params, client_id) +---@private +local function progress_handler(_, result, ctx, _) + local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) local client_name = client and client.name or string.format("id=%d", client_id) if not client then err_message("LSP[", client_name, "] client has shut down after sending the message") end - local val = params.value -- unspecified yet - local token = params.token -- string or number + local val = result.value -- unspecified yet + local token = result.token -- string or number if val.kind then @@ -61,13 +58,14 @@ local function progress_handler(_, _, params, client_id) vim.api.nvim_command("doautocmd <nomodeline> User LspProgressUpdate") end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#progress +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#progress M['$/progress'] = progress_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_workDoneProgress_create -M['window/workDoneProgress/create'] = function(_, _, params, client_id) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_workDoneProgress_create +M['window/workDoneProgress/create'] = function(_, result, ctx) + local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) - local token = params.token -- string or number + local token = result.token -- string or number local client_name = client and client.name or string.format("id=%d", client_id) if not client then err_message("LSP[", client_name, "] client has shut down after sending the message") @@ -76,12 +74,12 @@ M['window/workDoneProgress/create'] = function(_, _, params, client_id) return vim.NIL end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_showMessageRequest -M['window/showMessageRequest'] = function(_, _, params) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_showMessageRequest +M['window/showMessageRequest'] = function(_, result) - local actions = params.actions - print(params.message) - local option_strings = {params.message, "\nRequest Actions:"} + local actions = result.actions + print(result.message) + local option_strings = {result.message, "\nRequest Actions:"} for i, action in ipairs(actions) do local title = action.title:gsub('\r\n', '\\r\\n') title = title:gsub('\n', '\\n') @@ -97,8 +95,9 @@ M['window/showMessageRequest'] = function(_, _, params) end end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#client_registerCapability -M['client/registerCapability'] = function(_, _, _, client_id) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#client_registerCapability +M['client/registerCapability'] = function(_, _, ctx) + local client_id = ctx.client_id local warning_tpl = "The language server %s triggers a registerCapability ".. "handler despite dynamicRegistration set to false. ".. "Report upstream, this warning is harmless" @@ -109,42 +108,8 @@ M['client/registerCapability'] = function(_, _, _, client_id) return vim.NIL end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_codeAction -M['textDocument/codeAction'] = function(_, _, actions) - if actions == nil or vim.tbl_isempty(actions) then - print("No code actions available") - return - end - - local option_strings = {"Code Actions:"} - for i, action in ipairs(actions) do - local title = action.title:gsub('\r\n', '\\r\\n') - title = title:gsub('\n', '\\n') - table.insert(option_strings, string.format("%d. %s", i, title)) - end - - local choice = vim.fn.inputlist(option_strings) - if choice < 1 or choice > #actions then - return - end - local action_chosen = actions[choice] - -- textDocument/codeAction can return either Command[] or CodeAction[]. - -- If it is a CodeAction, it can have either an edit, a command or both. - -- Edits should be executed first - if action_chosen.edit or type(action_chosen.command) == "table" then - if action_chosen.edit then - util.apply_workspace_edit(action_chosen.edit) - end - if type(action_chosen.command) == "table" then - buf.execute_command(action_chosen.command) - end - else - buf.execute_command(action_chosen) - end -end - ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit -M['workspace/applyEdit'] = function(_, _, workspace_edit) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit +M['workspace/applyEdit'] = function(_, workspace_edit) if not workspace_edit then return end -- TODO(ashkan) Do something more with label? if workspace_edit.label then @@ -157,30 +122,30 @@ M['workspace/applyEdit'] = function(_, _, workspace_edit) } end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_configuration -M['workspace/configuration'] = function(err, _, params, client_id) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_configuration +M['workspace/configuration'] = function(_, result, ctx) + local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) if not client then err_message("LSP[id=", client_id, "] client has shut down after sending the message") return end - if err then error(vim.inspect(err)) end - if not params.items then + if not result.items then return {} end - local result = {} - for _, item in ipairs(params.items) do + local response = {} + for _, item in ipairs(result.items) do if item.section then local value = util.lookup_section(client.config.settings, item.section) or vim.NIL -- For empty sections with no explicit '' key, return settings as is if value == vim.NIL and item.section == '' then value = client.config.settings or vim.NIL end - table.insert(result, value) + table.insert(response, value) end end - return result + return response end M['textDocument/publishDiagnostics'] = function(...) @@ -191,51 +156,71 @@ M['textDocument/codeLens'] = function(...) return require('vim.lsp.codelens').on_codelens(...) end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references -M['textDocument/references'] = function(_, _, result) - if not result then return end - util.set_qflist(util.locations_to_items(result)) - api.nvim_command("copen") -end ---@private ---- Prints given list of symbols to the quickfix list. ---@param _ (not used) ---@param _ (not used) ---@param result (list of Symbols) LSP method name ---@param result (table) result of LSP method; a location or a list of locations. ----(`textDocument/definition` can return `Location` or `Location[]` -local symbol_handler = function(_, _, result, _, bufnr) - if not result or vim.tbl_isempty(result) then return end - util.set_qflist(util.symbols_to_items(result, bufnr)) - api.nvim_command("copen") +---@private +--- Return a function that converts LSP responses to list items and opens the list +--- +--- The returned function has an optional {config} parameter that accepts a table +--- with the following keys: +--- +--- loclist: (boolean) use the location list (default is to use the quickfix list) +--- +---@param map_result function `((resp, bufnr) -> list)` to convert the response +---@param entity name of the resource used in a `not found` error message +local function response_to_list(map_result, entity) + return function(_,result, ctx, config) + if not result or vim.tbl_isempty(result) then + vim.notify('No ' .. entity .. ' found') + else + config = config or {} + if config.loclist then + vim.fn.setloclist(0, {}, ' ', { + title = 'Language Server'; + items = map_result(result, ctx.bufnr); + }) + api.nvim_command("lopen") + else + vim.fn.setqflist({}, ' ', { + title = 'Language Server'; + items = map_result(result, ctx.bufnr); + }) + api.nvim_command("copen") + end + end + end end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentSymbol -M['textDocument/documentSymbol'] = symbol_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_symbol -M['workspace/symbol'] = symbol_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rename -M['textDocument/rename'] = function(_, _, result) + +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_references +M['textDocument/references'] = response_to_list(util.locations_to_items, 'references') + +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentSymbol +M['textDocument/documentSymbol'] = response_to_list(util.symbols_to_items, 'document symbols') + +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_symbol +M['workspace/symbol'] = response_to_list(util.symbols_to_items, 'symbols') + +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rename +M['textDocument/rename'] = function(_, result, _) if not result then return end util.apply_workspace_edit(result) end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rangeFormatting -M['textDocument/rangeFormatting'] = function(_, _, result) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_rangeFormatting +M['textDocument/rangeFormatting'] = function(_, result, ctx, _) if not result then return end - util.apply_text_edits(result) + util.apply_text_edits(result, ctx.bufnr) end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting -M['textDocument/formatting'] = function(_, _, result) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting +M['textDocument/formatting'] = function(_, result, ctx, _) if not result then return end - util.apply_text_edits(result) + util.apply_text_edits(result, ctx.bufnr) end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion -M['textDocument/completion'] = function(_, _, result) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion +M['textDocument/completion'] = function(_, result, _, _) if vim.tbl_isempty(result or {}) then return end local row, col = unpack(api.nvim_win_get_cursor(0)) local line = assert(api.nvim_buf_get_lines(0, row-1, row, false)[1]) @@ -260,9 +245,9 @@ end --- - border: (default=nil) --- - Add borders to the floating window --- - See |vim.api.nvim_open_win()| -function M.hover(_, method, result, _, _, config) +function M.hover(_, result, ctx, config) config = config or {} - config.focus_id = method + config.focus_id = ctx.method if not (result and result.contents) then -- return { 'No information available' } return @@ -276,18 +261,18 @@ function M.hover(_, method, result, _, _, config) return util.open_floating_preview(markdown_lines, "markdown", config) end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover M['textDocument/hover'] = M.hover ---@private +---@private --- Jumps to a location. Used as a handler for multiple LSP methods. ---@param _ (not used) ---@param method (string) LSP method name ---@param result (table) result of LSP method; a location or a list of locations. +---@param _ (not used) +---@param result (table) result of LSP method; a location or a list of locations. +---@param ctx (table) table containing the context of the request, including the method ---(`textDocument/definition` can return `Location` or `Location[]` -local function location_handler(_, method, result) +local function location_handler(_, result, ctx, _) if result == nil or vim.tbl_isempty(result) then - local _ = log.info() and log.info(method, 'No location found') + local _ = log.info() and log.info(ctx.method, 'No location found') return nil end @@ -306,16 +291,17 @@ local function location_handler(_, method, result) end end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_declaration +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_declaration M['textDocument/declaration'] = location_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_definition +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_definition M['textDocument/definition'] = location_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_typeDefinition +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_typeDefinition M['textDocument/typeDefinition'] = location_handler ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_implementation +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_implementation M['textDocument/implementation'] = location_handler ---- |lsp-handler| for the method "textDocument/signatureHelp" +--- |lsp-handler| for the method "textDocument/signatureHelp". +--- The active parameter is highlighted with |hl-LspSignatureActiveParameter|. --- <pre> --- vim.lsp.handlers["textDocument/signatureHelp"] = vim.lsp.with( --- vim.lsp.handlers.signature_help, { @@ -328,43 +314,53 @@ M['textDocument/implementation'] = location_handler --- - border: (default=nil) --- - Add borders to the floating window --- - See |vim.api.nvim_open_win()| -function M.signature_help(_, method, result, _, bufnr, config) +function M.signature_help(_, result, ctx, config) config = config or {} - config.focus_id = method + config.focus_id = ctx.method -- When use `autocmd CompleteDone <silent><buffer> lua vim.lsp.buf.signature_help()` to call signatureHelp handler -- If the completion item doesn't have signatures It will make noise. Change to use `print` that can use `<silent>` to ignore if not (result and result.signatures and result.signatures[1]) then - print('No signature help available') + if config.silent ~= true then + print('No signature help available') + end return end - local ft = api.nvim_buf_get_option(bufnr, 'filetype') - local lines = util.convert_signature_help_to_markdown_lines(result, ft) + local client = vim.lsp.get_client_by_id(ctx.client_id) + local triggers = client.resolved_capabilities.signature_help_trigger_characters + local ft = api.nvim_buf_get_option(ctx.bufnr, 'filetype') + local lines, hl = util.convert_signature_help_to_markdown_lines(result, ft, triggers) lines = util.trim_empty_lines(lines) if vim.tbl_isempty(lines) then - print('No signature help available') + if config.silent ~= true then + print('No signature help available') + end return end - return util.open_floating_preview(lines, "markdown", config) + local fbuf, fwin = util.open_floating_preview(lines, "markdown", config) + if hl then + api.nvim_buf_add_highlight(fbuf, -1, "LspSignatureActiveParameter", 0, unpack(hl)) + end + return fbuf, fwin end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp M['textDocument/signatureHelp'] = M.signature_help ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentHighlight -M['textDocument/documentHighlight'] = function(_, _, result, _, bufnr, _) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentHighlight +M['textDocument/documentHighlight'] = function(_, result, ctx, _) if not result then return end - util.buf_highlight_references(bufnr, result) + util.buf_highlight_references(ctx.bufnr, result) end ---@private +---@private --- --- Displays call hierarchy in the quickfix window. --- ---@param direction `"from"` for incoming calls and `"to"` for outgoing calls ---@returns `CallHierarchyIncomingCall[]` if {direction} is `"from"`, ---@returns `CallHierarchyOutgoingCall[]` if {direction} is `"to"`, +---@param direction `"from"` for incoming calls and `"to"` for outgoing calls +---@returns `CallHierarchyIncomingCall[]` if {direction} is `"from"`, +---@returns `CallHierarchyOutgoingCall[]` if {direction} is `"to"`, local make_call_hierarchy_handler = function(direction) - return function(_, _, result) + return function(_, result) if not result then return end local items = {} for _, call_hierarchy_call in pairs(result) do @@ -383,16 +379,17 @@ local make_call_hierarchy_handler = function(direction) end end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#callHierarchy/incomingCalls +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#callHierarchy_incomingCalls M['callHierarchy/incomingCalls'] = make_call_hierarchy_handler('from') ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#callHierarchy/outgoingCalls +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#callHierarchy_outgoingCalls M['callHierarchy/outgoingCalls'] = make_call_hierarchy_handler('to') ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window/logMessage -M['window/logMessage'] = function(_, _, result, client_id) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_logMessage +M['window/logMessage'] = function(_, result, ctx, _) local message_type = result.type local message = result.message + local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) local client_name = client and client.name or string.format("id=%d", client_id) if not client then @@ -402,7 +399,7 @@ M['window/logMessage'] = function(_, _, result, client_id) log.error(message) elseif message_type == protocol.MessageType.Warning then log.warn(message) - elseif message_type == protocol.MessageType.Info then + elseif message_type == protocol.MessageType.Info or message_type == protocol.MessageType.Log then log.info(message) else log.debug(message) @@ -410,10 +407,11 @@ M['window/logMessage'] = function(_, _, result, client_id) return result end ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window/showMessage -M['window/showMessage'] = function(_, _, result, client_id) +--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#window_showMessage +M['window/showMessage'] = function(_, result, ctx, _) local message_type = result.type local message = result.message + local client_id = ctx.client_id local client = vim.lsp.get_client_by_id(client_id) local client_name = client and client.name or string.format("id=%d", client_id) if not client then @@ -430,16 +428,23 @@ end -- Add boilerplate error validation and logging for all of these. for k, fn in pairs(M) do - M[k] = function(err, method, params, client_id, bufnr, config) - local _ = log.debug() and log.debug('default_handler', method, { - params = params, client_id = client_id, err = err, bufnr = bufnr, config = config + M[k] = function(err, result, ctx, config) + local _ = log.trace() and log.trace('default_handler', ctx.method, { + err = err, result = result, ctx=vim.inspect(ctx), config = config }) if err then - return err_message(tostring(err)) + local client = vim.lsp.get_client_by_id(ctx.client_id) + local client_name = client and client.name or string.format("client_id=%d", ctx.client_id) + -- LSP spec: + -- interface ResponseError: + -- code: integer; + -- message: string; + -- data?: string | number | boolean | array | object | null; + return err_message(client_name .. ': ' .. tostring(err.code) .. ': ' .. err.message) end - return fn(err, method, params, client_id, bufnr, config) + return fn(err, result, ctx, config) end end diff --git a/runtime/lua/vim/lsp/health.lua b/runtime/lua/vim/lsp/health.lua new file mode 100644 index 0000000000..855679a2df --- /dev/null +++ b/runtime/lua/vim/lsp/health.lua @@ -0,0 +1,27 @@ +local M = {} + +--- Performs a healthcheck for LSP +function M.check_health() + local report_info = vim.fn['health#report_info'] + local report_warn = vim.fn['health#report_warn'] + + local log = require('vim.lsp.log') + local current_log_level = log.get_level() + local log_level_string = log.levels[current_log_level] + report_info(string.format("LSP log level : %s", log_level_string)) + + if current_log_level < log.levels.WARN then + report_warn(string.format("Log level %s will cause degraded performance and high disk usage", log_level_string)) + end + + local log_path = vim.lsp.get_log_path() + report_info(string.format("Log path: %s", log_path)) + + local log_size = vim.loop.fs_stat(log_path).size + + local report_fn = (log_size / 1000000 > 100 and report_warn or report_info) + report_fn(string.format("Log size: %d KB", log_size / 1000 )) +end + +return M + diff --git a/runtime/lua/vim/lsp/log.lua b/runtime/lua/vim/lsp/log.lua index 471a311c16..4597f1919a 100644 --- a/runtime/lua/vim/lsp/log.lua +++ b/runtime/lua/vim/lsp/log.lua @@ -14,26 +14,38 @@ log.levels = vim.deepcopy(vim.log.levels) -- Default log level is warn. local current_log_level = log.levels.WARN -local log_date_format = "%FT%H:%M:%S%z" +local log_date_format = "%F %H:%M:%S" +local format_func = function(arg) return vim.inspect(arg, {newline=''}) end do - local path_sep = vim.loop.os_uname().sysname == "Windows" and "\\" or "/" - --@private + local path_sep = vim.loop.os_uname().version:match("Windows") and "\\" or "/" + ---@private local function path_join(...) return table.concat(vim.tbl_flatten{...}, path_sep) end local logfilename = path_join(vim.fn.stdpath('cache'), 'lsp.log') --- Returns the log filename. - --@returns (string) log filename + ---@returns (string) log filename function log.get_filename() return logfilename end vim.fn.mkdir(vim.fn.stdpath('cache'), "p") local logfile = assert(io.open(logfilename, "a+")) + + local log_info = vim.loop.fs_stat(logfilename) + if log_info and log_info.size > 1e9 then + local warn_msg = string.format( + "LSP client log is large (%d MB): %s", + log_info.size / (1000 * 1000), + logfilename + ) + vim.notify(warn_msg) + end + -- Start message for logging - logfile:write(string.format("[ START ] %s ] LSP logging initiated\n", os.date(log_date_format))) + logfile:write(string.format("[START][%s] LSP logging initiated\n", os.date(log_date_format))) for level, levelnr in pairs(log.levels) do -- Also export the log level on the root object. log[level] = levelnr @@ -56,14 +68,14 @@ do if levelnr < current_log_level then return false end if argc == 0 then return true end local info = debug.getinfo(2, "Sl") - local fileinfo = string.format("%s:%s", info.short_src, info.currentline) - local parts = { table.concat({"[", level, "]", os.date(log_date_format), "]", fileinfo, "]"}, " ") } + local header = string.format("[%s][%s] ...%s:%s", level, os.date(log_date_format), string.sub(info.short_src, #info.short_src - 15), info.currentline) + local parts = { header } for i = 1, argc do local arg = select(i, ...) if arg == nil then table.insert(parts, "nil") else - table.insert(parts, vim.inspect(arg, {newline=''})) + table.insert(parts, format_func(arg)) end end logfile:write(table.concat(parts, '\t'), "\n") @@ -77,7 +89,7 @@ end vim.tbl_add_reverse_lookup(log.levels) --- Sets the current log level. ---@param level (string or number) One of `vim.lsp.log.levels` +---@param level (string or number) One of `vim.lsp.log.levels` function log.set_level(level) if type(level) == 'string' then current_log_level = assert(log.levels[level:upper()], string.format("Invalid log level: %q", level)) @@ -88,9 +100,21 @@ function log.set_level(level) end end +--- Gets the current log level. +function log.get_level() + return current_log_level +end + +--- Sets formatting function used to format logs +---@param handle function function to apply to logging arguments, pass vim.inspect for multi-line formatting +function log.set_format_func(handle) + assert(handle == vim.inspect or type(handle) == 'function', "handle must be a function") + format_func = handle +end + --- Checks whether the level is sufficient for logging. ---@param level number log level ---@returns (bool) true if would log, false if not +---@param level number log level +---@returns (bool) true if would log, false if not function log.should_log(level) return level >= current_log_level end diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 7e43eb84de..b3aa8b934f 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -5,14 +5,14 @@ local if_nil = vim.F.if_nil local protocol = {} --[=[ ---@private +---@private --- Useful for interfacing with: --- https://github.com/microsoft/language-server-protocol/raw/gh-pages/_specifications/specification-3-14.md function transform_schema_comments() nvim.command [[silent! '<,'>g/\/\*\*\|\*\/\|^$/d]] nvim.command [[silent! '<,'>s/^\(\s*\) \* \=\(.*\)/\1--\2/]] end ---@private +---@private function transform_schema_to_table() transform_schema_comments() nvim.command [[silent! '<,'>s/: \S\+//]] @@ -645,6 +645,10 @@ function protocol.make_client_capabilities() end)(); }; }; + dataSupport = true; + resolveSupport = { + properties = { 'edit', } + }; }; completion = { dynamicRegistration = false; @@ -691,10 +695,11 @@ function protocol.make_client_capabilities() signatureHelp = { dynamicRegistration = false; signatureInformation = { + activeParameterSupport = true; documentationFormat = { protocol.MarkupKind.Markdown; protocol.MarkupKind.PlainText }; - -- parameterInformation = { - -- labelOffsetSupport = false; - -- }; + parameterInformation = { + labelOffsetSupport = true; + }; }; }; references = { @@ -1002,8 +1007,7 @@ function protocol.resolve_capabilities(server_capabilities) elseif type(server_capabilities.declarationProvider) == 'boolean' then general_properties.declaration = server_capabilities.declarationProvider elseif type(server_capabilities.declarationProvider) == 'table' then - -- TODO: support more detailed declarationProvider options. - general_properties.declaration = false + general_properties.declaration = server_capabilities.declarationProvider else error("The server sent invalid declarationProvider") end @@ -1013,8 +1017,7 @@ function protocol.resolve_capabilities(server_capabilities) elseif type(server_capabilities.typeDefinitionProvider) == 'boolean' then general_properties.type_definition = server_capabilities.typeDefinitionProvider elseif type(server_capabilities.typeDefinitionProvider) == 'table' then - -- TODO: support more detailed typeDefinitionProvider options. - general_properties.type_definition = false + general_properties.type_definition = server_capabilities.typeDefinitionProvider else error("The server sent invalid typeDefinitionProvider") end @@ -1024,8 +1027,7 @@ function protocol.resolve_capabilities(server_capabilities) elseif type(server_capabilities.implementationProvider) == 'boolean' then general_properties.implementation = server_capabilities.implementationProvider elseif type(server_capabilities.implementationProvider) == 'table' then - -- TODO(ashkan) support more detailed implementation options. - general_properties.implementation = false + general_properties.implementation = server_capabilities.implementationProvider else error("The server sent invalid implementationProvider") end diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index 4c5f02af9d..255eb65dfe 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -4,38 +4,10 @@ local log = require('vim.lsp.log') local protocol = require('vim.lsp.protocol') local validate, schedule, schedule_wrap = vim.validate, vim.schedule, vim.schedule_wrap --- TODO replace with a better implementation. ---@private ---- Encodes to JSON. ---- ---@param data (table) Data to encode ---@returns (string) Encoded object -local function json_encode(data) - local status, result = pcall(vim.fn.json_encode, data) - if status then - return result - else - return nil, result - end -end ---@private ---- Decodes from JSON. ---- ---@param data (string) Data to decode ---@returns (table) Decoded JSON object -local function json_decode(data) - local status, result = pcall(vim.fn.json_decode, data) - if status then - return result - else - return nil, result - end -end - ---@private +---@private --- Checks whether a given path exists and is a directory. ---@param filename (string) path to check ---@returns (bool) +---@param filename (string) path to check +---@returns (bool) local function is_dir(filename) local stat = vim.loop.fs_stat(filename) return stat and stat.type == 'directory' or false @@ -43,7 +15,7 @@ end local NIL = vim.NIL ---@private +---@private local recursive_convert_NIL recursive_convert_NIL = function(v, tbl_processed) if v == NIL then @@ -63,15 +35,15 @@ recursive_convert_NIL = function(v, tbl_processed) return v end ---@private +---@private --- Returns its argument, but converts `vim.NIL` to Lua `nil`. ---@param v (any) Argument ---@returns (any) +---@param v (any) Argument +---@returns (any) local function convert_NIL(v) return recursive_convert_NIL(v, {}) end ---@private +---@private --- Merges current process env with the given env and returns the result as --- a list of "k=v" strings. --- @@ -81,8 +53,8 @@ end --- in: { PRODUCTION="false", PATH="/usr/bin/", PORT=123, HOST="0.0.0.0", } --- out: { "PRODUCTION=false", "PATH=/usr/bin/", "PORT=123", "HOST=0.0.0.0", } --- </pre> ---@param env (table) table of environment variable assignments ---@returns (table) list of `"k=v"` strings +---@param env (table) table of environment variable assignments +---@returns (table) list of `"k=v"` strings local function env_merge(env) if env == nil then return env @@ -97,11 +69,11 @@ local function env_merge(env) return final_env end ---@private +---@private --- Embeds the given string into a table and correctly computes `Content-Length`. --- ---@param encoded_message (string) ---@returns (table) table containing encoded message and `Content-Length` attribute +---@param encoded_message (string) +---@returns (table) table containing encoded message and `Content-Length` attribute local function format_message_with_content_length(encoded_message) return table.concat { 'Content-Length: '; tostring(#encoded_message); '\r\n\r\n'; @@ -109,11 +81,11 @@ local function format_message_with_content_length(encoded_message) } end ---@private +---@private --- Parses an LSP Message's header --- ---@param header: The header to parse. ---@returns Parsed headers +---@param header: The header to parse. +---@returns Parsed headers local function parse_headers(header) if type(header) ~= 'string' then return nil @@ -141,7 +113,7 @@ end -- case insensitive pattern. local header_start_pattern = ("content"):gsub("%w", function(c) return "["..c..c:upper().."]" end) ---@private +---@private --- The actual workhorse. local function request_parser_loop() local buffer = '' -- only for header part @@ -203,8 +175,8 @@ local client_errors = vim.tbl_add_reverse_lookup { --- Constructs an error message from an LSP error object. --- ---@param err (table) The error object ---@returns (string) The formatted error message +---@param err (table) The error object +---@returns (string) The formatted error message local function format_rpc_error(err) validate { err = { err, 't' }; @@ -233,9 +205,9 @@ end --- Creates an RPC response object/table. --- ---@param code RPC error code defined in `vim.lsp.protocol.ErrorCodes` ---@param message (optional) arbitrary message to send to server ---@param data (optional) arbitrary data to send to server +---@param code RPC error code defined in `vim.lsp.protocol.ErrorCodes` +---@param message (optional) arbitrary message to send to server +---@param data (optional) arbitrary data to send to server local function rpc_response_error(code, message, data) -- TODO should this error or just pick a sane error (like InternalError)? local code_name = assert(protocol.ErrorCodes[code], 'Invalid RPC error code') @@ -250,38 +222,38 @@ end local default_dispatchers = {} ---@private +---@private --- Default dispatcher for notifications sent to an LSP server. --- ---@param method (string) The invoked LSP method ---@param params (table): Parameters for the invoked LSP method +---@param method (string) The invoked LSP method +---@param params (table): Parameters for the invoked LSP method function default_dispatchers.notification(method, params) local _ = log.debug() and log.debug('notification', method, params) end ---@private +---@private --- Default dispatcher for requests sent to an LSP server. --- ---@param method (string) The invoked LSP method ---@param params (table): Parameters for the invoked LSP method ---@returns `nil` and `vim.lsp.protocol.ErrorCodes.MethodNotFound`. +---@param method (string) The invoked LSP method +---@param params (table): Parameters for the invoked LSP method +---@returns `nil` and `vim.lsp.protocol.ErrorCodes.MethodNotFound`. function default_dispatchers.server_request(method, params) local _ = log.debug() and log.debug('server_request', method, params) return nil, rpc_response_error(protocol.ErrorCodes.MethodNotFound) end ---@private +---@private --- Default dispatcher for when a client exits. --- ---@param code (number): Exit code ---@param signal (number): Number describing the signal used to terminate (if +---@param code (number): Exit code +---@param signal (number): Number describing the signal used to terminate (if ---any) function default_dispatchers.on_exit(code, signal) local _ = log.info() and log.info("client_exit", { code = code, signal = signal }) end ---@private +---@private --- Default dispatcher for client errors. --- ---@param code (number): Error code ---@param err (any): Details about the error +---@param code (number): Error code +---@param err (any): Details about the error ---any) function default_dispatchers.on_error(code, err) local _ = log.error() and log.error('client_error:', client_errors[code], err) @@ -290,25 +262,25 @@ end --- Starts an LSP server process and create an LSP RPC client object to --- interact with it. --- ---@param cmd (string) Command to start the LSP server. ---@param cmd_args (table) List of additional string arguments to pass to {cmd}. ---@param dispatchers (table, optional) Dispatchers for LSP message types. Valid +---@param cmd (string) Command to start the LSP server. +---@param cmd_args (table) List of additional string arguments to pass to {cmd}. +---@param dispatchers (table, optional) Dispatchers for LSP message types. Valid ---dispatcher names are: --- - `"notification"` --- - `"server_request"` --- - `"on_error"` --- - `"on_exit"` ---@param extra_spawn_params (table, optional) Additional context for the LSP +---@param extra_spawn_params (table, optional) Additional context for the LSP --- server process. May contain: --- - {cwd} (string) Working directory for the LSP server process --- - {env} (table) Additional environment variables for LSP server process ---@returns Client RPC object. +---@returns Client RPC object. --- ---@returns Methods: +---@returns Methods: --- - `notify()` |vim.lsp.rpc.notify()| --- - `request()` |vim.lsp.rpc.request()| --- ---@returns Members: +---@returns Members: --- - {pid} (number) The LSP server's PID. --- - {handle} A handle for low-level interaction with the LSP server process --- |vim.loop|. @@ -358,10 +330,10 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) local handle, pid do - --@private + ---@private --- Callback for |vim.loop.spawn()| Closes all streams and runs the `on_exit` dispatcher. - --@param code (number) Exit code - --@param signal (number) Signal that was used to terminate (if any) + ---@param code (number) Exit code + ---@param signal (number) Signal that was used to terminate (if any) local function onexit(code, signal) stdin:close() stdout:close() @@ -385,20 +357,17 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) end end - --@private + ---@private --- Encodes {payload} into a JSON-RPC message and sends it to the remote --- process. --- - --@param payload (table) Converted into a JSON string, see |json_encode()| - --@returns true if the payload could be scheduled, false if the main event-loop is in the process of closing. + ---@param payload table + ---@returns true if the payload could be scheduled, false if the main event-loop is in the process of closing. local function encode_and_send(payload) - local _ = log.debug() and log.debug("rpc.send.payload", payload) + local _ = log.debug() and log.debug("rpc.send", payload) if handle == nil or handle:is_closing() then return false end - -- TODO(ashkan) remove this once we have a Lua json_encode - schedule(function() - local encoded = assert(json_encode(payload)) - stdin:write(format_message_with_content_length(encoded)) - end) + local encoded = vim.json.encode(payload) + stdin:write(format_message_with_content_length(encoded)) return true end @@ -406,9 +375,9 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) -- `start()` -- --- Sends a notification to the LSP server. - --@param method (string) The invoked LSP method - --@param params (table): Parameters for the invoked LSP method - --@returns (bool) `true` if notification could be sent, `false` if not + ---@param method (string) The invoked LSP method + ---@param params (table): Parameters for the invoked LSP method + ---@returns (bool) `true` if notification could be sent, `false` if not local function notify(method, params) return encode_and_send { jsonrpc = "2.0"; @@ -417,7 +386,7 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) } end - --@private + ---@private --- sends an error object to the remote LSP process. local function send_response(request_id, err, result) return encode_and_send { @@ -433,10 +402,10 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) -- --- Sends a request to the LSP server and runs {callback} upon response. --- - --@param method (string) The invoked LSP method - --@param params (table) Parameters for the invoked LSP method - --@param callback (function) Callback to invoke - --@returns (bool, number) `(true, message_id)` if request could be sent, `false` if not + ---@param method (string) The invoked LSP method + ---@param params (table) Parameters for the invoked LSP method + ---@param callback (function) Callback to invoke + ---@returns (bool, number) `(true, message_id)` if request could be sent, `false` if not local function request(method, params, callback) validate { callback = { callback, 'f' }; @@ -463,13 +432,13 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) end end) - --@private + ---@private local function on_error(errkind, ...) assert(client_errors[errkind]) -- TODO what to do if this fails? pcall(dispatchers.on_error, errkind, ...) end - --@private + ---@private local function pcall_handler(errkind, status, head, ...) if not status then on_error(errkind, head, ...) @@ -477,7 +446,7 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) end return status, head, ... end - --@private + ---@private local function try_call(errkind, fn, ...) return pcall_handler(errkind, pcall(fn, ...)) end @@ -486,16 +455,17 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) -- time and log them. This would require storing the timestamp. I could call -- them with an error then, perhaps. - --@private + ---@private local function handle_body(body) - local decoded, err = json_decode(body) - if not decoded then - -- on_error(client_errors.INVALID_SERVER_JSON, err) + local ok, decoded = pcall(vim.json.decode, body) + if not ok then + on_error(client_errors.INVALID_SERVER_JSON, decoded) return end - local _ = log.debug() and log.debug("decoded", decoded) + local _ = log.debug() and log.debug("rpc.receive", decoded) if type(decoded.method) == 'string' and decoded.id then + local err -- Server Request decoded.params = convert_NIL(decoded.params) -- Schedule here so that the users functions don't trigger an error and @@ -582,8 +552,6 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) on_error(client_errors.INVALID_SERVER_MESSAGE, decoded) end end - -- TODO(ashkan) remove this once we have a Lua json_decode - handle_body = schedule_wrap(handle_body) local request_parser = coroutine.wrap(request_parser_loop) request_parser() diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 195e3a0e65..e95f170427 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -1,4 +1,5 @@ local protocol = require 'vim.lsp.protocol' +local snippet = require 'vim.lsp._snippet' local vim = vim local validate = vim.validate local api = vim.api @@ -30,20 +31,12 @@ local default_border = { {" ", "NormalFloat"}, } - -local DiagnosticSeverity = protocol.DiagnosticSeverity -local loclist_type_map = { - [DiagnosticSeverity.Error] = 'E', - [DiagnosticSeverity.Warning] = 'W', - [DiagnosticSeverity.Information] = 'I', - [DiagnosticSeverity.Hint] = 'I', -} - - ---@private --- Check the border given by opts or the default border for the additional --- size it adds to a float. ---@returns size of border in height and width +---@private +--- Check the border given by opts or the default border for the additional +--- size it adds to a float. +---@param opts (table, optional) options for the floating window +--- - border (string or table) the border +---@returns (table) size of border in the form of { height = height, width = width } local function get_border_size(opts) local border = opts and opts.border or default_border local height = 0 @@ -52,12 +45,16 @@ local function get_border_size(opts) if type(border) == 'string' then local border_size = {none = {0, 0}, single = {2, 2}, double = {2, 2}, rounded = {2, 2}, solid = {2, 2}, shadow = {1, 1}} if border_size[border] == nil then - error("floating preview border is not correct. Please refer to the docs |vim.api.nvim_open_win()|" - .. vim.inspect(border)) + error(string.format("invalid floating preview border: %s. :help vim.api.nvim_open_win()", vim.inspect(border))) end height, width = unpack(border_size[border]) else + if 8 % #border ~= 0 then + error(string.format("invalid floating preview border: %s. :help vim.api.nvim_open_win()", vim.inspect(border))) + end + ---@private local function border_width(id) + id = (id - 1) % #border + 1 if type(border[id]) == "table" then -- border specified as a table of <character, highlight group> return vim.fn.strdisplaywidth(border[id][1]) @@ -65,9 +62,11 @@ local function get_border_size(opts) -- border specified as a list of border characters return vim.fn.strdisplaywidth(border[id]) end - error("floating preview border is not correct. Please refer to the docs |vim.api.nvim_open_win()|" .. vim.inspect(border)) + error(string.format("invalid floating preview border: %s. :help vim.api.nvim_open_win()", vim.inspect(border))) end + ---@private local function border_height(id) + id = (id - 1) % #border + 1 if type(border[id]) == "table" then -- border specified as a table of <character, highlight group> return #border[id][1] > 0 and 1 or 0 @@ -75,7 +74,7 @@ local function get_border_size(opts) -- border specified as a list of border characters return #border[id] > 0 and 1 or 0 end - error("floating preview border is not correct. Please refer to the docs |vim.api.nvim_open_win()|" .. vim.inspect(border)) + error(string.format("invalid floating preview border: %s. :help vim.api.nvim_open_win()", vim.inspect(border))) end height = height + border_height(2) -- top height = height + border_height(6) -- bottom @@ -86,7 +85,7 @@ local function get_border_size(opts) return { height = height, width = width } end ---@private +---@private local function split_lines(value) return split(value, '\n', true) end @@ -95,11 +94,11 @@ end --- --- CAUTION: Changes in-place! --- ---@param lines (table) Original list of strings ---@param A (table) Start position; a 2-tuple of {line, col} numbers ---@param B (table) End position; a 2-tuple of {line, col} numbers ---@param new_lines A list of strings to replace the original ---@returns (table) The modified {lines} object +---@param lines (table) Original list of strings +---@param A (table) Start position; a 2-tuple of {line, col} numbers +---@param B (table) End position; a 2-tuple of {line, col} numbers +---@param new_lines A list of strings to replace the original +---@returns (table) The modified {lines} object function M.set_lines(lines, A, B, new_lines) -- 0-indexing to 1-indexing local i_0 = A[1] + 1 @@ -133,7 +132,7 @@ function M.set_lines(lines, A, B, new_lines) return lines end ---@private +---@private local function sort_by_key(fn) return function(a,b) local ka, kb = fn(a), fn(b) @@ -147,12 +146,8 @@ local function sort_by_key(fn) return false end end ---@private -local edit_sort_key = sort_by_key(function(e) - return {e.A[1], e.A[2], e.i} -end) ---@private +---@private --- Position is a https://microsoft.github.io/language-server-protocol/specifications/specification-current/#position --- Returns a zero-indexed column, since set_lines() does the conversion to --- 1-indexed @@ -175,6 +170,7 @@ local function get_line_byte_from_position(bufnr, position) if ok then return result end + return math.min(#lines[1], col) end end return col @@ -238,53 +234,119 @@ function M.get_progress_messages() end --- Applies a list of text edits to a buffer. ---@param text_edits (table) list of `TextEdit` objects ---@param buf_nr (number) Buffer id +---@param text_edits table list of `TextEdit` objects +---@param bufnr number Buffer id +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textEdit function M.apply_text_edits(text_edits, bufnr) if not next(text_edits) then return end if not api.nvim_buf_is_loaded(bufnr) then vim.fn.bufload(bufnr) end api.nvim_buf_set_option(bufnr, 'buflisted', true) - local start_line, finish_line = math.huge, -1 - local cleaned = {} - for i, e in ipairs(text_edits) do - -- adjust start and end column for UTF-16 encoding of non-ASCII characters - local start_row = e.range.start.line - local start_col = get_line_byte_from_position(bufnr, e.range.start) - local end_row = e.range["end"].line - local end_col = get_line_byte_from_position(bufnr, e.range['end']) - start_line = math.min(e.range.start.line, start_line) - finish_line = math.max(e.range["end"].line, finish_line) - -- TODO(ashkan) sanity check ranges for overlap. - table.insert(cleaned, { - i = i; - A = {start_row; start_col}; - B = {end_row; end_col}; - lines = vim.split(e.newText, '\n', true); - }) - end - -- Reverse sort the orders so we can apply them without interfering with - -- eachother. Also add i as a sort key to mimic a stable sort. - table.sort(cleaned, edit_sort_key) - local lines = api.nvim_buf_get_lines(bufnr, start_line, finish_line + 1, false) - local fix_eol = api.nvim_buf_get_option(bufnr, 'fixeol') - local set_eol = fix_eol and api.nvim_buf_line_count(bufnr) <= finish_line + 1 - if set_eol and (#lines == 0 or #lines[#lines] ~= 0) then - table.insert(lines, '') + -- Fix reversed range and indexing each text_edits + local index = 0 + text_edits = vim.tbl_map(function(text_edit) + index = index + 1 + text_edit._index = index + + if text_edit.range.start.line > text_edit.range['end'].line or text_edit.range.start.line == text_edit.range['end'].line and text_edit.range.start.character > text_edit.range['end'].character then + local start = text_edit.range.start + text_edit.range.start = text_edit.range['end'] + text_edit.range['end'] = start + end + return text_edit + end, text_edits) + + -- Sort text_edits + table.sort(text_edits, function(a, b) + if a.range.start.line ~= b.range.start.line then + return a.range.start.line > b.range.start.line + end + if a.range.start.character ~= b.range.start.character then + return a.range.start.character > b.range.start.character + end + if a._index ~= b._index then + return a._index > b._index + end + end) + + -- Some LSP servers may return +1 range of the buffer content but nvim_buf_set_text can't accept it so we should fix it here. + local has_eol_text_edit = false + local max = vim.api.nvim_buf_line_count(bufnr) + local len = vim.str_utfindex(vim.api.nvim_buf_get_lines(bufnr, -2, -1, false)[1] or '') + text_edits = vim.tbl_map(function(text_edit) + if max <= text_edit.range.start.line then + text_edit.range.start.line = max - 1 + text_edit.range.start.character = len + text_edit.newText = '\n' .. text_edit.newText + has_eol_text_edit = true + end + if max <= text_edit.range['end'].line then + text_edit.range['end'].line = max - 1 + text_edit.range['end'].character = len + has_eol_text_edit = true + end + return text_edit + end, text_edits) + + -- Some LSP servers are depending on the VSCode behavior. + -- The VSCode will re-locate the cursor position after applying TextEdit so we also do it. + local is_current_buf = vim.api.nvim_get_current_buf() == bufnr + local cursor = (function() + if not is_current_buf then + return { + row = -1, + col = -1, + } + end + local cursor = vim.api.nvim_win_get_cursor(0) + return { + row = cursor[1] - 1, + col = cursor[2], + } + end)() + + -- Apply text edits. + local is_cursor_fixed = false + for _, text_edit in ipairs(text_edits) do + local e = { + start_row = text_edit.range.start.line, + start_col = get_line_byte_from_position(bufnr, text_edit.range.start), + end_row = text_edit.range['end'].line, + end_col = get_line_byte_from_position(bufnr, text_edit.range['end']), + text = vim.split(text_edit.newText, '\n', true), + } + vim.api.nvim_buf_set_text(bufnr, e.start_row, e.start_col, e.end_row, e.end_col, e.text) + + local row_count = (e.end_row - e.start_row) + 1 + if e.end_row < cursor.row then + cursor.row = cursor.row + (#e.text - row_count) + is_cursor_fixed = true + elseif e.end_row == cursor.row and e.end_col <= cursor.col then + cursor.row = cursor.row + (#e.text - row_count) + cursor.col = #e.text[#e.text] + (cursor.col - e.end_col) + if #e.text == 1 then + cursor.col = cursor.col + e.start_col + end + is_cursor_fixed = true + end end - for i = #cleaned, 1, -1 do - local e = cleaned[i] - local A = {e.A[1] - start_line, e.A[2]} - local B = {e.B[1] - start_line, e.B[2]} - lines = M.set_lines(lines, A, B, e.lines) + if is_cursor_fixed then + vim.api.nvim_win_set_cursor(0, { + cursor.row + 1, + math.min(cursor.col, #(vim.api.nvim_buf_get_lines(bufnr, cursor.row, cursor.row + 1, false)[1] or '')) + }) end - if set_eol and #lines[#lines] == 0 then - table.remove(lines) + + -- Remove final line if needed + local fix_eol = has_eol_text_edit + fix_eol = fix_eol and api.nvim_buf_get_option(bufnr, 'fixeol') + fix_eol = fix_eol and (vim.api.nvim_buf_get_lines(bufnr, -2, -1, false)[1] or '') == '' + if fix_eol then + vim.api.nvim_buf_set_lines(bufnr, -2, -1, false, {}) end - api.nvim_buf_set_lines(bufnr, start_line, finish_line + 1, false, lines) end -- local valid_windows_path_characters = "[^<>:\"/\\|?*]" @@ -294,11 +356,11 @@ end -- function M.glob_to_regex(glob) -- end ---@private +---@private --- Finds the first line and column of the difference between old and new lines ---@param old_lines table list of lines ---@param new_lines table list of lines ---@returns (int, int) start_line_idx and start_col_idx of range +---@param old_lines table list of lines +---@param new_lines table list of lines +---@returns (int, int) start_line_idx and start_col_idx of range local function first_difference(old_lines, new_lines, start_line_idx) local line_count = math.min(#old_lines, #new_lines) if line_count == 0 then return 1, 1 end @@ -324,12 +386,12 @@ local function first_difference(old_lines, new_lines, start_line_idx) end ---@private +---@private --- Finds the last line and column of the differences between old and new lines ---@param old_lines table list of lines ---@param new_lines table list of lines ---@param start_char integer First different character idx of range ---@returns (int, int) end_line_idx and end_col_idx of range +---@param old_lines table list of lines +---@param new_lines table list of lines +---@param start_char integer First different character idx of range +---@returns (int, int) end_line_idx and end_col_idx of range local function last_difference(old_lines, new_lines, start_char, end_line_idx) local line_count = math.min(#old_lines, #new_lines) if line_count == 0 then return 0,0 end @@ -368,14 +430,14 @@ local function last_difference(old_lines, new_lines, start_char, end_line_idx) end ---@private +---@private --- Get the text of the range defined by start and end line/column ---@param lines table list of lines ---@param start_char integer First different character idx of range ---@param end_char integer Last different character idx of range ---@param start_line integer First different line idx of range ---@param end_line integer Last different line idx of range ---@returns string text extracted from defined region +---@param lines table list of lines +---@param start_char integer First different character idx of range +---@param end_char integer Last different character idx of range +---@param start_line integer First different line idx of range +---@param end_line integer Last different line idx of range +---@returns string text extracted from defined region local function extract_text(lines, start_line, start_char, end_line, end_char) if start_line == #lines + end_line + 1 then if end_line == 0 then return '' end @@ -395,14 +457,14 @@ local function extract_text(lines, start_line, start_char, end_line, end_char) return result end ---@private +---@private --- Compute the length of the substituted range ---@param lines table list of lines ---@param start_char integer First different character idx of range ---@param end_char integer Last different character idx of range ---@param start_line integer First different line idx of range ---@param end_line integer Last different line idx of range ---@returns (int, int) end_line_idx and end_col_idx of range +---@param lines table list of lines +---@param start_char integer First different character idx of range +---@param end_char integer Last different character idx of range +---@param start_line integer First different line idx of range +---@param end_line integer Last different line idx of range +---@returns (int, int) end_line_idx and end_col_idx of range local function compute_length(lines, start_line, start_char, end_line, end_char) local adj_end_line = #lines + end_line + 1 local adj_end_char @@ -423,12 +485,12 @@ local function compute_length(lines, start_line, start_char, end_line, end_char) end --- Returns the range table for the difference between old and new lines ---@param old_lines table list of lines ---@param new_lines table list of lines ---@param start_line_idx int line to begin search for first difference ---@param end_line_idx int line to begin search for last difference ---@param offset_encoding string encoding requested by language server ---@returns table start_line_idx and start_col_idx of range +---@param old_lines table list of lines +---@param new_lines table list of lines +---@param start_line_idx int line to begin search for first difference +---@param end_line_idx int line to begin search for last difference +---@param offset_encoding string encoding requested by language server +---@returns table start_line_idx and start_col_idx of range function M.compute_diff(old_lines, new_lines, start_line_idx, end_line_idx, offset_encoding) local start_line, start_char = first_difference(old_lines, new_lines, start_line_idx) local end_line, end_char = last_difference(vim.list_slice(old_lines, start_line, #old_lines), @@ -468,9 +530,9 @@ end --- Can be used to extract the completion items from a --- `textDocument/completion` request, which may return one of --- `CompletionItem[]`, `CompletionList` or null. ---@param result (table) The result of a `textDocument/completion` request ---@returns (table) List of completion items ---@see https://microsoft.github.io/language-server-protocol/specification#textDocument_completion +---@param result (table) The result of a `textDocument/completion` request +---@returns (table) List of completion items +---@see https://microsoft.github.io/language-server-protocol/specification#textDocument_completion function M.extract_completion_items(result) if type(result) == 'table' and result.items then -- result is a `CompletionList` @@ -514,90 +576,34 @@ function M.apply_text_document_edit(text_document_edit, index) M.apply_text_edits(text_document_edit.edits, bufnr) end ---@private ---- Recursively parses snippets in a completion entry. ---- ---@param input (string) Snippet text to parse for snippets ---@param inner (bool) Whether this function is being called recursively ---@returns 2-tuple of strings: The first is the parsed result, the second is the ----unparsed rest of the input -local function parse_snippet_rec(input, inner) - local res = "" - - local close, closeend = nil, nil - if inner then - close, closeend = input:find("}", 1, true) - while close ~= nil and input:sub(close-1,close-1) == "\\" do - close, closeend = input:find("}", closeend+1, true) - end - end - - local didx = input:find('$', 1, true) - if didx == nil and close == nil then - return input, "" - elseif close ~=nil and (didx == nil or close < didx) then - -- No inner placeholders - return input:sub(0, close-1), input:sub(closeend+1) - end - - res = res .. input:sub(0, didx-1) - input = input:sub(didx+1) - - local tabstop, tabstopend = input:find('^%d+') - local placeholder, placeholderend = input:find('^{%d+:') - local choice, choiceend = input:find('^{%d+|') - - if tabstop then - input = input:sub(tabstopend+1) - elseif choice then - input = input:sub(choiceend+1) - close, closeend = input:find("|}", 1, true) - - res = res .. input:sub(0, close-1) - input = input:sub(closeend+1) - elseif placeholder then - -- TODO: add support for variables - input = input:sub(placeholderend+1) - - -- placeholders and variables are recursive - while input ~= "" do - local r, tail = parse_snippet_rec(input, true) - r = r:gsub("\\}", "}") - - res = res .. r - input = tail - end - else - res = res .. "$" - end - - return res, input -end - --- Parses snippets in a completion entry. --- ---@param input (string) unparsed snippet ---@returns (string) parsed snippet +---@param input string unparsed snippet +---@returns string parsed snippet function M.parse_snippet(input) - local res, _ = parse_snippet_rec(input, false) - - return res + local ok, parsed = pcall(function() + return tostring(snippet.parse(input)) + end) + if not ok then + return input + end + return parsed end ---@private +---@private --- Sorts by CompletionItem.sortText. --- ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion +--see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion local function sort_completion_items(items) table.sort(items, function(a, b) return (a.sortText or a.label) < (b.sortText or b.label) end) end ---@private +---@private --- Returns text that should be inserted when selecting completion item. The --- precedence is as follows: textEdit.newText > insertText > label ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion +--see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion local function get_completion_word(item) if item.textEdit ~= nil and item.textEdit.newText ~= nil and item.textEdit.newText ~= "" then local insert_text_format = protocol.InsertTextFormat[item.insertTextFormat] @@ -617,7 +623,7 @@ local function get_completion_word(item) return item.label end ---@private +---@private --- Some language servers return complementary candidates whose prefixes do not --- match are also returned. So we exclude completion candidates whose prefix --- does not match. @@ -632,9 +638,9 @@ end --- the client must handle it properly even if it receives a value outside the --- specification. --- ---@param completion_item_kind (`vim.lsp.protocol.completionItemKind`) ---@returns (`vim.lsp.protocol.completionItemKind`) ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion +---@param completion_item_kind (`vim.lsp.protocol.completionItemKind`) +---@returns (`vim.lsp.protocol.completionItemKind`) +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_completion function M._get_completion_item_kind_name(completion_item_kind) return protocol.CompletionItemKind[completion_item_kind] or "Unknown" end @@ -642,12 +648,12 @@ end --- Turns the result of a `textDocument/completion` request into vim-compatible --- |complete-items|. --- ---@param result The result of a `textDocument/completion` call, e.g. from +---@param result The result of a `textDocument/completion` call, e.g. from ---|vim.lsp.buf.completion()|, which may be one of `CompletionItem[]`, --- `CompletionList` or `null` ---@param prefix (string) the prefix to filter the completion items ---@returns { matches = complete-items table, incomplete = bool } ---@see |complete-items| +---@param prefix (string) the prefix to filter the completion items +---@returns { matches = complete-items table, incomplete = bool } +---@see |complete-items| function M.text_document_completion_list_to_complete_items(result, prefix) local items = M.extract_completion_items(result) if vim.tbl_isempty(items) then @@ -697,8 +703,8 @@ end --- Rename old_fname to new_fname --- ---@param opts (table) +--- +---@param opts (table) -- overwrite? bool -- ignoreIfExists? bool function M.rename(old_fname, new_fname, opts) @@ -753,8 +759,8 @@ end --- Applies a `WorkspaceEdit`. --- ---@param workspace_edit (table) `WorkspaceEdit` --- @see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit +---@param workspace_edit (table) `WorkspaceEdit` +--see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_applyEdit function M.apply_workspace_edit(workspace_edit) if workspace_edit.documentChanges then for idx, change in ipairs(workspace_edit.documentChanges) do @@ -793,10 +799,10 @@ end --- window for `textDocument/hover`, for parsing the result of --- `textDocument/signatureHelp`, and potentially others. --- ---@param input (`MarkedString` | `MarkedString[]` | `MarkupContent`) ---@param contents (table, optional, default `{}`) List of strings to extend with converted lines ---@returns {contents}, extended with lines of converted markdown. ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover +---@param input (`MarkedString` | `MarkedString[]` | `MarkupContent`) +---@param contents (table, optional, default `{}`) List of strings to extend with converted lines +---@returns {contents}, extended with lines of converted markdown. +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover function M.convert_input_to_markdown_lines(input, contents) contents = contents or {} -- MarkedString variation 1 @@ -810,16 +816,16 @@ function M.convert_input_to_markdown_lines(input, contents) -- If it's plaintext, then wrap it in a <text></text> block -- Some servers send input.value as empty, so let's ignore this :( - input.value = input.value or '' + local value = input.value or '' if input.kind == "plaintext" then -- wrap this in a <text></text> block so that stylize_markdown -- can properly process it as plaintext - input.value = string.format("<text>\n%s\n</text>", input.value or "") + value = string.format("<text>\n%s\n</text>", value) end - -- assert(type(input.value) == 'string') - list_extend(contents, split_lines(input.value)) + -- assert(type(value) == 'string') + list_extend(contents, split_lines(value)) -- MarkupString variation 2 elseif input.language then -- Some servers send input.value as empty, so let's ignore this :( @@ -843,11 +849,12 @@ end --- Converts `textDocument/SignatureHelp` response to markdown lines. --- ---@param signature_help Response of `textDocument/SignatureHelp` ---@param ft optional filetype that will be use as the `lang` for the label markdown code block ---@returns list of lines of converted markdown. ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp -function M.convert_signature_help_to_markdown_lines(signature_help, ft) +---@param signature_help Response of `textDocument/SignatureHelp` +---@param ft optional filetype that will be use as the `lang` for the label markdown code block +---@param triggers optional list of trigger characters from the lsp server. used to better determine parameter offsets +---@returns list of lines of converted markdown. +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp +function M.convert_signature_help_to_markdown_lines(signature_help, ft, triggers) if not signature_help.signatures then return end @@ -856,6 +863,7 @@ function M.convert_signature_help_to_markdown_lines(signature_help, ft) --=== 0`. Whenever possible implementors should make an active decision about --the active signature and shouldn't rely on a default value. local contents = {} + local active_hl local active_signature = signature_help.activeSignature or 0 -- If the activeSignature is not inside the valid range, then clip it. if active_signature >= #signature_help.signatures then @@ -875,11 +883,17 @@ function M.convert_signature_help_to_markdown_lines(signature_help, ft) M.convert_input_to_markdown_lines(signature.documentation, contents) end if signature.parameters and #signature.parameters > 0 then - local active_parameter = signature_help.activeParameter or 0 - -- If the activeParameter is not inside the valid range, then clip it. + local active_parameter = (signature.activeParameter or signature_help.activeParameter or 0) + if active_parameter < 0 + then active_parameter = 0 + end + + -- If the activeParameter is > #parameters, then set it to the last + -- NOTE: this is not fully according to the spec, but a client-side interpretation if active_parameter >= #signature.parameters then - active_parameter = 0 + active_parameter = #signature.parameters - 1 end + local parameter = signature.parameters[active_parameter + 1] if parameter then --[=[ @@ -900,22 +914,44 @@ function M.convert_signature_help_to_markdown_lines(signature_help, ft) documentation?: string | MarkupContent; } --]=] - -- TODO highlight parameter + if parameter.label then + if type(parameter.label) == "table" then + active_hl = parameter.label + else + local offset = 1 + -- try to set the initial offset to the first found trigger character + for _, t in ipairs(triggers or {}) do + local trigger_offset = signature.label:find(t, 1, true) + if trigger_offset and (offset == 1 or trigger_offset < offset) then + offset = trigger_offset + end + end + for p, param in pairs(signature.parameters) do + offset = signature.label:find(param.label, offset, true) + if not offset then break end + if p == active_parameter + 1 then + active_hl = {offset - 1, offset + #parameter.label - 1} + break + end + offset = offset + #param.label + 1 + end + end + end if parameter.documentation then M.convert_input_to_markdown_lines(parameter.documentation, contents) end end end - return contents + return contents, active_hl end --- Creates a table with sensible default options for a floating window. The --- table can be passed to |nvim_open_win()|. --- ---@param width (number) window width (in character cells) ---@param height (number) window height (in character cells) ---@param opts (table, optional) ---@returns (table) Options +---@param width (number) window width (in character cells) +---@param height (number) window height (in character cells) +---@param opts (table, optional) +---@returns (table) Options function M.make_floating_popup_options(width, height, opts) validate { opts = { opts, 't', true }; @@ -942,7 +978,7 @@ function M.make_floating_popup_options(width, height, opts) row = -get_border_size(opts).height end - if vim.fn.wincol() + width <= api.nvim_get_option('columns') then + if vim.fn.wincol() + width + (opts.offset_x or 0) <= api.nvim_get_option('columns') then anchor = anchor..'W' col = 0 else @@ -960,13 +996,14 @@ function M.make_floating_popup_options(width, height, opts) style = 'minimal', width = width, border = opts.border or default_border, + zindex = opts.zindex or 50, } end --- Jumps to a location. --- ---@param location (`Location`|`LocationLink`) ---@returns `true` if the jump succeeded +---@param location (`Location`|`LocationLink`) +---@returns `true` if the jump succeeded function M.jump_to_location(location) -- location may be Location or LocationLink local uri = location.uri or location.targetUri @@ -996,8 +1033,8 @@ end --- - for Location, range is shown (e.g., function definition) --- - for LocationLink, targetRange is shown (e.g., body of function definition) --- ---@param location a single `Location` or `LocationLink` ---@returns (bufnr,winnr) buffer and window number of floating window or nil +---@param location a single `Location` or `LocationLink` +---@returns (bufnr,winnr) buffer and window number of floating window or nil function M.preview_location(location, opts) -- location may be LocationLink or Location (more useful for the former) local uri = location.targetUri or location.uri @@ -1020,7 +1057,7 @@ function M.preview_location(location, opts) return M.open_floating_preview(contents, syntax, opts) end ---@private +---@private local function find_window_by_var(name, value) for _, win in ipairs(api.nvim_list_wins()) do if npcall(api.nvim_win_get_var, win, name) == value then @@ -1056,10 +1093,10 @@ function M._trim(contents, opts) return contents end --- Generates a table mapping markdown code block lang to vim syntax, --- based on g:markdown_fenced_languages --- @return a table of lang -> syntax mappings --- @private +--- Generates a table mapping markdown code block lang to vim syntax, +--- based on g:markdown_fenced_languages +---@return a table of lang -> syntax mappings +---@private local function get_markdown_fences() local fences = {} for _, fence in pairs(vim.g.markdown_fenced_languages or {}) do @@ -1129,6 +1166,8 @@ function M.stylize_markdown(bufnr, contents, opts) -- Clean up contents = M._trim(contents, opts) + -- Insert blank line separator after code block? + local add_sep = opts.separator == nil and true or opts.separator local stripped = {} local highlights = {} -- keep track of lnums that contain markdown @@ -1155,9 +1194,24 @@ function M.stylize_markdown(bufnr, contents, opts) start = start + 1; finish = #stripped; }) + -- add a separator, but not on the last line + if add_sep and i < #contents then + table.insert(stripped, "---") + markdown_lines[#stripped] = true + end else - table.insert(stripped, line) - markdown_lines[#stripped] = true + -- strip any emty lines or separators prior to this separator in actual markdown + if line:match("^---+$") then + while markdown_lines[#stripped] and (stripped[#stripped]:match("^%s*$") or stripped[#stripped]:match("^---+$")) do + markdown_lines[#stripped] = false + table.remove(stripped, #stripped) + end + end + -- add the line if its not an empty line following a separator + if not (line:match("^%s*$") and markdown_lines[#stripped] and stripped[#stripped]:match("^---+$")) then + table.insert(stripped, line) + markdown_lines[#stripped] = true + end i = i + 1 end end @@ -1165,7 +1219,7 @@ function M.stylize_markdown(bufnr, contents, opts) -- Compute size of float needed to show (wrapped) lines opts.wrap_at = opts.wrap_at or (vim.wo["wrap"] and api.nvim_win_get_width(0)) - local width, height = M._make_floating_popup_size(stripped, opts) + local width = M._make_floating_popup_size(stripped, opts) local sep_line = string.rep("─", math.min(width, opts.wrap_at or width)) @@ -1175,30 +1229,10 @@ function M.stylize_markdown(bufnr, contents, opts) end end - -- Insert blank line separator after code block - local insert_separator = opts.separator - if insert_separator == nil then insert_separator = true end - if insert_separator then - local offset = 0 - for _, h in ipairs(highlights) do - h.start = h.start + offset - h.finish = h.finish + offset - -- check if a seperator already exists and use that one instead of creating a new one - if h.finish + 1 <= #stripped then - if stripped[h.finish + 1] ~= sep_line then - table.insert(stripped, h.finish + 1, sep_line) - offset = offset + 1 - height = height + 1 - end - end - end - end - - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, stripped) local idx = 1 - --@private + ---@private -- keep track of syntaxes we already inlcuded. -- no need to include the same syntax more than once local langs = {} @@ -1247,26 +1281,26 @@ end --- Creates autocommands to close a preview window when events happen. --- ---@param events (table) list of events ---@param winnr (number) window id of preview window ---@see |autocmd-events| +---@param events (table) list of events +---@param winnr (number) window id of preview window +---@see |autocmd-events| function M.close_preview_autocmd(events, winnr) if #events > 0 then api.nvim_command("autocmd "..table.concat(events, ',').." <buffer> ++once lua pcall(vim.api.nvim_win_close, "..winnr..", true)") end end ---@internal +---@internal --- Computes size of float needed to show contents (with optional wrapping) --- ---@param contents table of lines to show in window ---@param opts dictionary with optional fields --- - height of floating window --- - width of floating window --- - wrap_at character to wrap at for computing height --- - max_width maximal width of floating window --- - max_height maximal height of floating window ---@returns width,height size of float +---@param contents table of lines to show in window +---@param opts dictionary with optional fields +--- - height of floating window +--- - width of floating window +--- - wrap_at character to wrap at for computing height +--- - max_width maximal width of floating window +--- - max_height maximal height of floating window +---@returns width,height size of float function M._make_floating_popup_size(contents, opts) validate { contents = { contents, 't' }; @@ -1333,9 +1367,9 @@ end --- Shows contents in a floating window. --- ---@param contents table of lines to show in window ---@param syntax string of syntax to set for opened buffer ---@param opts dictionary with optional fields +---@param contents table of lines to show in window +---@param syntax string of syntax to set for opened buffer +---@param opts dictionary with optional fields --- - height of floating window --- - width of floating window --- - wrap boolean enable wrapping of long lines (defaults to true) @@ -1349,7 +1383,7 @@ end --- - focus_id if a popup with this id is opened, then focus it --- - close_events list of events that closes the floating window --- - focusable (boolean, default true): Make float focusable ---@returns bufnr,winnr buffer and window number of the newly created floating +---@returns bufnr,winnr buffer and window number of the newly created floating ---preview window function M.open_floating_preview(contents, syntax, opts) validate { @@ -1445,7 +1479,7 @@ do --[[ References ]] --- Removes document highlights from a buffer. --- - --@param bufnr buffer id + ---@param bufnr buffer id function M.buf_clear_references(bufnr) validate { bufnr = {bufnr, 'n', true} } api.nvim_buf_clear_namespace(bufnr, reference_ns, 0, -1) @@ -1453,8 +1487,9 @@ do --[[ References ]] --- Shows a list of document highlights for a certain buffer. --- - --@param bufnr buffer id - --@param references List of `DocumentHighlight` objects to highlight + ---@param bufnr buffer id + ---@param references List of `DocumentHighlight` objects to highlight + ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#documentHighlight function M.buf_highlight_references(bufnr, references) validate { bufnr = {bufnr, 'n', true} } for _, reference in ipairs(references) do @@ -1475,24 +1510,24 @@ local position_sort = sort_by_key(function(v) return {v.start.line, v.start.character} end) --- Gets the zero-indexed line from the given uri. +--- Gets the zero-indexed line from the given uri. +---@param uri string uri of the resource to get the line from +---@param row number zero-indexed line number +---@return string the line at row in filename -- For non-file uris, we load the buffer and get the line. -- If a loaded buffer exists, then that is used. -- Otherwise we get the line using libuv which is a lot faster than loading the buffer. ---@param uri string uri of the resource to get the line from ---@param row number zero-indexed line number ---@return string the line at row in filename function M.get_line(uri, row) return M.get_lines(uri, { row })[row] end --- Gets the zero-indexed lines from the given uri. +--- Gets the zero-indexed lines from the given uri. +---@param uri string uri of the resource to get the lines from +---@param rows number[] zero-indexed line numbers +---@return table<number string> a table mapping rows to lines -- For non-file uris, we load the buffer and get the lines. -- If a loaded buffer exists, then that is used. -- Otherwise we get the lines using libuv which is a lot faster than loading the buffer. ---@param uri string uri of the resource to get the lines from ---@param rows number[] zero-indexed line numbers ---@return table<number string> a table mapping rows to lines function M.get_lines(uri, rows) rows = type(rows) == "table" and rows or { rows } @@ -1560,8 +1595,11 @@ end --- Returns the items with the byte position calculated correctly and in sorted --- order, for display in quickfix and location lists. --- ---@param locations (table) list of `Location`s or `LocationLink`s ---@returns (table) list of items +--- The result can be passed to the {list} argument of |setqflist()| or +--- |setloclist()|. +--- +---@param locations (table) list of `Location`s or `LocationLink`s +---@returns (table) list of items function M.locations_to_items(locations) local items = {} local grouped = setmetatable({}, { @@ -1618,7 +1656,9 @@ end --- Can be obtained with e.g. |vim.lsp.util.locations_to_items()|. --- Defaults to current window. --- ---@param items (table) list of items +---@deprecated Use |setloclist()| +--- +---@param items (table) list of items function M.set_loclist(items, win_id) vim.fn.setloclist(win_id or 0, {}, ' ', { title = 'Language Server'; @@ -1629,7 +1669,9 @@ end --- Fills quickfix list with given list of items. --- Can be obtained with e.g. |vim.lsp.util.locations_to_items()|. --- ---@param items (table) list of items +---@deprecated Use |setqflist()| +--- +---@param items (table) list of items function M.set_qflist(items) vim.fn.setqflist({}, ' ', { title = 'Language Server'; @@ -1646,9 +1688,9 @@ end --- Converts symbols to quickfix list items. --- ---@param symbols DocumentSymbol[] or SymbolInformation[] +---@param symbols DocumentSymbol[] or SymbolInformation[] function M.symbols_to_items(symbols, bufnr) - --@private + ---@private local function _symbols_to_items(_symbols, _items, _bufnr) for _, symbol in ipairs(_symbols) do if symbol.location then -- SymbolInformation type @@ -1684,19 +1726,19 @@ function M.symbols_to_items(symbols, bufnr) end --- Removes empty lines from the beginning and end. ---@param lines (table) list of lines to trim ---@returns (table) trimmed list of lines +---@param lines (table) list of lines to trim +---@returns (table) trimmed list of lines function M.trim_empty_lines(lines) local start = 1 for i = 1, #lines do - if #lines[i] > 0 then + if lines[i] ~= nil and #lines[i] > 0 then start = i break end end local finish = 1 for i = #lines, 1, -1 do - if #lines[i] > 0 then + if lines[i] ~= nil and #lines[i] > 0 then finish = i break end @@ -1709,8 +1751,8 @@ end --- --- CAUTION: Modifies the input in-place! --- ---@param lines (table) list of lines ---@returns (string) filetype or 'markdown' if it was unchanged. +---@param lines (table) list of lines +---@returns (string) filetype or 'markdown' if it was unchanged. function M.try_trim_markdown_code_blocks(lines) local language_id = lines[1]:match("^```(.*)") if language_id then @@ -1733,7 +1775,7 @@ function M.try_trim_markdown_code_blocks(lines) end local str_utfindex = vim.str_utfindex ---@private +---@private local function make_position_param() local row, col = unpack(api.nvim_win_get_cursor(0)) row = row - 1 @@ -1747,8 +1789,8 @@ end --- Creates a `TextDocumentPositionParams` object for the current buffer and cursor position. --- ---@returns `TextDocumentPositionParams` object ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams +---@returns `TextDocumentPositionParams` object +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentPositionParams function M.make_position_params() return { textDocument = M.make_text_document_params(); @@ -1761,7 +1803,7 @@ end --- `textDocument/codeAction`, `textDocument/colorPresentation`, --- `textDocument/rangeFormatting`. --- ---@returns { textDocument = { uri = `current_file_uri` }, range = { start = +---@returns { textDocument = { uri = `current_file_uri` }, range = { start = ---`current_position`, end = `current_position` } } function M.make_range_params() local position = make_position_param() @@ -1774,11 +1816,11 @@ end --- Using the given range in the current buffer, creates an object that --- is similar to |vim.lsp.util.make_range_params()|. --- ---@param start_pos ({number, number}, optional) mark-indexed position. +---@param start_pos ({number, number}, optional) mark-indexed position. ---Defaults to the start of the last visual selection. ---@param end_pos ({number, number}, optional) mark-indexed position. +---@param end_pos ({number, number}, optional) mark-indexed position. ---Defaults to the end of the last visual selection. ---@returns { textDocument = { uri = `current_file_uri` }, range = { start = +---@returns { textDocument = { uri = `current_file_uri` }, range = { start = ---`start_position`, end = `end_position` } } function M.make_given_range_params(start_pos, end_pos) validate { @@ -1814,23 +1856,23 @@ end --- Creates a `TextDocumentIdentifier` object for the current buffer. --- ---@returns `TextDocumentIdentifier` ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentIdentifier +---@returns `TextDocumentIdentifier` +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocumentIdentifier function M.make_text_document_params() return { uri = vim.uri_from_bufnr(0) } end --- Create the workspace params ---@param added ---@param removed +---@param added +---@param removed function M.make_workspace_params(added, removed) return { event = { added = added; removed = removed; } } end --- Returns visual width of tabstop. --- ---@see |softtabstop| ---@param bufnr (optional, number): Buffer handle, defaults to current ---@returns (number) tabstop visual width +---@see |softtabstop| +---@param bufnr (optional, number): Buffer handle, defaults to current +---@returns (number) tabstop visual width function M.get_effective_tabstop(bufnr) validate { bufnr = {bufnr, 'n', true} } local bo = bufnr and vim.bo[bufnr] or vim.bo @@ -1838,11 +1880,11 @@ function M.get_effective_tabstop(bufnr) return (sts > 0 and sts) or (sts < 0 and bo.shiftwidth) or bo.tabstop end ---- Creates a `FormattingOptions` object for the current buffer and cursor position. +--- Creates a `DocumentFormattingParams` object for the current buffer and cursor position. --- ---@param options Table with valid `FormattingOptions` entries ---@returns `FormattingOptions object ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting +---@param options Table with valid `FormattingOptions` entries +---@returns `DocumentFormattingParams` object +---@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_formatting function M.make_formatting_params(options) validate { options = {options, 't', true} } options = vim.tbl_extend('keep', options or {}, { @@ -1857,10 +1899,10 @@ end --- Returns the UTF-32 and UTF-16 offsets for a position in a certain buffer. --- ---@param buf buffer id (0 for current) ---@param row 0-indexed line ---@param col 0-indexed byte offset in line ---@returns (number, number) UTF-32 and UTF-16 index of the character in line {row} column {col} in buffer {buf} +---@param buf buffer id (0 for current) +---@param row 0-indexed line +---@param col 0-indexed byte offset in line +---@returns (number, number) UTF-32 and UTF-16 index of the character in line {row} column {col} in buffer {buf} function M.character_offset(bufnr, row, col) local uri = vim.uri_from_bufnr(bufnr) local line = M.get_line(uri, row) @@ -1873,9 +1915,9 @@ end --- Helper function to return nested values in language server settings --- ---@param settings a table of language server settings ---@param section a string indicating the field of the settings table ---@returns (table or string) The value of settings accessed via section +---@param settings a table of language server settings +---@param section a string indicating the field of the settings table +---@returns (table or string) The value of settings accessed via section function M.lookup_section(settings, section) for part in vim.gsplit(section, '.', true) do settings = settings[part] @@ -1886,40 +1928,6 @@ function M.lookup_section(settings, section) return settings end - ---- Convert diagnostics grouped by bufnr to a list of items for use in the ---- quickfix or location list. ---- ---@param diagnostics_by_bufnr table bufnr -> Diagnostic[] ---@param predicate an optional function to filter the diagnostics. --- If present, only diagnostic items matching will be included. ---@return table (A list of items) -function M.diagnostics_to_items(diagnostics_by_bufnr, predicate) - local items = {} - for bufnr, diagnostics in pairs(diagnostics_by_bufnr or {}) do - for _, d in pairs(diagnostics) do - if not predicate or predicate(d) then - table.insert(items, { - bufnr = bufnr, - lnum = d.range.start.line + 1, - col = d.range.start.character + 1, - text = d.message, - type = loclist_type_map[d.severity or DiagnosticSeverity.Error] or 'E' - }) - end - end - end - table.sort(items, function(a, b) - if a.bufnr == b.bufnr then - return a.lnum < b.lnum - else - return a.bufnr < b.bufnr - end - end) - return items -end - - M._get_line_byte_from_position = get_line_byte_from_position M._warn_once = warn_once |