diff options
Diffstat (limited to 'runtime/lua')
-rw-r--r-- | runtime/lua/vim/lsp.lua | 213 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/buf.lua | 136 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/builtin_callbacks.lua | 296 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/callbacks.lua | 223 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/protocol.lua | 14 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 1 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 460 | ||||
-rw-r--r-- | runtime/lua/vim/shared.lua | 48 | ||||
-rw-r--r-- | runtime/lua/vim/uri.lua | 7 |
9 files changed, 776 insertions, 622 deletions
diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 9dbe03dace..82f4fda66f 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -1,9 +1,10 @@ -local builtin_callbacks = require 'vim.lsp.builtin_callbacks' +local default_callbacks = require 'vim.lsp.callbacks' local log = require 'vim.lsp.log' local lsp_rpc = require 'vim.lsp.rpc' local protocol = require 'vim.lsp.protocol' local util = require 'vim.lsp.util' +local vim = vim local nvim_err_writeln, nvim_buf_get_lines, nvim_command, nvim_buf_get_option = vim.api.nvim_err_writeln, vim.api.nvim_buf_get_lines, vim.api.nvim_command, vim.api.nvim_buf_get_option local uv = vim.loop @@ -12,7 +13,8 @@ local validate = vim.validate local lsp = { protocol = protocol; - builtin_callbacks = builtin_callbacks; + callbacks = default_callbacks; + buf = require'vim.lsp.buf'; util = util; -- Allow raw RPC access. rpc = lsp_rpc; @@ -23,9 +25,13 @@ local lsp = { -- format_rpc_error = lsp_rpc.format_rpc_error; } --- TODO consider whether 'eol' or 'fixeol' should change the nvim_buf_get_lines that send. -- TODO improve handling of scratch buffers with LSP attached. +local function err_message(...) + nvim_err_writeln(table.concat(vim.tbl_flatten{...})) + nvim_command("redraw") +end + local function resolve_bufnr(bufnr) validate { bufnr = { bufnr, 'n', true } } if bufnr == nil or bufnr == 0 then @@ -93,11 +99,7 @@ local function for_each_buffer_client(bufnr, callback) return end for client_id in pairs(client_ids) do - -- This is unlikely to happen. Could only potentially happen in a race - -- condition between literally a single statement. - -- We could skip this error, but let's error for now. local client = active_clients[client_id] - -- or error(string.format("Client %d has already shut down.", client_id)) if client then callback(client, client_id) end @@ -155,13 +157,13 @@ local function validate_client_config(config) root_dir = { config.root_dir, is_dir, "directory" }; callbacks = { config.callbacks, "t", true }; capabilities = { config.capabilities, "t", true }; - -- cmd = { config.cmd, "s", false }; cmd_cwd = { config.cmd_cwd, optional_validator(is_dir), "directory" }; cmd_env = { config.cmd_env, "f", true }; name = { config.name, 's', true }; on_error = { config.on_error, "f", true }; on_exit = { config.on_exit, "f", true }; on_init = { config.on_init, "f", true }; + before_init = { config.before_init, "f", true }; offset_encoding = { config.offset_encoding, "s", true }; } local cmd, cmd_args = validate_command(config.cmd) @@ -175,6 +177,14 @@ local function validate_client_config(config) } end +local function buf_get_full_text(bufnr) + local text = table.concat(nvim_buf_get_lines(bufnr, 0, -1, true), '\n') + if nvim_buf_get_option(bufnr, 'eol') then + text = text .. '\n' + end + return text +end + local function text_document_did_open_handler(bufnr, client) if not client.resolved_capabilities.text_document_open_close then return @@ -188,7 +198,7 @@ local function text_document_did_open_handler(bufnr, client) uri = vim.uri_from_bufnr(bufnr); -- TODO make sure our filetypes are compatible with languageId names. languageId = nvim_buf_get_option(bufnr, 'filetype'); - text = table.concat(nvim_buf_get_lines(bufnr, 0, -1, false), '\n'); + text = buf_get_full_text(bufnr); } } client.notify('textDocument/didOpen', params) @@ -254,6 +264,12 @@ end -- possible errors. `vim.lsp.client_errors[code]` can be used to retrieve a -- human understandable string. -- +-- before_init(initialize_params, config): A function which is called *before* +-- the request `initialize` is completed. `initialize_params` contains +-- the parameters we are sending to the server and `config` is the config that +-- was passed to `start_client()` for convenience. You can use this to modify +-- parameters before they are sent. +-- -- on_init(client, initialize_result): A function which is called after the -- request `initialize` is completed. `initialize_result` contains -- `capabilities` and anything else the server may send. For example, `clangd` @@ -283,19 +299,19 @@ function lsp.start_client(config) local client_id = next_client_id() - local callbacks = tbl_extend("keep", config.callbacks or {}, builtin_callbacks) - -- Copy metatable if it has one. - if config.callbacks and config.callbacks.__metatable then - setmetatable(callbacks, getmetatable(config.callbacks)) - end + local callbacks = config.callbacks or {} local name = config.name or tostring(client_id) local log_prefix = string.format("LSP[%s]", name) local handlers = {} + local function resolve_callback(method) + return callbacks[method] or default_callbacks[method] + end + function handlers.notification(method, params) local _ = log.debug() and log.debug('notification', method, params) - local callback = callbacks[method] + local callback = resolve_callback(method) if callback then -- Method name is provided here for convenience. callback(nil, method, params, client_id) @@ -304,7 +320,7 @@ function lsp.start_client(config) function handlers.server_request(method, params) local _ = log.debug() and log.debug('server_request', method, params) - local callback = callbacks[method] + local callback = resolve_callback(method) if callback then local _ = log.debug() and log.debug("server_request: found callback for", method) return callback(nil, method, params, client_id) @@ -315,12 +331,12 @@ function lsp.start_client(config) function handlers.on_error(code, err) local _ = log.error() and log.error(log_prefix, "on_error", { code = lsp.client_errors[code], err = err }) - nvim_err_writeln(string.format('%s: Error %s: %q', log_prefix, lsp.client_errors[code], vim.inspect(err))) + err_message(log_prefix, ': Error ', lsp.client_errors[code], ': ', vim.inspect(err)) if config.on_error then local status, usererr = pcall(config.on_error, code, err) if not status then local _ = log.error() and log.error(log_prefix, "user on_error failed", { err = usererr }) - nvim_err_writeln(log_prefix.." user on_error failed: "..tostring(usererr)) + err_message(log_prefix, ' user on_error failed: ', tostring(usererr)) end end end @@ -328,9 +344,19 @@ function lsp.start_client(config) function handlers.on_exit(code, signal) active_clients[client_id] = nil uninitialized_clients[client_id] = nil - for _, client_ids in pairs(all_buffer_active_clients) do + local active_buffers = {} + for bufnr, client_ids in pairs(all_buffer_active_clients) do + if client_ids[client_id] then + table.insert(active_buffers, bufnr) + end client_ids[client_id] = nil end + -- Buffer level cleanup + vim.schedule(function() + for _, bufnr in ipairs(active_buffers) do + util.buf_clear_diagnostics(bufnr) + end + end) if config.on_exit then pcall(config.on_exit, code, signal, client_id) end @@ -372,7 +398,6 @@ function lsp.start_client(config) -- The rootUri of the workspace. Is null if no folder is open. If both -- `rootPath` and `rootUri` are set `rootUri` wins. rootUri = vim.uri_from_fname(config.root_dir); --- rootUri = vim.uri_from_fname(vim.fn.expand("%:p:h")); -- User provided initialization options. initializationOptions = config.init_options; -- The capabilities provided by the client (editor or tool) @@ -396,11 +421,15 @@ function lsp.start_client(config) -- } workspaceFolders = nil; } + if config.before_init then + -- TODO(ashkan) handle errors here. + pcall(config.before_init, initialize_params, config) + end local _ = log.debug() and log.debug(log_prefix, "initialize_params", initialize_params) rpc.request('initialize', initialize_params, function(init_err, result) assert(not init_err, tostring(init_err)) assert(result, "server sent empty result") - rpc.notify('initialized', {}) + rpc.notify('initialized', {[vim.type_idx]=vim.types.dictionary}) client.initialized = true uninitialized_clients[client_id] = nil client.server_capabilities = assert(result.capabilities, "initialize result doesn't contain capabilities") @@ -432,15 +461,15 @@ function lsp.start_client(config) local function unsupported_method(method) local msg = "server doesn't support "..method local _ = log.warn() and log.warn(msg) - nvim_err_writeln(msg) + err_message(msg) return lsp.rpc_response_error(protocol.ErrorCodes.MethodNotFound, msg) end --- Checks capabilities before rpc.request-ing. function client.request(method, params, callback) if not callback then - callback = client.callbacks[method] - or error(string.format("request callback is empty and no default was found for client %s", client.name)) + callback = resolve_callback(method) + or error("not found: request callback for client "..client.name) end local _ = log.debug() and log.debug(log_prefix, "client.request", client_id, method, params, callback) -- TODO keep these checks or just let it go anyway? @@ -551,7 +580,7 @@ do end) local full_changes = once(function() return { - text = table.concat(nvim_buf_get_lines(bufnr, 0, -1, false), "\n"); + text = buf_get_full_text(bufnr); }; end) local uri = vim.uri_from_bufnr(bufnr) @@ -844,7 +873,7 @@ function lsp.omnifunc(findstart, base) position = { -- 0-indexed for both line and character line = pos[1] - 1, - character = pos[2], + character = vim.str_utfindex(line, pos[2]), }; -- The completion context. This is only available if the client specifies -- to send this using `ClientCapabilities.textDocument.completion.contextSupport === true` @@ -869,134 +898,8 @@ function lsp.omnifunc(findstart, base) end end ---- ---- FileType based configuration utility ---- - -local all_filetype_configs = {} - --- Lookup a filetype config client by its name. -function lsp.get_filetype_client_by_name(name) - local config = all_filetype_configs[name] - if config.client_id then - return active_clients[config.client_id] - end -end - -local function start_filetype_config(config) - config.client_id = lsp.start_client(config) - nvim_command(string.format( - "autocmd FileType %s silent lua vim.lsp.buf_attach_client(0, %d)", - table.concat(config.filetypes, ','), - config.client_id)) - return config.client_id -end - --- Easy configuration option for common LSP use-cases. --- This will lazy initialize the client when the filetypes specified are --- encountered and attach to those buffers. --- --- The configuration options are the same as |vim.lsp.start_client()|, but --- with a few additions and distinctions: --- --- Additional parameters: --- - filetype: {string} or {list} of filetypes to attach to. --- - name: A unique string among all other servers configured with --- |vim.lsp.add_filetype_config|. --- --- Differences: --- - root_dir: will default to |getcwd()| --- -function lsp.add_filetype_config(config) - -- Additional defaults. - -- Keep a copy of the user's input for debugging reasons. - local user_config = config - config = tbl_extend("force", {}, user_config) - config.root_dir = config.root_dir or uv.cwd() - -- Validate config. - validate_client_config(config) - validate { - name = { config.name, 's' }; - } - assert(config.filetype, "config must have 'filetype' key") - - local filetypes - if type(config.filetype) == 'string' then - filetypes = { config.filetype } - elseif type(config.filetype) == 'table' then - filetypes = config.filetype - assert(not tbl_isempty(filetypes), "config.filetype must not be an empty table") - else - error("config.filetype must be a string or a list of strings") - end - - if all_filetype_configs[config.name] then - -- If the client exists, then it is likely that they are doing some kind of - -- reload flow, so let's not throw an error here. - if all_filetype_configs[config.name].client_id then - -- TODO log here? It might be unnecessarily annoying. - return - end - error(string.format('A configuration with the name %q already exists. They must be unique', config.name)) - end - - all_filetype_configs[config.name] = tbl_extend("keep", config, { - client_id = nil; - filetypes = filetypes; - user_config = user_config; - }) - - nvim_command(string.format( - "autocmd FileType %s ++once silent lua vim.lsp._start_filetype_config_client(%q)", - table.concat(filetypes, ','), - config.name)) -end - --- Create a copy of an existing configuration, and override config with values --- from new_config. --- This is useful if you wish you create multiple LSPs with different root_dirs --- or other use cases. --- --- You can specify a new unique name, but if you do not, a unique name will be --- created like `name-dup_count`. --- --- existing_name: the name of the existing config to copy. --- new_config: the new configuration options. @see |vim.lsp.start_client()|. --- @returns string the new name. -function lsp.copy_filetype_config(existing_name, new_config) - local config = all_filetype_configs[existing_name] - or error(string.format("Configuration with name %q doesn't exist", existing_name)) - config = tbl_extend("force", config, new_config or {}) - config.client_id = nil - config.original_config_name = existing_name - - -- If the user didn't rename it, we will. - if config.name == existing_name then - -- Create a new, unique name. - local duplicate_count = 0 - for _, conf in pairs(all_filetype_configs) do - if conf.original_config_name == existing_name then - duplicate_count = duplicate_count + 1 - end - end - config.name = string.format("%s-%d", existing_name, duplicate_count + 1) - end - print("New config name:", config.name) - lsp.add_filetype_config(config) - return config.name -end - --- Autocmd handler to actually start the client when an applicable filetype is --- encountered. -function lsp._start_filetype_config_client(name) - local config = all_filetype_configs[name] - -- If it exists and is running, don't make it again. - if config.client_id and active_clients[config.client_id] then - -- TODO log here? - return - end - lsp.buf_attach_client(0, start_filetype_config(config)) - return config.client_id +function lsp.client_is_stopped(client_id) + return active_clients[client_id] == nil end --- @@ -1023,7 +926,7 @@ end -- Print some debug information about all LSP related things. -- The output of this function should not be relied upon and may change. function lsp.print_debug_info() - print(vim.inspect({ clients = active_clients, filetype_configs = all_filetype_configs })) + print(vim.inspect({ clients = active_clients })) end -- Log level dictionary with reverse lookup as well. diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua new file mode 100644 index 0000000000..a6a05fb095 --- /dev/null +++ b/runtime/lua/vim/lsp/buf.lua @@ -0,0 +1,136 @@ +local vim = vim +local validate = vim.validate +local api = vim.api +local vfn = vim.fn +local util = require 'vim.lsp.util' +local list_extend = vim.list_extend + +local M = {} + +local function ok_or_nil(status, ...) + if not status then return end + return ... +end +local function npcall(fn, ...) + return ok_or_nil(pcall(fn, ...)) +end + +local function request(method, params, callback) + validate { + method = {method, 's'}; + callback = {callback, 'f', true}; + } + return vim.lsp.buf_request(0, method, params, callback) +end + +function M.hover() + local params = util.make_position_params() + request('textDocument/hover', params) +end + +function M.peek_definition() + local params = util.make_position_params() + request('textDocument/peekDefinition', params) +end + + +function M.declaration() + local params = util.make_position_params() + request('textDocument/declaration', params) +end + +function M.definition() + local params = util.make_position_params() + request('textDocument/definition', params) +end + +function M.type_definition() + local params = util.make_position_params() + request('textDocument/typeDefinition', params) +end + +function M.implementation() + local params = util.make_position_params() + request('textDocument/implementation', params) +end + +function M.signature_help() + local params = util.make_position_params() + request('textDocument/signatureHelp', params) +end + +-- TODO(ashkan) ? +function M.completion(context) + local params = util.make_position_params() + params.context = context + return request('textDocument/completion', params) +end + +function M.formatting(options) + validate { options = {options, 't', true} } + options = vim.tbl_extend('keep', options or {}, { + tabSize = vim.bo.tabstop; + insertSpaces = vim.bo.expandtab; + }) + local params = { + textDocument = { uri = vim.uri_from_bufnr(0) }; + options = options; + } + return request('textDocument/formatting', params) +end + +function M.range_formatting(options, start_pos, end_pos) + validate { + options = {options, 't', true}; + start_pos = {start_pos, 't', true}; + end_pos = {end_pos, 't', true}; + } + options = vim.tbl_extend('keep', options or {}, { + tabSize = vim.bo.tabstop; + insertSpaces = vim.bo.expandtab; + }) + local A = list_extend({}, start_pos or api.nvim_buf_get_mark(0, '<')) + local B = list_extend({}, end_pos or api.nvim_buf_get_mark(0, '>')) + -- convert to 0-index + A[1] = A[1] - 1 + B[1] = B[1] - 1 + -- account for encoding. + if A[2] > 0 then + A = {A[1], util.character_offset(0, A[1], A[2])} + end + if B[2] > 0 then + B = {B[1], util.character_offset(0, B[1], B[2])} + end + local params = { + textDocument = { uri = vim.uri_from_bufnr(0) }; + range = { + start = { line = A[1]; character = A[2]; }; + ["end"] = { line = B[1]; character = B[2]; }; + }; + options = options; + } + return request('textDocument/rangeFormatting', params) +end + +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: ") + if not (new_name and #new_name > 0) then return end + params.newName = new_name + request('textDocument/rename', params) +end + +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 + +return M +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/builtin_callbacks.lua b/runtime/lua/vim/lsp/builtin_callbacks.lua deleted file mode 100644 index cc739ce3ad..0000000000 --- a/runtime/lua/vim/lsp/builtin_callbacks.lua +++ /dev/null @@ -1,296 +0,0 @@ ---- Implements the following default callbacks: --- --- vim.api.nvim_buf_set_lines(0, 0, 0, false, vim.tbl_keys(vim.lsp.builtin_callbacks)) --- - --- textDocument/completion --- textDocument/declaration --- textDocument/definition --- textDocument/hover --- textDocument/implementation --- textDocument/publishDiagnostics --- textDocument/rename --- textDocument/signatureHelp --- textDocument/typeDefinition --- TODO codeLens/resolve --- TODO completionItem/resolve --- TODO documentLink/resolve --- TODO textDocument/codeAction --- TODO textDocument/codeLens --- TODO textDocument/documentHighlight --- TODO textDocument/documentLink --- TODO textDocument/documentSymbol --- TODO textDocument/formatting --- TODO textDocument/onTypeFormatting --- TODO textDocument/rangeFormatting --- TODO textDocument/references --- window/logMessage --- window/showMessage - -local log = require 'vim.lsp.log' -local protocol = require 'vim.lsp.protocol' -local util = require 'vim.lsp.util' -local api = vim.api - -local function split_lines(value) - return vim.split(value, '\n', true) -end - -local builtin_callbacks = {} - --- textDocument/completion --- https://microsoft.github.io/language-server-protocol/specification#textDocument_completion -builtin_callbacks['textDocument/completion'] = function(_, _, result) - if not result or vim.tbl_isempty(result) then - return - end - local pos = api.nvim_win_get_cursor(0) - local row, col = pos[1], pos[2] - local line = assert(api.nvim_buf_get_lines(0, row-1, row, false)[1]) - local line_to_cursor = line:sub(col+1) - - local matches = util.text_document_completion_list_to_complete_items(result, line_to_cursor) - local match_result = vim.fn.matchstrpos(line_to_cursor, '\\k\\+$') - local match_start, match_finish = match_result[2], match_result[3] - - vim.fn.complete(col + 1 - (match_finish - match_start), matches) -end - --- textDocument/rename -builtin_callbacks['textDocument/rename'] = function(_, _, result) - if not result then return end - util.workspace_apply_workspace_edit(result) -end - -local function uri_to_bufnr(uri) - return vim.fn.bufadd((vim.uri_to_fname(uri))) -end - -builtin_callbacks['textDocument/publishDiagnostics'] = function(_, _, result) - if not result then return end - local uri = result.uri - local bufnr = uri_to_bufnr(uri) - if not bufnr then - api.nvim_err_writeln(string.format("LSP.publishDiagnostics: Couldn't find buffer for %s", uri)) - return - end - util.buf_clear_diagnostics(bufnr) - util.buf_diagnostics_save_positions(bufnr, result.diagnostics) - util.buf_diagnostics_underline(bufnr, result.diagnostics) - util.buf_diagnostics_virtual_text(bufnr, result.diagnostics) - -- util.buf_loclist(bufnr, result.diagnostics) -end - --- textDocument/hover --- https://microsoft.github.io/language-server-protocol/specification#textDocument_hover --- @params MarkedString | MarkedString[] | MarkupContent -builtin_callbacks['textDocument/hover'] = function(_, _, result) - if result == nil or vim.tbl_isempty(result) then - return - end - - if result.contents ~= nil then - local markdown_lines = util.convert_input_to_markdown_lines(result.contents) - if vim.tbl_isempty(markdown_lines) then - markdown_lines = { 'No information available' } - end - util.open_floating_preview(markdown_lines, 'markdown') - end -end - -builtin_callbacks['textDocument/peekDefinition'] = function(_, _, result) - if result == nil or vim.tbl_isempty(result) then return end - -- TODO(ashkan) what to do with multiple locations? - result = result[1] - local bufnr = uri_to_bufnr(result.uri) - assert(bufnr) - local start = result.range.start - local finish = result.range["end"] - util.open_floating_peek_preview(bufnr, start, finish, { offset_x = 1 }) - util.open_floating_preview({"*Peek:*", string.rep(" ", finish.character - start.character + 1) }, 'markdown', { offset_y = -(finish.line - start.line) }) -end - ---- Convert SignatureHelp response to preview contents. --- https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_signatureHelp -local function signature_help_to_preview_contents(input) - if not input.signatures then - return - end - --The active signature. If omitted or the value lies outside the range of - --`signatures` the value defaults to zero or is ignored if `signatures.length - --=== 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_signature = input.activeSignature or 0 - -- If the activeSignature is not inside the valid range, then clip it. - if active_signature >= #input.signatures then - active_signature = 0 - end - local signature = input.signatures[active_signature + 1] - if not signature then - return - end - vim.list_extend(contents, split_lines(signature.label)) - if signature.documentation then - util.convert_input_to_markdown_lines(signature.documentation, contents) - end - if input.parameters then - local active_parameter = input.activeParameter or 0 - -- If the activeParameter is not inside the valid range, then clip it. - if active_parameter >= #input.parameters then - active_parameter = 0 - end - local parameter = signature.parameters and signature.parameters[active_parameter] - if parameter then - --[=[ - --Represents a parameter of a callable-signature. A parameter can - --have a label and a doc-comment. - interface ParameterInformation { - --The label of this parameter information. - -- - --Either a string or an inclusive start and exclusive end offsets within its containing - --signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 - --string representation as `Position` and `Range` does. - -- - --*Note*: a label of type string should be a substring of its containing signature label. - --Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. - label: string | [number, number]; - --The human-readable doc-comment of this parameter. Will be shown - --in the UI but can be omitted. - documentation?: string | MarkupContent; - } - --]=] - -- TODO highlight parameter - if parameter.documentation then - util.convert_input_to_markdown_lines(parameter.documentation, contents) - end - end - end - return contents -end - --- textDocument/signatureHelp --- https://microsoft.github.io/language-server-protocol/specification#textDocument_signatureHelp -builtin_callbacks['textDocument/signatureHelp'] = function(_, _, result) - if result == nil or vim.tbl_isempty(result) then - return - end - - -- TODO show empty popup when signatures is empty? - if #result.signatures > 0 then - local markdown_lines = signature_help_to_preview_contents(result) - if vim.tbl_isempty(markdown_lines) then - markdown_lines = { 'No signature available' } - end - util.open_floating_preview(markdown_lines, 'markdown') - end -end - -local function update_tagstack() - local bufnr = api.nvim_get_current_buf() - local line = vim.fn.line('.') - local col = vim.fn.col('.') - local tagname = vim.fn.expand('<cWORD>') - local item = { bufnr = bufnr, from = { bufnr, line, col, 0 }, tagname = tagname } - local winid = vim.fn.win_getid() - local tagstack = vim.fn.gettagstack(winid) - - local action - - if tagstack.length == tagstack.curidx then - action = 'r' - tagstack.items[tagstack.curidx] = item - elseif tagstack.length > tagstack.curidx then - action = 'r' - if tagstack.curidx > 1 then - tagstack.items = table.insert(tagstack.items[tagstack.curidx - 1], item) - else - tagstack.items = { item } - end - else - action = 'a' - tagstack.items = { item } - end - - tagstack.curidx = tagstack.curidx + 1 - vim.fn.settagstack(winid, tagstack, action) -end - -local function handle_location(result) - -- We can sometimes get a list of locations, so set the first value as the - -- only value we want to handle - -- TODO(ashkan) was this correct^? We could use location lists. - if result[1] ~= nil then - result = result[1] - end - if result.uri == nil then - api.nvim_err_writeln('[LSP] Could not find a valid location') - return - end - local result_file = vim.uri_to_fname(result.uri) - local bufnr = vim.fn.bufadd(result_file) - update_tagstack() - api.nvim_set_current_buf(bufnr) - local start = result.range.start - api.nvim_win_set_cursor(0, {start.line + 1, start.character}) -end - -local function location_callback(_, method, result) - if result == nil or vim.tbl_isempty(result) then - local _ = log.info() and log.info(method, 'No location found') - return nil - end - handle_location(result) - return true -end - -local location_callbacks = { - -- https://microsoft.github.io/language-server-protocol/specification#textDocument_declaration - 'textDocument/declaration'; - -- https://microsoft.github.io/language-server-protocol/specification#textDocument_definition - 'textDocument/definition'; - -- https://microsoft.github.io/language-server-protocol/specification#textDocument_implementation - 'textDocument/implementation'; - -- https://microsoft.github.io/language-server-protocol/specification#textDocument_typeDefinition - 'textDocument/typeDefinition'; -} - -for _, location_method in ipairs(location_callbacks) do - builtin_callbacks[location_method] = location_callback -end - -local function log_message(_, _, result, client_id) - local message_type = result.type - local message = result.message - 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 - api.nvim_err_writeln(string.format("LSP[%s] client has shut down after sending the message", client_name)) - end - if message_type == protocol.MessageType.Error then - -- Might want to not use err_writeln, - -- but displaying a message with red highlights or something - api.nvim_err_writeln(string.format("LSP[%s] %s", client_name, message)) - else - local message_type_name = protocol.MessageType[message_type] - api.nvim_out_write(string.format("LSP[%s][%s] %s\n", client_name, message_type_name, message)) - end - return result -end - -builtin_callbacks['window/showMessage'] = log_message -builtin_callbacks['window/logMessage'] = log_message - --- Add boilerplate error validation and logging for all of these. -for k, fn in pairs(builtin_callbacks) do - builtin_callbacks[k] = function(err, method, params, client_id) - local _ = log.debug() and log.debug('builtin_callback', method, { params = params, client_id = client_id, err = err }) - if err then - error(tostring(err)) - end - return fn(err, method, params, client_id) - end -end - -return builtin_callbacks --- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/callbacks.lua b/runtime/lua/vim/lsp/callbacks.lua new file mode 100644 index 0000000000..4fc3f74519 --- /dev/null +++ b/runtime/lua/vim/lsp/callbacks.lua @@ -0,0 +1,223 @@ +local log = require 'vim.lsp.log' +local protocol = require 'vim.lsp.protocol' +local util = require 'vim.lsp.util' +local vim = vim +local api = vim.api + +local M = {} + +local function err_message(...) + api.nvim_err_writeln(table.concat(vim.tbl_flatten{...})) + api.nvim_command("redraw") +end + +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 + print("Workspace edit", workspace_edit.label) + end + util.apply_workspace_edit(workspace_edit.edit) +end + +M['textDocument/publishDiagnostics'] = function(_, _, result) + if not result then return end + local uri = result.uri + local bufnr = vim.uri_to_bufnr(uri) + if not bufnr then + err_message("LSP.publishDiagnostics: Couldn't find buffer for ", uri) + return + end + util.buf_clear_diagnostics(bufnr) + util.buf_diagnostics_save_positions(bufnr, result.diagnostics) + util.buf_diagnostics_underline(bufnr, result.diagnostics) + util.buf_diagnostics_virtual_text(bufnr, result.diagnostics) + -- util.set_loclist(result.diagnostics) +end + +M['textDocument/references'] = function(_, _, result) + if not result then return end + util.set_qflist(result) + api.nvim_command("copen") + api.nvim_command("wincmd p") +end + +M['textDocument/rename'] = function(_, _, result) + if not result then return end + util.apply_workspace_edit(result) +end + +M['textDocument/rangeFormatting'] = function(_, _, result) + if not result then return end + util.apply_text_edits(result) +end + +M['textDocument/formatting'] = function(_, _, result) + if not result then return end + util.apply_text_edits(result) +end + +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]) + local line_to_cursor = line:sub(col+1) + + local matches = util.text_document_completion_list_to_complete_items(result, line_to_cursor) + vim.fn.complete(col, matches) +end + +M['textDocument/hover'] = function(_, method, result) + util.focusable_preview(method, function() + if not (result and result.contents) then + return { 'No information available' } + 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' } + end + return markdown_lines, util.try_trim_markdown_code_blocks(markdown_lines) + end) +end + +local function location_callback(_, method, result) + if result == nil or vim.tbl_isempty(result) then + local _ = log.info() and log.info(method, 'No location found') + return nil + end + util.jump_to_location(result[1]) + if #result > 1 then + util.set_qflist(result) + api.nvim_command("copen") + api.nvim_command("wincmd p") + end +end + +M['textDocument/declaration'] = location_callback +M['textDocument/definition'] = location_callback +M['textDocument/typeDefinition'] = location_callback +M['textDocument/implementation'] = location_callback + +--- Convert SignatureHelp response to preview contents. +-- https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_signatureHelp +local function signature_help_to_preview_contents(input) + if not input.signatures then + return + end + --The active signature. If omitted or the value lies outside the range of + --`signatures` the value defaults to zero or is ignored if `signatures.length + --=== 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_signature = input.activeSignature or 0 + -- If the activeSignature is not inside the valid range, then clip it. + if active_signature >= #input.signatures then + active_signature = 0 + end + local signature = input.signatures[active_signature + 1] + if not signature then + return + end + vim.list_extend(contents, vim.split(signature.label, '\n', true)) + if signature.documentation then + util.convert_input_to_markdown_lines(signature.documentation, contents) + end + if input.parameters then + local active_parameter = input.activeParameter or 0 + -- If the activeParameter is not inside the valid range, then clip it. + if active_parameter >= #input.parameters then + active_parameter = 0 + end + local parameter = signature.parameters and signature.parameters[active_parameter] + if parameter then + --[=[ + --Represents a parameter of a callable-signature. A parameter can + --have a label and a doc-comment. + interface ParameterInformation { + --The label of this parameter information. + -- + --Either a string or an inclusive start and exclusive end offsets within its containing + --signature label. (see SignatureInformation.label). The offsets are based on a UTF-16 + --string representation as `Position` and `Range` does. + -- + --*Note*: a label of type string should be a substring of its containing signature label. + --Its intended use case is to highlight the parameter label part in the `SignatureInformation.label`. + label: string | [number, number]; + --The human-readable doc-comment of this parameter. Will be shown + --in the UI but can be omitted. + documentation?: string | MarkupContent; + } + --]=] + -- TODO highlight parameter + if parameter.documentation then + util.convert_input_to_markdown_lines(parameter.documentation, contents) + end + end + end + return contents +end + +M['textDocument/signatureHelp'] = function(_, method, result) + util.focusable_preview(method, function() + if not (result and result.signatures and result.signatures[1]) then + return { 'No signature available' } + end + -- TODO show popup when signatures is empty? + local lines = signature_help_to_preview_contents(result) + lines = util.trim_empty_lines(lines) + if vim.tbl_isempty(lines) then + return { 'No signature available' } + end + return lines, util.try_trim_markdown_code_blocks(lines) + end) +end + +M['textDocument/peekDefinition'] = function(_, _, result, _) + if not (result and result[1]) then return end + local loc = result[1] + local bufnr = vim.uri_to_bufnr(loc.uri) or error("not found: "..tostring(loc.uri)) + local start = loc.range.start + local finish = loc.range["end"] + util.open_floating_peek_preview(bufnr, start, finish, { offset_x = 1 }) + local headbuf = util.open_floating_preview({"Peek:"}, nil, { + offset_y = -(finish.line - start.line); + width = finish.character - start.character + 2; + }) + -- TODO(ashkan) change highlight group? + api.nvim_buf_add_highlight(headbuf, -1, 'Keyword', 0, -1) +end + +local function log_message(_, _, result, client_id) + local message_type = result.type + local message = result.message + 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 + if message_type == protocol.MessageType.Error then + err_message("LSP[", client_name, "] ", message) + else + local message_type_name = protocol.MessageType[message_type] + api.nvim_out_write(string.format("LSP[%s][%s] %s\n", client_name, message_type_name, message)) + end + return result +end + +M['window/showMessage'] = log_message +M['window/logMessage'] = log_message + +-- 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) + local _ = log.debug() and log.debug('default_callback', method, { params = params, client_id = client_id, err = err }) + if err then + error(tostring(err)) + end + return fn(err, method, params, client_id) + end +end + +return M +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index 1413a88ce2..ead90cc75a 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -10,7 +10,6 @@ end --[=[ -- Useful for interfacing with: --- https://github.com/microsoft/language-server-protocol/blob/gh-pages/_specifications/specification-3-14.md -- https://github.com/microsoft/language-server-protocol/raw/gh-pages/_specifications/specification-3-14.md function transform_schema_comments() nvim.command [[silent! '<,'>g/\/\*\*\|\*\/\|^$/d]] @@ -681,19 +680,6 @@ function protocol.make_client_capabilities() } end -function protocol.make_text_document_position_params() - local position = vim.api.nvim_win_get_cursor(0) - return { - textDocument = { - uri = vim.uri_from_bufnr() - }; - position = { - line = position[1] - 1; - character = position[2]; - } - } -end - --[=[ export interface DocumentFilter { --A language id, like `typescript`. diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index e0ec8863d6..a558f66a42 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -340,6 +340,7 @@ local function create_and_start_client(cmd, cmd_args, handlers, extra_spawn_para local decoded, err = json_decode(body) if not decoded then on_error(client_errors.INVALID_SERVER_JSON, err) + return end local _ = log.debug() and log.debug("decoded", decoded) diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index f96e0f01a8..b9990ed082 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -1,6 +1,8 @@ local protocol = require 'vim.lsp.protocol' +local vim = vim local validate = vim.validate local api = vim.api +local list_extend = vim.list_extend local M = {} @@ -9,7 +11,13 @@ local function split_lines(value) return split(value, '\n', true) end -local list_extend = vim.list_extend +local function ok_or_nil(status, ...) + if not status then return end + return ... +end +local function npcall(fn, ...) + return ok_or_nil(pcall(fn, ...)) +end --- Find the longest shared prefix between prefix and word. -- e.g. remove_prefix("123tes", "testing") == "ting" @@ -26,11 +34,90 @@ local function remove_prefix(prefix, word) return word:sub(prefix_length + 1) end -local function resolve_bufnr(bufnr) - if bufnr == nil or bufnr == 0 then - return api.nvim_get_current_buf() +-- TODO(ashkan) @performance this could do less copying. +function M.set_lines(lines, A, B, new_lines) + -- 0-indexing to 1-indexing + local i_0 = A[1] + 1 + local i_n = B[1] + 1 + if not (i_0 >= 1 and i_0 <= #lines and i_n >= 1 and i_n <= #lines) then + error("Invalid range: "..vim.inspect{A = A; B = B; #lines, new_lines}) + end + local prefix = "" + local suffix = lines[i_n]:sub(B[2]+1) + if A[2] > 0 then + prefix = lines[i_0]:sub(1, A[2]) + end + local n = i_n - i_0 + 1 + if n ~= #new_lines then + for _ = 1, n - #new_lines do table.remove(lines, i_0) end + for _ = 1, #new_lines - n do table.insert(lines, i_0, '') end + end + for i = 1, #new_lines do + lines[i - 1 + i_0] = new_lines[i] + end + if #suffix > 0 then + local i = i_0 + #new_lines - 1 + lines[i] = lines[i]..suffix + end + if #prefix > 0 then + lines[i_0] = prefix..lines[i_0] + end + return lines +end + +local function sort_by_key(fn) + return function(a,b) + local ka, kb = fn(a), fn(b) + assert(#ka == #kb) + for i = 1, #ka do + if ka[i] ~= kb[i] then + return ka[i] < kb[i] + end + end + -- every value must have been equal here, which means it's not less than. + return false + end +end +local edit_sort_key = sort_by_key(function(e) + return {e.A[1], e.A[2], e.i} +end) + +function M.apply_text_edits(text_edits, bufnr) + if not next(text_edits) then return end + local start_line, finish_line = math.huge, -1 + local cleaned = {} + for i, e in ipairs(text_edits) do + 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 = {e.range.start.line; e.range.start.character}; + B = {e.range["end"].line; e.range["end"].character}; + 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[#lines] ~= 0 then + table.insert(lines, '') + 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) end - return bufnr + if set_eol and #lines[#lines] == 0 then + table.remove(lines) + end + api.nvim_buf_set_lines(bufnr, start_line, finish_line + 1, false, lines) end -- local valid_windows_path_characters = "[^<>:\"/\\|?*]" @@ -40,30 +127,6 @@ end -- function M.glob_to_regex(glob) -- end ---- Apply the TextEdit response. --- @params TextEdit [table] see https://microsoft.github.io/language-server-protocol/specification -function M.text_document_apply_text_edit(text_edit, bufnr) - bufnr = resolve_bufnr(bufnr) - local range = text_edit.range - local start = range.start - local finish = range['end'] - local new_lines = split_lines(text_edit.newText) - if start.character == 0 and finish.character == 0 then - api.nvim_buf_set_lines(bufnr, start.line, finish.line, false, new_lines) - return - end - api.nvim_err_writeln('apply_text_edit currently only supports character ranges starting at 0') - error('apply_text_edit currently only supports character ranges starting at 0') - return - -- TODO test and finish this support for character ranges. --- local lines = api.nvim_buf_get_lines(0, start.line, finish.line + 1, false) --- local suffix = lines[#lines]:sub(finish.character+2) --- local prefix = lines[1]:sub(start.character+2) --- new_lines[#new_lines] = new_lines[#new_lines]..suffix --- new_lines[1] = prefix..new_lines[1] --- api.nvim_buf_set_lines(0, start.line, finish.line, false, new_lines) -end - -- textDocument/completion response returns one of CompletionItem[], CompletionList or null. -- https://microsoft.github.io/language-server-protocol/specification#textDocument_completion function M.extract_completion_items(result) @@ -78,18 +141,15 @@ end --- Apply the TextDocumentEdit response. -- @params TextDocumentEdit [table] see https://microsoft.github.io/language-server-protocol/specification -function M.text_document_apply_text_document_edit(text_document_edit, bufnr) - -- local text_document = text_document_edit.textDocument - -- TODO use text_document_version? - -- local text_document_version = text_document.version - - -- TODO technically, you could do this without doing multiple buf_get/set - -- by getting the full region (smallest line and largest line) and doing - -- the edits on the buffer, and then applying the buffer at the end. - -- I'm not sure if that's better. - for _, text_edit in ipairs(text_document_edit.edits) do - M.text_document_apply_text_edit(text_edit, bufnr) +function M.apply_text_document_edit(text_document_edit) + local text_document = text_document_edit.textDocument + local bufnr = vim.uri_to_bufnr(text_document.uri) + -- TODO(ashkan) check this is correct. + if api.nvim_buf_get_changedtick(bufnr) > text_document.version then + print("Buffer ", text_document.uri, " newer than edits.") + return end + M.apply_text_edits(text_document_edit.edits, bufnr) end function M.get_current_line_to_cursor() @@ -145,32 +205,27 @@ function M.text_document_completion_list_to_complete_items(result, line_prefix) end -- @params WorkspaceEdit [table] see https://microsoft.github.io/language-server-protocol/specification -function M.workspace_apply_workspace_edit(workspace_edit) +function M.apply_workspace_edit(workspace_edit) if workspace_edit.documentChanges then for _, change in ipairs(workspace_edit.documentChanges) do if change.kind then -- TODO(ashkan) handle CreateFile/RenameFile/DeleteFile error(string.format("Unsupported change: %q", vim.inspect(change))) else - M.text_document_apply_text_document_edit(change) + M.apply_text_document_edit(change) end end return end - if workspace_edit.changes == nil or #workspace_edit.changes == 0 then + local all_changes = workspace_edit.changes + if not (all_changes and not vim.tbl_isempty(all_changes)) then return end - for uri, changes in pairs(workspace_edit.changes) do - local fname = vim.uri_to_fname(uri) - -- TODO improve this approach. Try to edit open buffers without switching. - -- Not sure how to handle files which aren't open. This is deprecated - -- anyway, so I guess it could be left as is. - api.nvim_command('edit '..fname) - for _, change in ipairs(changes) do - M.text_document_apply_text_edit(change) - end + for uri, changes in pairs(all_changes) do + local bufnr = vim.uri_to_bufnr(uri) + M.apply_text_edits(changes, bufnr) end end @@ -255,35 +310,77 @@ function M.make_floating_popup_options(width, height, opts) } end +function M.jump_to_location(location) + if location.uri == nil then return end + local bufnr = vim.uri_to_bufnr(location.uri) + -- TODO(ashkan) use tagfunc here to update tagstack. + api.nvim_set_current_buf(bufnr) + local row = location.range.start.line + local col = location.range.start.character + local line = api.nvim_buf_get_lines(0, row, row+1, true)[1] + col = vim.str_byteindex(line, col) + api.nvim_win_set_cursor(0, {row + 1, col}) + return true +end + +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 + return win + end + end +end + +-- Check if a window with `unique_name` tagged is associated with the current +-- buffer. If not, make a new preview. +-- +-- fn()'s return values will be passed directly to open_floating_preview in the +-- case that a new floating window should be created. +function M.focusable_preview(unique_name, fn) + if npcall(api.nvim_win_get_var, 0, unique_name) then + return api.nvim_command("wincmd p") + end + local bufnr = api.nvim_get_current_buf() + do + local win = find_window_by_var(unique_name, bufnr) + if win then + api.nvim_set_current_win(win) + api.nvim_command("stopinsert") + return + end + end + local pbufnr, pwinnr = M.open_floating_preview(fn()) + api.nvim_win_set_var(pwinnr, unique_name, bufnr) + return pbufnr, pwinnr +end + function M.open_floating_preview(contents, filetype, opts) validate { contents = { contents, 't' }; filetype = { filetype, 's', true }; opts = { opts, 't', true }; } + opts = opts or {} -- Trim empty lines from the end. - for i = #contents, 1, -1 do - if #contents[i] == 0 then - table.remove(contents) - else - break + contents = M.trim_empty_lines(contents) + + local width = opts.width + local height = opts.height or #contents + if not width then + width = 0 + for i, line in ipairs(contents) do + -- Clean up the input and add left pad. + line = " "..line:gsub("\r", "") + -- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced. + local line_width = vim.fn.strdisplaywidth(line) + width = math.max(line_width, width) + contents[i] = line end + -- Add right padding of 1 each. + width = width + 1 end - local width = 0 - local height = #contents - for i, line in ipairs(contents) do - -- Clean up the input and add left pad. - line = " "..line:gsub("\r", "") - -- TODO(ashkan) use nvim_strdisplaywidth if/when that is introduced. - local line_width = vim.fn.strdisplaywidth(line) - width = math.max(line_width, width) - contents[i] = line - end - -- Add right padding of 1 each. - width = width + 1 - local floating_bufnr = api.nvim_create_buf(false, true) if filetype then api.nvim_buf_set_option(floating_bufnr, 'filetype', filetype) @@ -295,7 +392,8 @@ function M.open_floating_preview(contents, filetype, opts) end api.nvim_buf_set_lines(floating_bufnr, 0, -1, true, contents) api.nvim_buf_set_option(floating_bufnr, 'modifiable', false) - api.nvim_command("autocmd CursorMoved <buffer> ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") + -- TODO make InsertCharPre disappearing optional? + api.nvim_command("autocmd CursorMoved,BufHidden,InsertCharPre <buffer> ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") return floating_bufnr, floating_winnr end @@ -342,6 +440,11 @@ do local diagnostic_ns = api.nvim_create_namespace("vim_lsp_diagnostics") + local underline_highlight_name = "LspDiagnosticsUnderline" + api.nvim_command(string.format("highlight default %s gui=underline cterm=underline", underline_highlight_name)) + + local severity_highlights = {} + local default_severity_highlight = { [protocol.DiagnosticSeverity.Error] = { guifg = "Red" }; [protocol.DiagnosticSeverity.Warning] = { guifg = "Orange" }; @@ -349,60 +452,17 @@ do [protocol.DiagnosticSeverity.Hint] = { guifg = "LightGrey" }; } - local underline_highlight_name = "LspDiagnosticsUnderline" - api.nvim_command(string.format("highlight %s gui=underline cterm=underline", underline_highlight_name)) - - local function find_color_rgb(color) - local rgb_hex = api.nvim_get_color_by_name(color) - validate { color = {color, function() return rgb_hex ~= -1 end, "valid color name"} } - return rgb_hex - end - - --- Determine whether to use black or white text - -- Ref: https://stackoverflow.com/a/1855903/837964 - -- https://stackoverflow.com/questions/596216/formula-to-determine-brightness-of-rgb-color - local function color_is_bright(r, g, b) - -- Counting the perceptive luminance - human eye favors green color - local luminance = (0.299*r + 0.587*g + 0.114*b)/255 - if luminance > 0.5 then - return true -- Bright colors, black font - else - return false -- Dark colors, white font - end - end - - local severity_highlights = {} - - function M.set_severity_highlights(highlights) - validate {highlights = {highlights, 't'}} - for severity, default_color in pairs(default_severity_highlight) do - local severity_name = protocol.DiagnosticSeverity[severity] - local highlight_name = "LspDiagnostics"..severity_name - local hi_info = highlights[severity] or default_color - -- Try to fill in the foreground color with a sane default. - if not hi_info.guifg and hi_info.guibg then - -- TODO(ashkan) move this out when bitop is guaranteed to be included. - local bit = require 'bit' - local band, rshift = bit.band, bit.rshift - local rgb = find_color_rgb(hi_info.guibg) - local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF)) - hi_info.guifg = is_bright and "Black" or "White" - end - if not hi_info.ctermfg and hi_info.ctermbg then - -- TODO(ashkan) move this out when bitop is guaranteed to be included. - local bit = require 'bit' - local band, rshift = bit.band, bit.rshift - local rgb = find_color_rgb(hi_info.ctermbg) - local is_bright = color_is_bright(rshift(rgb, 16), band(rshift(rgb, 8), 0xFF), band(rgb, 0xFF)) - hi_info.ctermfg = is_bright and "Black" or "White" - end - local cmd_parts = {"highlight", highlight_name} - for k, v in pairs(hi_info) do - table.insert(cmd_parts, k.."="..v) - end - api.nvim_command(table.concat(cmd_parts, ' ')) - severity_highlights[severity] = highlight_name + -- Initialize default severity highlights + for severity, hi_info in pairs(default_severity_highlight) do + local severity_name = protocol.DiagnosticSeverity[severity] + local highlight_name = "LspDiagnostics"..severity_name + -- Try to fill in the foreground color with a sane default. + local cmd_parts = {"highlight", "default", highlight_name} + for k, v in pairs(hi_info) do + table.insert(cmd_parts, k.."="..v) end + api.nvim_command(table.concat(cmd_parts, ' ')) + severity_highlights[severity] = highlight_name end function M.buf_clear_diagnostics(bufnr) @@ -411,9 +471,6 @@ do api.nvim_buf_clear_namespace(bufnr, diagnostic_ns, 0, -1) end - -- Initialize with the defaults. - M.set_severity_highlights(default_severity_highlight) - function M.get_severity_highlight_name(severity) return severity_highlights[severity] end @@ -527,30 +584,141 @@ do end end -function M.buf_loclist(bufnr, locations) - local targetwin - for _, winnr in ipairs(api.nvim_list_wins()) do - local winbuf = api.nvim_win_get_buf(winnr) - if winbuf == bufnr then - targetwin = winnr - break - end - end - if not targetwin then return end +local position_sort = sort_by_key(function(v) + return {v.line, v.character} +end) +-- Returns the items with the byte position calculated correctly and in sorted +-- order. +function M.locations_to_items(locations) local items = {} - local path = api.nvim_buf_get_name(bufnr) + local grouped = setmetatable({}, { + __index = function(t, k) + local v = {} + rawset(t, k, v) + return v + end; + }) for _, d in ipairs(locations) do - -- TODO: URL parsing here? local start = d.range.start - table.insert(items, { - filename = path, - lnum = start.line + 1, - col = start.character + 1, - text = d.message, - }) + local fname = assert(vim.uri_to_fname(d.uri)) + table.insert(grouped[fname], start) + end + local keys = vim.tbl_keys(grouped) + table.sort(keys) + -- TODO(ashkan) I wish we could do this lazily. + for _, fname in ipairs(keys) do + local rows = grouped[fname] + table.sort(rows, position_sort) + local i = 0 + for line in io.lines(fname) do + for _, pos in ipairs(rows) do + local row = pos.line + if i == row then + local col + if pos.character > #line then + col = #line + else + col = vim.str_byteindex(line, pos.character) + end + table.insert(items, { + filename = fname, + lnum = row + 1, + col = col + 1; + text = line; + }) + end + end + i = i + 1 + end + end + return items +end + +-- locations is Location[] +-- Only sets for the current window. +function M.set_loclist(locations) + vim.fn.setloclist(0, {}, ' ', { + title = 'Language Server'; + items = M.locations_to_items(locations); + }) +end + +-- locations is Location[] +function M.set_qflist(locations) + vim.fn.setqflist({}, ' ', { + title = 'Language Server'; + items = M.locations_to_items(locations); + }) +end + +-- Remove empty lines from the beginning and end. +function M.trim_empty_lines(lines) + local start = 1 + for i = 1, #lines do + if #lines[i] > 0 then + start = i + break + end + end + local finish = 1 + for i = #lines, 1, -1 do + if #lines[i] > 0 then + finish = i + break + end + end + return vim.list_extend({}, lines, start, finish) +end + +-- Accepts markdown lines and tries to reduce it to a filetype if it is +-- just a single code block. +-- Note: This modifies the input. +-- +-- Returns: 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 + local has_inner_code_fence = false + for i = 2, (#lines - 1) do + local line = lines[i] + if line:sub(1,3) == '```' then + has_inner_code_fence = true + break + end + end + -- No inner code fences + starting with code fence = hooray. + if not has_inner_code_fence then + table.remove(lines, 1) + table.remove(lines) + return language_id + end + end + return 'markdown' +end + +local str_utfindex = vim.str_utfindex +function M.make_position_params() + local row, col = unpack(api.nvim_win_get_cursor(0)) + row = row - 1 + local line = api.nvim_buf_get_lines(0, row, row+1, true)[1] + col = str_utfindex(line, col) + return { + textDocument = { uri = vim.uri_from_bufnr(0) }; + position = { line = row; character = col; } + } +end + +-- @param buf buffer handle or 0 for current. +-- @param row 0-indexed line +-- @param col 0-indexed byte offset in line +function M.character_offset(buf, row, col) + local line = api.nvim_buf_get_lines(buf, row, row+1, true)[1] + -- If the col is past the EOL, use the line length. + if col > #line then + return str_utfindex(line) end - vim.fn.setloclist(targetwin, items, ' ', 'Language Server') + return str_utfindex(line, col) end return M diff --git a/runtime/lua/vim/shared.lua b/runtime/lua/vim/shared.lua index ff89acc524..b5b04d7757 100644 --- a/runtime/lua/vim/shared.lua +++ b/runtime/lua/vim/shared.lua @@ -4,7 +4,7 @@ -- test-suite. If, in the future, Nvim itself is used to run the test-suite -- instead of "vanilla Lua", these functions could move to src/nvim/lua/vim.lua -local vim = {} +local vim = vim or {} --- Returns a deep copy of the given object. Non-table objects are copied as --- in a typical Lua assignment, whereas table objects are copied recursively. @@ -228,16 +228,24 @@ end --- Extends a list-like table with the values of another list-like table. --- ---NOTE: This *mutates* dst! ---@see |extend()| +--- NOTE: This mutates dst! +--- +--@see |vim.tbl_extend()| --- ---@param dst The list which will be modified and appended to. ---@param src The list from which values will be inserted. -function vim.list_extend(dst, src) - assert(type(dst) == 'table', "dst must be a table") - assert(type(src) == 'table', "src must be a table") - for _, v in ipairs(src) do - table.insert(dst, v) +--@param dst list which will be modified and appended to. +--@param src list from which values will be inserted. +--@param start Start index on src. defaults to 1 +--@param finish Final index on src. defaults to #src +--@returns dst +function vim.list_extend(dst, src, start, finish) + vim.validate { + dst = {dst, 't'}; + src = {src, 't'}; + start = {start, 'n', true}; + finish = {finish, 'n', true}; + } + for i = start or 1, finish or #src do + table.insert(dst, src[i]) end return dst end @@ -312,6 +320,26 @@ function vim.pesc(s) return s:gsub('[%(%)%.%%%+%-%*%?%[%]%^%$]', '%%%1') end +--- Test if `prefix` is a prefix of `s` for strings. +-- +-- @param s String to check +-- @param prefix Potential prefix +-- @return boolean True if prefix is a prefix of s +function vim.startswith(s, prefix) + vim.validate { s = {s, 's'}; prefix = {prefix, 's'}; } + return s:sub(1, #prefix) == prefix +end + +--- Test if `suffix` is a suffix of `s` for strings. +-- +-- @param s String to check +-- @param suffix Potential suffix +-- @return boolean True if suffix is a suffix of s +function vim.endswith(s, suffix) + vim.validate { s = {s, 's'}; suffix = {suffix, 's'}; } + return #suffix == 0 or s:sub(-#suffix) == suffix +end + --- Validates a parameter specification (types and values). --- --- Usage example: diff --git a/runtime/lua/vim/uri.lua b/runtime/lua/vim/uri.lua index 0a6e0fcb97..1065f84f4c 100644 --- a/runtime/lua/vim/uri.lua +++ b/runtime/lua/vim/uri.lua @@ -77,13 +77,18 @@ local function uri_to_fname(uri) else uri = uri:gsub('^file://', '') end - return uri_decode(uri) end +-- Return or create a buffer for a uri. +local function uri_to_bufnr(uri) + return vim.fn.bufadd((uri_to_fname(uri))) +end + return { uri_from_fname = uri_from_fname, uri_from_bufnr = uri_from_bufnr, uri_to_fname = uri_to_fname, + uri_to_bufnr = uri_to_bufnr, } -- vim:sw=2 ts=2 et |