diff options
Diffstat (limited to 'runtime/lua/vim/lsp')
-rw-r--r-- | runtime/lua/vim/lsp/buf.lua | 136 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/codelens.lua | 231 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/diagnostic.lua | 87 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/handlers.lua | 44 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/log.lua | 2 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 30 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 550 |
7 files changed, 835 insertions, 245 deletions
diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 31116985e2..c63b947c99 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -111,6 +111,39 @@ function M.completion(context) return request('textDocument/completion', params) end +--@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 +local function select_client(method) + local clients = vim.tbl_values(vim.lsp.buf_get_clients()); + clients = vim.tbl_filter(function (client) + return client.supports_method(method) + end, clients) + -- better UX when choices are always in the same order (between restarts) + table.sort(clients, function (a, b) return a.name < b.name end) + + if #clients > 1 then + local choices = {} + for k,v in ipairs(clients) do + table.insert(choices, string.format("%d %s", k, v.name)) + end + local user_choice = vim.fn.confirm( + "Select a language server:", + table.concat(choices, "\n"), + 0, + "Question" + ) + if user_choice == 0 then return nil end + return clients[user_choice] + elseif #clients < 1 then + return nil + else + return clients[1] + end +end + --- Formats the current buffer. --- --@param options (optional, table) Can be used to specify FormattingOptions. @@ -119,8 +152,11 @@ end -- --@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 + local params = util.make_formatting_params(options) - return request('textDocument/formatting', params) + return client.request("textDocument/formatting", params) end --- Performs |vim.lsp.buf.formatting()| synchronously. @@ -134,14 +170,62 @@ end --- --@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 + local params = util.make_formatting_params(options) - local result = vim.lsp.buf_request_sync(0, "textDocument/formatting", params, timeout_ms) - if not result or vim.tbl_isempty(result) then return end - local _, formatting_result = next(result) - result = formatting_result.result - if not result then return end - vim.lsp.util.apply_text_edits(result) + local result, err = client.request_sync("textDocument/formatting", params, timeout_ms) + if result and result.result then + util.apply_text_edits(result.result) + elseif err then + vim.notify("vim.lsp.buf.formatting_sync: " .. err, vim.log.levels.WARN) + end +end + +--- Formats the current buffer by sequentially requesting formatting from attached clients. +--- +--- Useful when multiple clients with formatting capability are attached. +--- +--- Since it's synchronous, can be used for running on save, to make sure buffer is formatted +--- prior to being saved. {timeout_ms} is passed on to the |vim.lsp.client| `request_sync` method. +--- Example: +--- <pre> +--- 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 +---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 + -- if the client exists, move to the end of the list + for i, client in ipairs(clients) do + if client.name == client_name then + table.insert(clients, table.remove(clients, i)) + break + end + end + end + + -- loop through the clients and make synchronous formatting requests + for _, client in ipairs(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) + if result and result.result then + util.apply_text_edits(result.result) + elseif err then + vim.notify(string.format("vim.lsp.buf.formatting_seq_sync: (%s) %s", client.name, err), vim.log.levels.WARN) + end + end + end end --- Formats a given range. @@ -152,15 +236,12 @@ end --@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) - validate { options = {options, 't', true} } - local sts = vim.bo.softtabstop; - options = vim.tbl_extend('keep', options or {}, { - tabSize = (sts > 0 and sts) or (sts < 0 and vim.bo.shiftwidth) or vim.bo.tabstop; - insertSpaces = vim.bo.expandtab; - }) + local client = select_client("textDocument/rangeFormatting") + if client == nil then return end + local params = util.make_given_range_params(start_pos, end_pos) - params.options = options - return request('textDocument/rangeFormatting', params) + params.options = util.make_formatting_params(options).options + return client.request("textDocument/rangeFormatting", params) end --- Renames all references to the symbol under the cursor. @@ -216,26 +297,31 @@ local function pick_call_hierarchy_item(call_hierarchy_items) return choice end +--@private +local function call_hierarchy(method) + local params = util.make_position_params() + request('textDocument/prepareCallHierarchy', params, function(err, _, result) + 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 }) + end) +end + --- Lists all the call sites of the symbol under the cursor in the --- |quickfix| window. If the symbol can resolve to multiple --- items, the user can pick one in the |inputlist|. function M.incoming_calls() - local params = util.make_position_params() - request('textDocument/prepareCallHierarchy', params, function(_, _, result) - local call_hierarchy_item = pick_call_hierarchy_item(result) - vim.lsp.buf_request(0, 'callHierarchy/incomingCalls', { item = call_hierarchy_item }) - end) + call_hierarchy('callHierarchy/incomingCalls') end --- Lists all the items that are called by the symbol under the --- cursor in the |quickfix| window. If the symbol can resolve to --- multiple items, the user can pick one in the |inputlist|. function M.outgoing_calls() - local params = util.make_position_params() - request('textDocument/prepareCallHierarchy', params, function(_, _, result) - local call_hierarchy_item = pick_call_hierarchy_item(result) - vim.lsp.buf_request(0, 'callHierarchy/outgoingCalls', { item = call_hierarchy_item }) - end) + call_hierarchy('callHierarchy/outgoingCalls') end --- List workspace folders. diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua new file mode 100644 index 0000000000..fbd37e3830 --- /dev/null +++ b/runtime/lua/vim/lsp/codelens.lua @@ -0,0 +1,231 @@ +local util = require('vim.lsp.util') +local api = vim.api +local M = {} + +--- bufnr → true|nil +--- to throttle refreshes to at most one at a time +local active_refreshes = {} + +--- bufnr -> client_id -> lenses +local lens_cache_by_buf = setmetatable({}, { + __index = function(t, b) + local key = b > 0 and b or api.nvim_get_current_buf() + return rawget(t, key) + end +}) + +local namespaces = setmetatable({}, { + __index = function(t, key) + local value = api.nvim_create_namespace('vim_lsp_codelens:' .. key) + rawset(t, key, value) + return value + end; +}) + +--@private +M.__namespaces = namespaces + + +--@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) + + -- 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 result = vim.lsp.handlers['workspace/executeCommand'](...) + M.refresh() + return result + end, bufnr) +end + + +--- Return all lenses for the given buffer +--- +---@return table (`CodeLens[]`) +function M.get(bufnr) + local lenses_by_client = lens_cache_by_buf[bufnr] + if not lenses_by_client then return {} end + local lenses = {} + for _, client_lenses in pairs(lenses_by_client) do + vim.list_extend(lenses, client_lenses) + end + return lenses +end + + +--- Run the code lens in the current line +--- +function M.run() + local line = api.nvim_win_get_cursor(0)[1] + local bufnr = api.nvim_get_current_buf() + local options = {} + local lenses_by_client = lens_cache_by_buf[bufnr] or {} + for client, lenses in pairs(lenses_by_client) do + for _, lens in pairs(lenses) do + if lens.range.start.line == (line - 1) then + table.insert(options, {client=client, lens=lens}) + end + end + end + if #options == 0 then + vim.notify('No executable codelens found at current line') + elseif #options == 1 then + local option = options[1] + execute_lens(option.lens, bufnr, option.client) + else + local options_strings = {"Code lenses:"} + for i, option in ipairs(options) do + table.insert(options_strings, string.format('%d. %s', i, option.lens.command.title)) + end + local choice = vim.fn.inputlist(options_strings) + if choice < 1 or choice > #options then + return + end + local option = options[choice] + execute_lens(option.lens, bufnr, option.client) + end +end + + +--- Display the lenses using virtual text +--- +---@param lenses table of lenses to display (`CodeLens[] | null`) +---@param bufnr number +---@param client_id number +function M.display(lenses, bufnr, client_id) + if not lenses or not next(lenses) then + return + end + local lenses_by_lnum = {} + for _, lens in pairs(lenses) do + local line_lenses = lenses_by_lnum[lens.range.start.line] + if not line_lenses then + line_lenses = {} + lenses_by_lnum[lens.range.start.line] = line_lenses + end + table.insert(line_lenses, lens) + end + 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] + api.nvim_buf_clear_namespace(bufnr, ns, i, i + 1) + local chunks = {} + for _, lens in pairs(line_lenses or {}) do + local text = lens.command and lens.command.title or 'Unresolved lens ...' + table.insert(chunks, {text, 'LspCodeLens' }) + end + if #chunks > 0 then + api.nvim_buf_set_virtual_text(bufnr, ns, i, chunks, {}) + end + end +end + + +--- Store lenses for a specific buffer and client +--- +---@param lenses table of lenses to store (`CodeLens[] | null`) +---@param bufnr number +---@param client_id number +function M.save(lenses, bufnr, client_id) + local lenses_by_client = lens_cache_by_buf[bufnr] + if not lenses_by_client then + lenses_by_client = {} + lens_cache_by_buf[bufnr] = lenses_by_client + local ns = namespaces[client_id] + api.nvim_buf_attach(bufnr, false, { + on_detach = function(b) lens_cache_by_buf[b] = nil end, + on_lines = function(_, b, _, first_lnum, last_lnum) + api.nvim_buf_clear_namespace(b, ns, first_lnum, last_lnum) + end + }) + end + lenses_by_client[client_id] = lenses +end + + +--@private +local function resolve_lenses(lenses, bufnr, client_id, callback) + lenses = lenses or {} + local num_lens = vim.tbl_count(lenses) + if num_lens == 0 then + callback() + return + end + + --@private + local function countdown() + num_lens = num_lens - 1 + if num_lens == 0 then + callback() + end + end + local ns = namespaces[client_id] + local client = vim.lsp.get_client_by_id(client_id) + for _, lens in pairs(lenses or {}) do + if lens.command then + countdown() + else + 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( + bufnr, + ns, + lens.range.start.line, + {{ lens.command.title, 'LspCodeLens' },}, + {} + ) + end + countdown() + end, bufnr) + end + end +end + + +--- |lsp-handler| for the method `textDocument/codeLens` +--- +function M.on_codelens(err, _, result, client_id, bufnr) + assert(not err, vim.inspect(err)) + + M.save(result, bufnr, 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 + end) +end + + +--- Refresh the codelens for the current buffer +--- +--- It is recommended to trigger this using an autocmd or via keymap. +--- +--- <pre> +--- autocmd BufEnter,CursorHold,InsertLeave <buffer> lua vim.lsp.codelens.refresh() +--- </pre> +--- +function M.refresh() + local params = { + textDocument = util.make_text_document_params() + } + local bufnr = api.nvim_get_current_buf() + if active_refreshes[bufnr] then + return + end + active_refreshes[bufnr] = true + vim.lsp.buf_request(0, 'textDocument/codeLens', params) +end + + +return M diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 6f2f846a3b..d4580885db 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -271,8 +271,12 @@ local function set_diagnostic_cache(diagnostics, bufnr, client_id) end -- Account for servers that place diagnostics on terminating newline if buf_line_count > 0 then - local start = diagnostic.range.start - start.line = math.min(start.line, buf_line_count - 1) + 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) end end @@ -317,9 +321,9 @@ function M.save(diagnostics, bufnr, client_id) -- Clean up our data when the buffer unloads. api.nvim_buf_attach(bufnr, false, { - on_detach = function(b) + on_detach = function(_, b) clear_diagnostic_cache(b, client_id) - _diagnostic_cleanup[bufnr][client_id] = nil + _diagnostic_cleanup[b][client_id] = nil end }) end @@ -330,15 +334,19 @@ end -- Diagnostic Retrieval {{{ ---- Get all diagnostics for all clients +--- Get all diagnostics for clients --- ----@return {bufnr: Diagnostic[]} -function M.get_all() +---@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 _, client_diagnostics in pairs(buf_diagnostics) do - vim.list_extend(diagnostics_by_bufnr[bufnr], client_diagnostics) + 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 end return diagnostics_by_bufnr @@ -1151,6 +1159,7 @@ function M.show_line_diagnostics(opts, bufnr, line_nr, client_id) end 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) @@ -1161,13 +1170,6 @@ function M.show_line_diagnostics(opts, bufnr, line_nr, client_id) return popup_bufnr, winnr end -local loclist_type_map = { - [DiagnosticSeverity.Error] = 'E', - [DiagnosticSeverity.Warning] = 'W', - [DiagnosticSeverity.Information] = 'I', - [DiagnosticSeverity.Hint] = 'I', -} - --- Clear diagnotics and diagnostic cache --- @@ -1196,44 +1198,29 @@ end --- - 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 false) +--- - Set the list with workspace diagnostics function M.set_loclist(opts) opts = opts or {} - local open_loclist = if_nil(opts.open_loclist, true) - - local bufnr = vim.api.nvim_get_current_buf() - local buffer_diags = M.get(bufnr, opts.client_id) - - if opts.severity then - buffer_diags = filter_to_severity_limit(opts.severity, buffer_diags) - elseif opts.severity_limit then - buffer_diags = filter_by_severity_limit(opts.severity_limit, buffer_diags) - end - - local items = {} - local insert_diag = function(diag) - local pos = diag.range.start - local row = pos.line - local col = util.character_offset(bufnr, row, pos.character) - - local line = (api.nvim_buf_get_lines(bufnr, row, row + 1, false) or {""})[1] - - table.insert(items, { - bufnr = bufnr, - lnum = row + 1, - col = col + 1, - text = line .. " | " .. diag.message, - type = loclist_type_map[diag.severity or DiagnosticSeverity.Error] or 'E', - }) - end - - for _, diag in ipairs(buffer_diags) do - insert_diag(diag) + 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 + severity = to_severity(opts.severity_limit) + if severity then + return d.severity == severity + end + return true end - - table.sort(items, function(a, b) return a.lnum < b.lnum end) - - util.set_loclist(items) + 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]] end diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua index 525ec4ce5b..5d38150fc0 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -187,6 +187,10 @@ M['textDocument/publishDiagnostics'] = function(...) return require('vim.lsp.diagnostic').on_publish_diagnostics(...) end +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 @@ -260,24 +264,18 @@ end --- - See |vim.api.nvim_open_win()| function M.hover(_, method, result, _, _, config) config = config or {} - local bufnr, winnr = util.focusable_float(method, function() - if not (result and result.contents) then - -- return { 'No information available' } - return - end - local markdown_lines = util.convert_input_to_markdown_lines(result.contents) - markdown_lines = util.trim_empty_lines(markdown_lines) - if vim.tbl_isempty(markdown_lines) then - -- return { 'No information available' } - return - end - local bufnr, winnr = util.fancy_floating_markdown(markdown_lines, { - border = config.border - }) - util.close_preview_autocmd({"CursorMoved", "BufHidden", "InsertCharPre"}, winnr) - return bufnr, winnr - end) - return bufnr, winnr + config.focus_id = method + if not (result and result.contents) then + -- return { 'No information available' } + return + end + local markdown_lines = util.convert_input_to_markdown_lines(result.contents) + markdown_lines = util.trim_empty_lines(markdown_lines) + if vim.tbl_isempty(markdown_lines) then + -- return { 'No information available' } + return + end + return util.open_floating_preview(markdown_lines, "markdown", config) end --@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_hover @@ -335,23 +333,21 @@ M['textDocument/implementation'] = location_handler --- - See |vim.api.nvim_open_win()| function M.signature_help(_, method, result, _, bufnr, config) config = config or {} + config.focus_id = 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') return end - local lines = util.convert_signature_help_to_markdown_lines(result) + local ft = api.nvim_buf_get_option(bufnr, 'filetype') + local lines = util.convert_signature_help_to_markdown_lines(result, ft) lines = util.trim_empty_lines(lines) if vim.tbl_isempty(lines) then print('No signature help available') return end - local syntax = api.nvim_buf_get_option(bufnr, 'syntax') - local p_bufnr, _ = util.focusable_preview(method, function() - return lines, util.try_trim_markdown_code_blocks(lines), config - end) - api.nvim_buf_set_option(p_bufnr, 'syntax', syntax) + return util.open_floating_preview(lines, "markdown", config) end --@see https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_signatureHelp diff --git a/runtime/lua/vim/lsp/log.lua b/runtime/lua/vim/lsp/log.lua index 331e980e67..471a311c16 100644 --- a/runtime/lua/vim/lsp/log.lua +++ b/runtime/lua/vim/lsp/log.lua @@ -10,7 +10,7 @@ local log = {} -- Can be used to lookup the number from the name or the name from the number. -- Levels by name: 'trace', 'debug', 'info', 'warn', 'error' -- Level numbers begin with 'trace' at 0 -log.levels = vim.log.levels +log.levels = vim.deepcopy(vim.log.levels) -- Default log level is warn. local current_log_level = log.levels.WARN diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index 1aa8326514..98835d6708 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -518,32 +518,38 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) send_response(decoded.id, err, result) end) -- This works because we are expecting vim.NIL here - elseif decoded.id and (decoded.result or decoded.error) then + elseif decoded.id and (decoded.result ~= vim.NIL or decoded.error ~= vim.NIL) then -- Server Result decoded.error = convert_NIL(decoded.error) decoded.result = convert_NIL(decoded.result) + -- We sent a number, so we expect a number. + local result_id = tonumber(decoded.id) + -- Do not surface RequestCancelled or ContentModified to users, it is RPC-internal. if decoded.error then + local mute_error = false if decoded.error.code == protocol.ErrorCodes.RequestCancelled then local _ = log.debug() and log.debug("Received cancellation ack", decoded) + mute_error = true elseif decoded.error.code == protocol.ErrorCodes.ContentModified then local _ = log.debug() and log.debug("Received content modified ack", decoded) + mute_error = true end - local result_id = tonumber(decoded.id) - -- Clear any callback since this is cancelled now. - -- This is safe to do assuming that these conditions hold: - -- - The server will not send a result callback after this cancellation. - -- - If the server sent this cancellation ACK after sending the result, the user of this RPC - -- client will ignore the result themselves. - if result_id then - message_callbacks[result_id] = nil + + if mute_error then + -- Clear any callback since this is cancelled now. + -- This is safe to do assuming that these conditions hold: + -- - The server will not send a result callback after this cancellation. + -- - If the server sent this cancellation ACK after sending the result, the user of this RPC + -- client will ignore the result themselves. + if result_id then + message_callbacks[result_id] = nil + end + return end - return end - -- We sent a number, so we expect a number. - local result_id = tonumber(decoded.id) local callback = message_callbacks[result_id] if callback then message_callbacks[result_id] = nil diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 92ec447b55..326005dac4 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -4,6 +4,7 @@ local validate = vim.validate local api = vim.api local list_extend = vim.list_extend local highlight = require 'vim.highlight' +local uv = vim.loop local npcall = vim.F.npcall local split = vim.split @@ -29,6 +30,16 @@ 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. @@ -39,14 +50,37 @@ local function get_border_size(opts) local width = 0 if type(border) == 'string' then - -- 'single', 'double', etc. - height = 2 - width = 2 + 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)) + end + height, width = unpack(border_size[border]) else - height = height + vim.fn.strdisplaywidth(border[2][1]) -- top - height = height + vim.fn.strdisplaywidth(border[6][1]) -- bottom - width = width + vim.fn.strdisplaywidth(border[4][1]) -- right - width = width + vim.fn.strdisplaywidth(border[8][1]) -- left + local function border_width(id) + if type(border[id]) == "table" then + -- border specified as a table of <character, highlight group> + return vim.fn.strdisplaywidth(border[id][1]) + elseif type(border[id]) == "string" then + -- 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)) + end + local function border_height(id) + if type(border[id]) == "table" then + -- border specified as a table of <character, highlight group> + return #border[id][1] > 0 and 1 or 0 + elseif type(border[id]) == "string" then + -- 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)) + end + height = height + border_height(2) -- top + height = height + border_height(6) -- bottom + width = width + border_width(4) -- right + width = width + border_width(8) -- left end return { height = height, width = width } @@ -73,7 +107,7 @@ function M.set_lines(lines, A, B, new_lines) -- way the LSP describes the range including the last newline is by -- specifying a line number after what we would call the last line. local i_n = math.min(B[1] + 1, #lines) - if not (i_0 >= 1 and i_0 <= #lines and i_n >= 1 and i_n <= #lines) then + if not (i_0 >= 1 and i_0 <= #lines + 1 and i_n >= 1 and i_n <= #lines) then error("Invalid range: "..vim.inspect{A = A; B = B; #lines, new_lines}) end local prefix = "" @@ -566,13 +600,15 @@ end --@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 - if protocol.InsertTextFormat[item.insertTextFormat] == "PlainText" then + local insert_text_format = protocol.InsertTextFormat[item.insertTextFormat] + if insert_text_format == "PlainText" or insert_text_format == nil then return item.textEdit.newText else return M.parse_snippet(item.textEdit.newText) end elseif item.insertText ~= nil and item.insertText ~= "" then - if protocol.InsertTextFormat[item.insertTextFormat] == "PlainText" then + local insert_text_format = protocol.InsertTextFormat[item.insertTextFormat] + if insert_text_format == "PlainText" or insert_text_format == nil then return item.insertText else return M.parse_snippet(item.insertText) @@ -770,14 +806,20 @@ function M.convert_input_to_markdown_lines(input, contents) assert(type(input) == 'table', "Expected a table for Hover.contents") -- MarkupContent if input.kind then - -- The kind can be either plaintext or markdown. However, either way we - -- will just be rendering markdown, so we handle them both the same way. - -- TODO these can have escaped/sanitized html codes in markdown. We - -- should make sure we handle this correctly. + -- The kind can be either plaintext or markdown. + -- 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 '' + + 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 "") + end + -- assert(type(input.value) == 'string') - list_extend(contents, split_lines(input.value or '')) + list_extend(contents, split_lines(input.value)) -- MarkupString variation 2 elseif input.language then -- Some servers send input.value as empty, so let's ignore this :( @@ -802,9 +844,10 @@ 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) +function M.convert_signature_help_to_markdown_lines(signature_help, ft) if not signature_help.signatures then return end @@ -822,7 +865,12 @@ function M.convert_signature_help_to_markdown_lines(signature_help) if not signature then return end - vim.list_extend(contents, vim.split(signature.label, '\n', true)) + local label = signature.label + if ft then + -- wrap inside a code block so fancy_markdown can render it properly + label = ("```%s\n%s\n```"):format(ft, label) + end + vim.list_extend(contents, vim.split(label, '\n', true)) if signature.documentation then M.convert_input_to_markdown_lines(signature.documentation, contents) end @@ -906,6 +954,7 @@ function M.make_floating_popup_options(width, height, opts) anchor = anchor, col = col + (opts.offset_x or 0), height = height, + focusable = opts.focusable, relative = 'cursor', row = row + (opts.offset_y or 0), style = 'minimal', @@ -914,23 +963,6 @@ function M.make_floating_popup_options(width, height, opts) } end -local function _should_add_to_tagstack(new_item) - local stack = vim.fn.gettagstack() - - -- Check if we're at the bottom of the tagstack. - if stack.curidx <= 1 then return true end - - local top_item = stack.items[stack.curidx-1] - - -- Check if the item at the top of the tagstack is exactly the - -- same as the one we want to push. - if top_item.tagname ~= new_item.tagname then return true end - for i, v in ipairs(top_item.from) do - if v ~= new_item.from[i] then return true end - end - return false -end - --- Jumps to a location. --- --@param location (`Location`|`LocationLink`) @@ -939,33 +971,22 @@ function M.jump_to_location(location) -- location may be Location or LocationLink local uri = location.uri or location.targetUri if uri == nil then return end + local bufnr = vim.uri_to_bufnr(uri) + -- Save position in jumplist + vim.cmd "normal! m'" - local from_bufnr = vim.fn.bufnr('%') - local from = {from_bufnr, vim.fn.line('.'), vim.fn.col('.'), 0} - local item = {tagname=vim.fn.expand('<cword>'), from=from} + -- Push a new item into tagstack + local from = {vim.fn.bufnr('%'), vim.fn.line('.'), vim.fn.col('.'), 0} + local items = {{tagname=vim.fn.expand('<cword>'), from=from}} + vim.fn.settagstack(vim.fn.win_getid(), {items=items}, 't') --- Jump to new location (adjusting for UTF-16 encoding of characters) - local bufnr = vim.uri_to_bufnr(uri) api.nvim_set_current_buf(bufnr) api.nvim_buf_set_option(0, 'buflisted', true) local range = location.range or location.targetSelectionRange local row = range.start.line local col = get_line_byte_from_position(0, range.start) - -- This prevents the tagstack to be filled with items that provide - -- no motion when CTRL-T is pressed because they're both the source - -- and the destination. - local motionless = - bufnr == from_bufnr and - row+1 == from[2] and col+1 == from[3] - if not motionless and _should_add_to_tagstack(item) then - local winid = vim.fn.win_getid() - local items = {item} - vim.fn.settagstack(winid, {items=items}, 't') - end - - -- Jump to new location api.nvim_win_set_cursor(0, {row + 1, col}) - return true end @@ -977,7 +998,7 @@ end --- --@param location a single `Location` or `LocationLink` --@returns (bufnr,winnr) buffer and window number of floating window or nil -function M.preview_location(location) +function M.preview_location(location, opts) -- location may be LocationLink or Location (more useful for the former) local uri = location.targetUri or location.uri if uri == nil then return end @@ -988,7 +1009,15 @@ function M.preview_location(location) local range = location.targetRange or location.range local contents = api.nvim_buf_get_lines(bufnr, range.start.line, range["end"].line+1, false) local syntax = api.nvim_buf_get_option(bufnr, 'syntax') - return M.open_floating_preview(contents, syntax) + if syntax == "" then + -- When no syntax is set, we use filetype as fallback. This might not result + -- in a valid syntax definition. See also ft detection in fancy_floating_win. + -- An empty syntax is more common now with TreeSitter, since TS disables syntax. + syntax = api.nvim_buf_get_option(bufnr, 'filetype') + end + opts = opts or {} + opts.focus_id = "location" + return M.open_floating_preview(contents, syntax, opts) end --@private @@ -1010,7 +1039,9 @@ end ---buffer id, the newly created window will be the new focus associated with ---the current buffer via the tag `unique_name`. --@returns (pbufnr, pwinnr) if `fn()` has created a new window; nil otherwise +---@deprecated please use open_floating_preview directly function M.focusable_float(unique_name, fn) + vim.notify("focusable_float is deprecated. Please use open_floating_preview and pass focus_id = [unique_name] instead", vim.log.levels.WARN) -- Go back to previous window if we are in a focusable one if npcall(api.nvim_win_get_var, 0, unique_name) then return api.nvim_command("wincmd p") @@ -1039,10 +1070,10 @@ end --@param fn (function) The return values of this function will be passed ---directly to |vim.lsp.util.open_floating_preview()|, in the case that a new ---floating window should be created +---@deprecated please use open_floating_preview directly function M.focusable_preview(unique_name, fn) - return M.focusable_float(unique_name, function() - return M.open_floating_preview(fn()) - end) + vim.notify("focusable_preview is deprecated. Please use open_floating_preview and pass focus_id = [unique_name] instead", vim.log.levels.WARN) + return M.open_floating_preview(fn(), {focus_id = unique_name}) end --- Trims empty lines from input and pad top and bottom with empty lines @@ -1074,13 +1105,20 @@ end --- TODO: refactor to separate stripping/converting and make use of open_floating_preview --- +--- @deprecated please use open_floating_preview directly +function M.fancy_floating_markdown(contents, opts) + vim.notify("fancy_floating_markdown is deprecated. Please use open_floating_preview and pass focus_id = [unique_name] instead", vim.log.levels.WARN) + return M.open_floating_preview(contents, "markdown", opts) +end + --- Converts markdown into syntax highlighted regions by stripping the code --- blocks and converting them into highlighted code. --- This will by default insert a blank line separator after those code block --- regions to improve readability. ---- The result is shown in a floating preview. +--- +--- This method configures the given buffer and returns the lines to set. +--- +--- If you want to open a popup with fancy markdown, use `open_floating_preview` instead --- ---@param contents table of lines to show in window ---@param opts dictionary with optional fields @@ -1095,29 +1133,57 @@ end --- - pad_bottom number of lines to pad contents at bottom --- - separator insert separator after code block ---@returns width,height size of float -function M.fancy_floating_markdown(contents, opts) +function M.stylize_markdown(bufnr, contents, opts) validate { contents = { contents, 't' }; opts = { opts, 't', true }; } opts = opts or {} + -- table of fence types to {ft, begin, end} + -- when ft is nil, we get the ft from the regex match + local matchers = { + block = {nil, "```+([a-zA-Z0-9_]*)", "```+"}, + pre = {"", "<pre>", "</pre>"}, + code = {"", "<code>", "</code>"}, + text = {"plaintex", "<text>", "</text>"}, + } + + local match_begin = function(line) + for type, pattern in pairs(matchers) do + local ret = line:match(string.format("^%%s*%s%%s*$", pattern[2])) + if ret then + return { + type = type, + ft = pattern[1] or ret + } + end + end + end + + local match_end = function(line, match) + local pattern = matchers[match.type] + return line:match(string.format("^%%s*%s%%s*$", pattern[3])) + end + + -- Clean up + contents = M._trim(contents, opts) + local stripped = {} local highlights = {} + -- keep track of lnums that contain markdown + local markdown_lines = {} do local i = 1 while i <= #contents do local line = contents[i] - -- TODO(ashkan): use a more strict regex for filetype? - local ft = line:match("^```([a-zA-Z0-9_]*)$") - -- local ft = line:match("^```(.*)$") - -- TODO(ashkan): validate the filetype here. - if ft then + local match = match_begin(line) + if match then local start = #stripped i = i + 1 while i <= #contents do line = contents[i] - if line == "```" then + if match_end(line, match) then i = i + 1 break end @@ -1125,76 +1191,96 @@ function M.fancy_floating_markdown(contents, opts) i = i + 1 end table.insert(highlights, { - ft = ft; + ft = match.ft; start = start + 1; finish = #stripped + 1 - 1; }) else table.insert(stripped, line) + markdown_lines[#stripped] = true i = i + 1 end end end - -- Clean up - stripped = M._trim(stripped, 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 sep_line = string.rep("─", math.min(width, opts.wrap_at or width)) + + for l in pairs(markdown_lines) do + if stripped[l]:match("^---+$") then + stripped[l] = sep_line + 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 - for i, h in ipairs(highlights) do - h.start = h.start + i - 1 - h.finish = h.finish + i - 1 + 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 - table.insert(stripped, h.finish + 1, string.rep("─", math.min(width, opts.wrap_at or width))) - height = height + 1 + 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 - -- Make the floating window. - local bufnr = api.nvim_create_buf(false, true) - local winnr = api.nvim_open_win(bufnr, false, M.make_floating_popup_options(width, height, opts)) - vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, stripped) - api.nvim_buf_set_option(bufnr, 'modifiable', false) - -- Switch to the floating window to apply the syntax highlighting. - -- This is because the syntax command doesn't accept a target. - local cwin = vim.api.nvim_get_current_win() - vim.api.nvim_set_current_win(winnr) - api.nvim_win_set_option(winnr, 'conceallevel', 2) - api.nvim_win_set_option(winnr, 'concealcursor', 'n') + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, stripped) - vim.cmd("ownsyntax lsp_markdown") local idx = 1 --@private + -- keep track of syntaxes we already inlcuded. + -- no need to include the same syntax more than once + local langs = {} local function apply_syntax_to_region(ft, start, finish) - if ft == '' then return end + if ft == "" then + vim.cmd(string.format("syntax region markdownCode start=+\\%%%dl+ end=+\\%%%dl+ keepend extend", start, finish + 1)) + return + end local name = ft..idx idx = idx + 1 local lang = "@"..ft:upper() - -- TODO(ashkan): better validation before this. - if not pcall(vim.cmd, string.format("syntax include %s syntax/%s.vim", lang, ft)) then - return + if not langs[lang] then + -- HACK: reset current_syntax, since some syntax files like markdown won't load if it is already set + pcall(vim.api.nvim_buf_del_var, bufnr, "current_syntax") + -- TODO(ashkan): better validation before this. + if not pcall(vim.cmd, string.format("syntax include %s syntax/%s.vim", lang, ft)) then + return + end + langs[lang] = true end - vim.cmd(string.format("syntax region %s start=+\\%%%dl+ end=+\\%%%dl+ contains=%s", name, start, finish + 1, lang)) - end - -- Previous highlight region. - -- TODO(ashkan): this wasn't working for some reason, but I would like to - -- make sure that regions between code blocks are definitely markdown. - -- local ph = {start = 0; finish = 1;} - for _, h in ipairs(highlights) do - -- apply_syntax_to_region('markdown', ph.finish, h.start) - apply_syntax_to_region(h.ft, h.start, h.finish) - -- ph = h + vim.cmd(string.format("syntax region %s start=+\\%%%dl+ end=+\\%%%dl+ contains=%s keepend", name, start, finish + 1, lang)) end - vim.api.nvim_set_current_win(cwin) - return bufnr, winnr + -- needs to run in the buffer for the regions to work + api.nvim_buf_call(bufnr, function() + -- we need to apply lsp_markdown regions speperately, since otherwise + -- markdown regions can "bleed" through the other syntax regions + -- and mess up the formatting + local last = 1 + for _, h in ipairs(highlights) do + if last < h.start then + apply_syntax_to_region("lsp_markdown", last, h.start - 1) + end + apply_syntax_to_region(h.ft, h.start, h.finish) + last = h.finish + 1 + end + if last < #stripped then + apply_syntax_to_region("lsp_markdown", last, #stripped) + end + end) + + return stripped end --- Creates autocommands to close a preview window when events happen. @@ -1203,7 +1289,9 @@ end --@param winnr (number) window id of preview window --@see |autocmd-events| function M.close_preview_autocmd(events, winnr) - api.nvim_command("autocmd "..table.concat(events, ',').." <buffer> ++once lua pcall(vim.api.nvim_win_close, "..winnr..", true)") + 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 @@ -1286,15 +1374,19 @@ end --@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_at character to wrap at for computing height --- - max_width maximal width of floating window --- - max_height maximal height of floating window --- - pad_left number of columns to pad contents at left --- - pad_right number of columns to pad contents at right --- - pad_top number of lines to pad contents at top --- - pad_bottom number of lines to pad contents at bottom +--- - height of floating window +--- - width of floating window +--- - wrap boolean enable wrapping of long lines (defaults to true) +--- - wrap_at character to wrap at for computing height when wrap is enabled +--- - max_width maximal width of floating window +--- - max_height maximal height of floating window +--- - pad_left number of columns to pad contents at left +--- - pad_right number of columns to pad contents at right +--- - pad_top number of lines to pad contents at top +--- - pad_bottom number of lines to pad contents at bottom +--- - 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 ---preview window function M.open_floating_preview(contents, syntax, opts) @@ -1304,27 +1396,85 @@ function M.open_floating_preview(contents, syntax, opts) opts = { opts, 't', true }; } opts = opts or {} + opts.wrap = opts.wrap ~= false -- wrapping by default + opts.stylize_markdown = opts.stylize_markdown ~= false + opts.close_events = opts.close_events or {"CursorMoved", "CursorMovedI", "BufHidden", "InsertCharPre"} + + local bufnr = api.nvim_get_current_buf() + + -- check if this popup is focusable and we need to focus + if opts.focus_id and opts.focusable ~= false then + -- Go back to previous window if we are in a focusable one + local current_winnr = api.nvim_get_current_win() + if npcall(api.nvim_win_get_var, current_winnr, opts.focus_id) then + api.nvim_command("wincmd p") + return bufnr, current_winnr + end + do + local win = find_window_by_var(opts.focus_id, bufnr) + if win and api.nvim_win_is_valid(win) and vim.fn.pumvisible() == 0 then + -- focus and return the existing buf, win + api.nvim_set_current_win(win) + api.nvim_command("stopinsert") + return api.nvim_win_get_buf(win), win + end + end + end + + -- check if another floating preview already exists for this buffer + -- and close it if needed + local existing_float = npcall(api.nvim_buf_get_var, bufnr, "lsp_floating_preview") + if existing_float and api.nvim_win_is_valid(existing_float) then + api.nvim_win_close(existing_float, true) + end + + local floating_bufnr = api.nvim_create_buf(false, true) + local do_stylize = syntax == "markdown" and opts.stylize_markdown + -- Clean up input: trim empty lines from the end, pad contents = M._trim(contents, opts) + if do_stylize then + -- applies the syntax and sets the lines to the buffer + contents = M.stylize_markdown(floating_bufnr, contents, opts) + else + if syntax then + api.nvim_buf_set_option(floating_bufnr, 'syntax', syntax) + end + api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents) + end + -- 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)) + if opts.wrap then + opts.wrap_at = opts.wrap_at or api.nvim_win_get_width(0) + else + opts.wrap_at = nil + end local width, height = M._make_floating_popup_size(contents, opts) - local floating_bufnr = api.nvim_create_buf(false, true) - if syntax then - api.nvim_buf_set_option(floating_bufnr, 'syntax', syntax) - end local float_option = M.make_floating_popup_options(width, height, opts) local floating_winnr = api.nvim_open_win(floating_bufnr, false, float_option) - if syntax == 'markdown' then + if do_stylize then api.nvim_win_set_option(floating_winnr, 'conceallevel', 2) + api.nvim_win_set_option(floating_winnr, 'concealcursor', 'n') end - api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents) + -- disable folding + api.nvim_win_set_option(floating_winnr, 'foldenable', false) + -- soft wrapping + api.nvim_win_set_option(floating_winnr, 'wrap', opts.wrap) + api.nvim_buf_set_option(floating_bufnr, 'modifiable', false) api.nvim_buf_set_option(floating_bufnr, 'bufhidden', 'wipe') - M.close_preview_autocmd({"CursorMoved", "CursorMovedI", "BufHidden", "BufLeave"}, floating_winnr) + api.nvim_buf_set_keymap(floating_bufnr, "n", "q", "<cmd>bdelete<cr>", {silent = true, noremap = true}) + M.close_preview_autocmd(opts.close_events, floating_winnr) + + -- save focus_id + if opts.focus_id then + api.nvim_win_set_var(floating_winnr, opts.focus_id, bufnr) + end + api.nvim_buf_set_var(bufnr, "lsp_floating_preview", floating_winnr) + return floating_bufnr, floating_winnr end @@ -1363,6 +1513,88 @@ 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. +-- 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. +-- 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 } + + local function buf_lines(bufnr) + local lines = {} + for _, row in pairs(rows) do + lines[row] = (vim.api.nvim_buf_get_lines(bufnr, row, row + 1, false) or { "" })[1] + end + return lines + end + + -- load the buffer if this is not a file uri + -- Custom language server protocol extensions can result in servers sending URIs with custom schemes. Plugins are able to load these via `BufReadCmd` autocmds. + if uri:sub(1, 4) ~= "file" then + local bufnr = vim.uri_to_bufnr(uri) + vim.fn.bufload(bufnr) + return buf_lines(bufnr) + end + + local filename = vim.uri_to_fname(uri) + + -- use loaded buffers if available + if vim.fn.bufloaded(filename) == 1 then + local bufnr = vim.fn.bufnr(filename, false) + return buf_lines(bufnr) + end + + -- get the data from the file + local fd = uv.fs_open(filename, "r", 438) + if not fd then return "" end + local stat = uv.fs_fstat(fd) + local data = uv.fs_read(fd, stat.size, 0) + uv.fs_close(fd) + + local lines = {} -- rows we need to retrieve + local need = 0 -- keep track of how many unique rows we need + for _, row in pairs(rows) do + if not lines[row] then + need = need + 1 + end + lines[row] = true + end + + local found = 0 + local lnum = 0 + + for line in string.gmatch(data, "([^\n]*)\n?") do + if lines[lnum] == true then + lines[lnum] = line + found = found + 1 + if found == need then break end + end + lnum = lnum + 1 + end + + -- change any lines we didn't find to the empty string + for i, line in pairs(lines) do + if line == true then + lines[i] = "" + end + end + return lines +end + --- Returns the items with the byte position calculated correctly and in sorted --- order, for display in quickfix and location lists. --- @@ -1391,14 +1623,24 @@ function M.locations_to_items(locations) for _, uri in ipairs(keys) do local rows = grouped[uri] table.sort(rows, position_sort) - local bufnr = vim.uri_to_bufnr(uri) - vim.fn.bufload(bufnr) local filename = vim.uri_to_fname(uri) + + -- list of row numbers + local uri_rows = {} for _, temp in ipairs(rows) do local pos = temp.start local row = pos.line - local line = (api.nvim_buf_get_lines(bufnr, row, row + 1, false) or {""})[1] - local col = M.character_offset(bufnr, row, pos.character) + table.insert(uri_rows, row) + end + + -- get all the lines for this uri + local lines = M.get_lines(uri, uri_rows) + + for _, temp in ipairs(rows) do + local pos = temp.start + local row = pos.line + local line = lines[row] or "" + local col = pos.character table.insert(items, { filename = filename, lnum = row + 1, @@ -1410,12 +1652,13 @@ function M.locations_to_items(locations) return items end ---- Fills current window's location list with given list of items. +--- Fills target window's location list with given list of items. --- Can be obtained with e.g. |vim.lsp.util.locations_to_items()|. +--- Defaults to current window. --- --@param items (table) list of items -function M.set_loclist(items) - vim.fn.setloclist(0, {}, ' ', { +function M.set_loclist(items, win_id) + vim.fn.setloclist(win_id or 0, {}, ' ', { title = 'Language Server'; items = items; }) @@ -1456,13 +1699,13 @@ function M.symbols_to_items(symbols, bufnr) kind = kind, text = '['..kind..'] '..symbol.name, }) - elseif symbol.range then -- DocumentSymbole type + elseif symbol.selectionRange then -- DocumentSymbole type local kind = M._get_symbol_kind_name(symbol.kind) table.insert(_items, { -- bufnr = _bufnr, filename = vim.api.nvim_buf_get_name(_bufnr), - lnum = symbol.range.start.line + 1, - col = symbol.range.start.character + 1, + lnum = symbol.selectionRange.start.line + 1, + col = symbol.selectionRange.start.character + 1, kind = kind, text = '['..kind..'] '..symbol.name }) @@ -1592,6 +1835,12 @@ function M.make_given_range_params(start_pos, end_pos) if B[2] > 0 then B = {B[1], M.character_offset(0, B[1], B[2])} end + -- we need to offset the end character position otherwise we loose the last + -- character of the selection, as LSP end position is exclusive + -- see https://microsoft.github.io/language-server-protocol/specification#range + if vim.o.selection ~= 'exclusive' then + B[2] = B[2] + 1 + end return { textDocument = M.make_text_document_params(), range = { @@ -1650,8 +1899,9 @@ end --@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(buf, row, col) - local line = api.nvim_buf_get_lines(buf, row, row+1, true)[1] +function M.character_offset(bufnr, row, col) + local uri = vim.uri_from_bufnr(bufnr) + local line = M.get_line(uri, row) -- If the col is past the EOL, use the line length. if col > #line then return str_utfindex(line) @@ -1674,6 +1924,40 @@ 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 |