diff options
Diffstat (limited to 'runtime/lua')
-rw-r--r-- | runtime/lua/vim/inspect.lua | 2 | ||||
-rw-r--r-- | runtime/lua/vim/lsp.lua | 1055 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/builtin_callbacks.lua | 296 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/log.lua | 95 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/protocol.lua | 936 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 451 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 557 | ||||
-rw-r--r-- | runtime/lua/vim/shared.lua | 227 | ||||
-rw-r--r-- | runtime/lua/vim/uri.lua | 89 |
9 files changed, 3700 insertions, 8 deletions
diff --git a/runtime/lua/vim/inspect.lua b/runtime/lua/vim/inspect.lua index 7cb40ca64d..0f3b908dc1 100644 --- a/runtime/lua/vim/inspect.lua +++ b/runtime/lua/vim/inspect.lua @@ -289,7 +289,7 @@ function Inspector:putValue(v) if tv == 'string' then self:puts(smartQuote(escape(v))) elseif tv == 'number' or tv == 'boolean' or tv == 'nil' or - tv == 'cdata' or tv == 'ctype' then + tv == 'cdata' or tv == 'ctype' or (vim and v == vim.NIL) then self:puts(tostring(v)) elseif tv == 'table' then self:putTable(v) diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua new file mode 100644 index 0000000000..9dbe03dace --- /dev/null +++ b/runtime/lua/vim/lsp.lua @@ -0,0 +1,1055 @@ +local builtin_callbacks = require 'vim.lsp.builtin_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 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 +local tbl_isempty, tbl_extend = vim.tbl_isempty, vim.tbl_extend +local validate = vim.validate + +local lsp = { + protocol = protocol; + builtin_callbacks = builtin_callbacks; + util = util; + -- Allow raw RPC access. + rpc = lsp_rpc; + -- Export these directly from rpc. + rpc_response_error = lsp_rpc.rpc_response_error; + -- You probably won't need this directly, since __tostring is set for errors + -- by the RPC. + -- 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 resolve_bufnr(bufnr) + validate { bufnr = { bufnr, 'n', true } } + if bufnr == nil or bufnr == 0 then + return vim.api.nvim_get_current_buf() + end + return bufnr +end + +local function is_dir(filename) + validate{filename={filename,'s'}} + local stat = uv.fs_stat(filename) + return stat and stat.type == 'directory' or false +end + +-- TODO Use vim.wait when that is available, but provide an alternative for now. +local wait = vim.wait or function(timeout_ms, condition, interval) + validate { + timeout_ms = { timeout_ms, 'n' }; + condition = { condition, 'f' }; + interval = { interval, 'n', true }; + } + assert(timeout_ms > 0, "timeout_ms must be > 0") + local _ = log.debug() and log.debug("wait.fallback", timeout_ms) + interval = interval or 200 + local interval_cmd = "sleep "..interval.."m" + local timeout = timeout_ms + uv.now() + -- TODO is there a better way to sync this? + while true do + uv.update_time() + if condition() then + return 0 + end + if uv.now() >= timeout then + return -1 + end + nvim_command(interval_cmd) + -- vim.loop.sleep(10) + end +end +local wait_result_reason = { [-1] = "timeout"; [-2] = "interrupted"; [-3] = "error" } + +local valid_encodings = { + ["utf-8"] = 'utf-8'; ["utf-16"] = 'utf-16'; ["utf-32"] = 'utf-32'; + ["utf8"] = 'utf-8'; ["utf16"] = 'utf-16'; ["utf32"] = 'utf-32'; + UTF8 = 'utf-8'; UTF16 = 'utf-16'; UTF32 = 'utf-32'; +} + +local client_index = 0 +local function next_client_id() + client_index = client_index + 1 + return client_index +end +-- Tracks all clients created via lsp.start_client +local active_clients = {} +local all_buffer_active_clients = {} +local uninitialized_clients = {} + +local function for_each_buffer_client(bufnr, callback) + validate { + callback = { callback, 'f' }; + } + bufnr = resolve_bufnr(bufnr) + local client_ids = all_buffer_active_clients[bufnr] + if not client_ids or tbl_isempty(client_ids) then + 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 + end +end + +-- Error codes to be used with `on_error` from |vim.lsp.start_client|. +-- Can be used to look up the string from a the number or the number +-- from the string. +lsp.client_errors = tbl_extend("error", lsp_rpc.client_errors, vim.tbl_add_reverse_lookup { + ON_INIT_CALLBACK_ERROR = table.maxn(lsp_rpc.client_errors) + 1; +}) + +local function validate_encoding(encoding) + validate { + encoding = { encoding, 's' }; + } + return valid_encodings[encoding:lower()] + or error(string.format("Invalid offset encoding %q. Must be one of: 'utf-8', 'utf-16', 'utf-32'", encoding)) +end + +local function validate_command(input) + local cmd, cmd_args + if type(input) == 'string' then + -- Use a shell to execute the command if it is a string. + cmd = vim.api.nvim_get_option('shell') + cmd_args = {vim.api.nvim_get_option('shellcmdflag'), input} + elseif vim.tbl_islist(input) then + cmd = input[1] + cmd_args = {} + -- Don't mutate our input. + for i, v in ipairs(input) do + assert(type(v) == 'string', "input arguments must be strings") + if i > 1 then + table.insert(cmd_args, v) + end + end + else + error("cmd type must be string or list.") + end + return cmd, cmd_args +end + +local function optional_validator(fn) + return function(v) + return v == nil or fn(v) + end +end + +local function validate_client_config(config) + validate { + config = { config, 't' }; + } + validate { + 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 }; + offset_encoding = { config.offset_encoding, "s", true }; + } + local cmd, cmd_args = validate_command(config.cmd) + local offset_encoding = valid_encodings.UTF16 + if config.offset_encoding then + offset_encoding = validate_encoding(config.offset_encoding) + end + return { + cmd = cmd; cmd_args = cmd_args; + offset_encoding = offset_encoding; + } +end + +local function text_document_did_open_handler(bufnr, client) + if not client.resolved_capabilities.text_document_open_close then + return + end + if not vim.api.nvim_buf_is_loaded(bufnr) then + return + end + local params = { + textDocument = { + version = 0; + 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'); + } + } + client.notify('textDocument/didOpen', params) +end + + +--- Start a client and initialize it. +-- Its arguments are passed via a configuration object. +-- +-- Mandatory parameters: +-- +-- root_dir: {string} specifying the directory where the LSP server will base +-- as its rootUri on initialization. +-- +-- cmd: {string} or {list} which is the base command to execute for the LSP. A +-- string will be run using |'shell'| and a list will be interpreted as a bare +-- command with arguments passed. This is the same as |jobstart()|. +-- +-- Optional parameters: + +-- cmd_cwd: {string} specifying the directory to launch the `cmd` process. This +-- is not related to `root_dir`. By default, |getcwd()| is used. +-- +-- cmd_env: {table} specifying the environment flags to pass to the LSP on +-- spawn. This can be specified using keys like a map or as a list with `k=v` +-- pairs or both. Non-string values are coerced to a string. +-- For example: `{ "PRODUCTION=true"; "TEST=123"; PORT = 8080; HOST = "0.0.0.0"; }`. +-- +-- capabilities: A {table} which will be used instead of +-- `vim.lsp.protocol.make_client_capabilities()` which contains neovim's +-- default capabilities and passed to the language server on initialization. +-- You'll probably want to use make_client_capabilities() and modify the +-- result. +-- NOTE: +-- To send an empty dictionary, you should use +-- `{[vim.type_idx]=vim.types.dictionary}` Otherwise, it will be encoded as +-- an array. +-- +-- callbacks: A {table} of whose keys are language server method names and the +-- values are `function(err, method, params, client_id)`. +-- This will be called for: +-- - notifications from the server, where `err` will always be `nil` +-- - requests initiated by the server. For these, you can respond by returning +-- two values: `result, err`. The err must be in the format of an RPC error, +-- which is `{ code, message, data? }`. You can use |vim.lsp.rpc_response_error()| +-- to help with this. +-- - as a callback for requests initiated by the client if the request doesn't +-- explicitly specify a callback. +-- +-- init_options: A {table} of values to pass in the initialization request +-- as `initializationOptions`. See the `initialize` in the LSP spec. +-- +-- name: A {string} used in log messages. Defaults to {client_id} +-- +-- offset_encoding: One of 'utf-8', 'utf-16', or 'utf-32' which is the +-- encoding that the LSP server expects. By default, it is 'utf-16' as +-- specified in the LSP specification. The client does not verify this +-- is correct. +-- +-- on_error(code, ...): A function for handling errors thrown by client +-- operation. {code} is a number describing the error. Other arguments may be +-- passed depending on the error kind. @see |vim.lsp.client_errors| for +-- possible errors. `vim.lsp.client_errors[code]` can be used to retrieve a +-- human understandable string. +-- +-- 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` +-- sends `result.offsetEncoding` if `capabilities.offsetEncoding` was sent to +-- it. +-- +-- on_exit(code, signal, client_id): A function which is called after the +-- client has exited. code is the exit code of the process, and signal is a +-- number describing the signal used to terminate (if any). +-- +-- on_attach(client, bufnr): A function which is called after the client is +-- attached to a buffer. +-- +-- trace: 'off' | 'messages' | 'verbose' | nil passed directly to the language +-- server in the initialize request. Invalid/empty values will default to 'off' +-- +-- @returns client_id You can use |vim.lsp.get_client_by_id()| to get the +-- actual client. +-- +-- NOTE: The client is only available *after* it has been initialized, which +-- may happen after a small delay (or never if there is an error). +-- For this reason, you may want to use `on_init` to do any actions once the +-- client has been initialized. +function lsp.start_client(config) + local cleaned_config = validate_client_config(config) + local cmd, cmd_args, offset_encoding = cleaned_config.cmd, cleaned_config.cmd_args, cleaned_config.offset_encoding + + 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 name = config.name or tostring(client_id) + local log_prefix = string.format("LSP[%s]", name) + + local handlers = {} + + function handlers.notification(method, params) + local _ = log.debug() and log.debug('notification', method, params) + local callback = callbacks[method] + if callback then + -- Method name is provided here for convenience. + callback(nil, method, params, client_id) + end + end + + function handlers.server_request(method, params) + local _ = log.debug() and log.debug('server_request', method, params) + local callback = callbacks[method] + if callback then + local _ = log.debug() and log.debug("server_request: found callback for", method) + return callback(nil, method, params, client_id) + end + local _ = log.debug() and log.debug("server_request: no callback found for", method) + return nil, lsp.rpc_response_error(protocol.ErrorCodes.MethodNotFound) + end + + 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))) + 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)) + end + end + end + + 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 + client_ids[client_id] = nil + end + if config.on_exit then + pcall(config.on_exit, code, signal, client_id) + end + end + + -- Start the RPC client. + local rpc = lsp_rpc.start(cmd, cmd_args, handlers, { + cwd = config.cmd_cwd; + env = config.cmd_env; + }) + + local client = { + id = client_id; + name = name; + rpc = rpc; + offset_encoding = offset_encoding; + callbacks = callbacks; + config = config; + } + + -- Store the uninitialized_clients for cleanup in case we exit before + -- initialize finishes. + uninitialized_clients[client_id] = client; + + local function initialize() + local valid_traces = { + off = 'off'; messages = 'messages'; verbose = 'verbose'; + } + local initialize_params = { + -- The process Id of the parent process that started the server. Is null if + -- the process has not been started by another process. If the parent + -- process is not alive then the server should exit (see exit notification) + -- its process. + processId = uv.getpid(); + -- The rootPath of the workspace. Is null if no folder is open. + -- + -- @deprecated in favour of rootUri. + rootPath = nil; + -- 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) + capabilities = config.capabilities or protocol.make_client_capabilities(); + -- The initial trace setting. If omitted trace is disabled ('off'). + -- trace = 'off' | 'messages' | 'verbose'; + trace = valid_traces[config.trace] or 'off'; + -- The workspace folders configured in the client when the server starts. + -- This property is only available if the client supports workspace folders. + -- It can be `null` if the client supports workspace folders but none are + -- configured. + -- + -- Since 3.6.0 + -- workspaceFolders?: WorkspaceFolder[] | null; + -- export interface WorkspaceFolder { + -- -- The associated URI for this workspace folder. + -- uri + -- -- The name of the workspace folder. Used to refer to this + -- -- workspace folder in the user interface. + -- name + -- } + workspaceFolders = nil; + } + 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', {}) + client.initialized = true + uninitialized_clients[client_id] = nil + client.server_capabilities = assert(result.capabilities, "initialize result doesn't contain capabilities") + -- These are the cleaned up capabilities we use for dynamically deciding + -- when to send certain events to clients. + client.resolved_capabilities = protocol.resolve_capabilities(client.server_capabilities) + if config.on_init then + local status, err = pcall(config.on_init, client, result) + if not status then + pcall(handlers.on_error, lsp.client_errors.ON_INIT_CALLBACK_ERROR, err) + end + end + local _ = log.debug() and log.debug(log_prefix, "server_capabilities", client.server_capabilities) + local _ = log.info() and log.info(log_prefix, "initialized", { resolved_capabilities = client.resolved_capabilities }) + + -- Only assign after initialized. + active_clients[client_id] = client + -- If we had been registered before we start, then send didOpen This can + -- happen if we attach to buffers before initialize finishes or if + -- someone restarts a client. + for bufnr, client_ids in pairs(all_buffer_active_clients) do + if client_ids[client_id] then + client._on_attach(bufnr) + end + end + end) + end + + local function unsupported_method(method) + local msg = "server doesn't support "..method + local _ = log.warn() and log.warn(msg) + nvim_err_writeln(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)) + 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? + if (not client.resolved_capabilities.hover and method == 'textDocument/hover') + or (not client.resolved_capabilities.signature_help and method == 'textDocument/signatureHelp') + or (not client.resolved_capabilities.goto_definition and method == 'textDocument/definition') + or (not client.resolved_capabilities.implementation and method == 'textDocument/implementation') + then + callback(unsupported_method(method), method, nil, client_id) + return + end + return rpc.request(method, params, function(err, result) + callback(err, method, result, client_id) + end) + end + + function client.notify(...) + return rpc.notify(...) + end + + function client.cancel_request(id) + validate{id = {id, 'n'}} + return rpc.notify("$/cancelRequest", { id = id }) + end + + -- Track this so that we can escalate automatically if we've alredy tried a + -- graceful shutdown + local tried_graceful_shutdown = false + function client.stop(force) + local handle = rpc.handle + if handle:is_closing() then + return + end + if force or (not client.initialized) or tried_graceful_shutdown then + handle:kill(15) + return + end + tried_graceful_shutdown = true + -- Sending a signal after a process has exited is acceptable. + rpc.request('shutdown', nil, function(err, _) + if err == nil then + rpc.notify('exit') + else + -- If there was an error in the shutdown request, then term to be safe. + handle:kill(15) + end + end) + end + + function client.is_stopped() + return rpc.handle:is_closing() + end + + function client._on_attach(bufnr) + text_document_did_open_handler(bufnr, client) + if config.on_attach then + -- TODO(ashkan) handle errors. + pcall(config.on_attach, client, bufnr) + end + end + + initialize() + + return client_id +end + +local function once(fn) + local value + return function(...) + if not value then value = fn(...) end + return value + end +end + +local text_document_did_change_handler +do + local encoding_index = { ["utf-8"] = 1; ["utf-16"] = 2; ["utf-32"] = 3; } + text_document_did_change_handler = function(_, bufnr, changedtick, + firstline, lastline, new_lastline, old_byte_size, old_utf32_size, + old_utf16_size) + local _ = log.debug() and log.debug("on_lines", bufnr, changedtick, firstline, + lastline, new_lastline, old_byte_size, old_utf32_size, old_utf16_size, nvim_buf_get_lines(bufnr, firstline, new_lastline, true)) + if old_byte_size == 0 then + return + end + -- Don't do anything if there are no clients attached. + if tbl_isempty(all_buffer_active_clients[bufnr] or {}) then + return + end + -- Lazy initialize these because clients may not even need them. + local incremental_changes = once(function(client) + local size_index = encoding_index[client.offset_encoding] + local length = select(size_index, old_byte_size, old_utf16_size, old_utf32_size) + local lines = nvim_buf_get_lines(bufnr, firstline, new_lastline, true) + -- This is necessary because we are specifying the full line including the + -- newline in range. Therefore, we must replace the newline as well. + if #lines > 0 then + table.insert(lines, '') + end + return { + range = { + start = { line = firstline, character = 0 }; + ["end"] = { line = lastline, character = 0 }; + }; + rangeLength = length; + text = table.concat(lines, '\n'); + }; + end) + local full_changes = once(function() + return { + text = table.concat(nvim_buf_get_lines(bufnr, 0, -1, false), "\n"); + }; + end) + local uri = vim.uri_from_bufnr(bufnr) + for_each_buffer_client(bufnr, function(client, _client_id) + local text_document_did_change = client.resolved_capabilities.text_document_did_change + local changes + if text_document_did_change == protocol.TextDocumentSyncKind.None then + return + --[=[ TODO(ashkan) there seem to be problems with the byte_sizes sent by + -- neovim right now so only send the full content for now. In general, we + -- can assume that servers *will* support both versions anyway, as there + -- is no way to specify the sync capability by the client. + -- See https://github.com/palantir/python-language-server/commit/cfd6675bc10d5e8dbc50fc50f90e4a37b7178821#diff-f68667852a14e9f761f6ebf07ba02fc8 for an example of pyls handling both. + --]=] + elseif true or text_document_did_change == protocol.TextDocumentSyncKind.Full then + changes = full_changes(client) + elseif text_document_did_change == protocol.TextDocumentSyncKind.Incremental then + changes = incremental_changes(client) + end + client.notify("textDocument/didChange", { + textDocument = { + uri = uri; + version = changedtick; + }; + contentChanges = { changes; } + }) + end) + end +end + +-- Buffer lifecycle handler for textDocument/didSave +function lsp._text_document_did_save_handler(bufnr) + bufnr = resolve_bufnr(bufnr) + local uri = vim.uri_from_bufnr(bufnr) + local text = once(function() + return table.concat(nvim_buf_get_lines(bufnr, 0, -1, false), '\n') + end) + for_each_buffer_client(bufnr, function(client, _client_id) + if client.resolved_capabilities.text_document_save then + local included_text + if client.resolved_capabilities.text_document_save_include_text then + included_text = text() + end + client.notify('textDocument/didSave', { + textDocument = { + uri = uri; + text = included_text; + } + }) + end + end) +end + +-- Implements the textDocument/did* notifications required to track a buffer +-- for any language server. +-- @param bufnr [number] buffer handle or 0 for current +-- @param client_id [number] the client id +function lsp.buf_attach_client(bufnr, client_id) + validate { + bufnr = {bufnr, 'n', true}; + client_id = {client_id, 'n'}; + } + bufnr = resolve_bufnr(bufnr) + local buffer_client_ids = all_buffer_active_clients[bufnr] + -- This is our first time attaching to this buffer. + if not buffer_client_ids then + buffer_client_ids = {} + all_buffer_active_clients[bufnr] = buffer_client_ids + + local uri = vim.uri_from_bufnr(bufnr) + nvim_command(string.format("autocmd BufWritePost <buffer=%d> lua vim.lsp._text_document_did_save_handler(0)", bufnr)) + -- First time, so attach and set up stuff. + vim.api.nvim_buf_attach(bufnr, false, { + on_lines = text_document_did_change_handler; + on_detach = function() + local params = { textDocument = { uri = uri; } } + for_each_buffer_client(bufnr, function(client, _client_id) + if client.resolved_capabilities.text_document_open_close then + client.notify('textDocument/didClose', params) + end + end) + all_buffer_active_clients[bufnr] = nil + end; + -- TODO if we know all of the potential clients ahead of time, then we + -- could conditionally set this. + -- utf_sizes = size_index > 1; + utf_sizes = true; + }) + end + if buffer_client_ids[client_id] then return end + -- This is our first time attaching this client to this buffer. + buffer_client_ids[client_id] = true + + local client = active_clients[client_id] + -- Send didOpen for the client if it is initialized. If it isn't initialized + -- then it will send didOpen on initialize. + if client then + client._on_attach(bufnr) + end + return true +end + +-- Check if a buffer is attached for a particular client. +-- @param bufnr [number] buffer handle or 0 for current +-- @param client_id [number] the client id +function lsp.buf_is_attached(bufnr, client_id) + return (all_buffer_active_clients[bufnr] or {})[client_id] == true +end + +-- Look up an active client by its id, returns nil if it is not yet initialized +-- or is not a valid id. +-- @param client_id number the client id. +function lsp.get_client_by_id(client_id) + return active_clients[client_id] +end + +-- Stop a client by its id, optionally with force. +-- You can also use the `stop()` function on a client if you already have +-- access to it. +-- By default, it will just ask the server to shutdown without force. +-- If you request to stop a client which has previously been requested to shutdown, +-- it will automatically force shutdown. +-- @param client_id number the client id. +-- @param force boolean (optional) whether to use force or request shutdown +function lsp.stop_client(client_id, force) + local client + client = active_clients[client_id] + if client then + client.stop(force) + return + end + client = uninitialized_clients[client_id] + if client then + client.stop(true) + end +end + +-- Returns a list of all the active clients. +function lsp.get_active_clients() + return vim.tbl_values(active_clients) +end + +-- Stop all the clients, optionally with force. +-- You can also use the `stop()` function on a client if you already have +-- access to it. +-- By default, it will just ask the server to shutdown without force. +-- If you request to stop a client which has previously been requested to shutdown, +-- it will automatically force shutdown. +-- @param force boolean (optional) whether to use force or request shutdown +function lsp.stop_all_clients(force) + for _, client in pairs(uninitialized_clients) do + client.stop(true) + end + for _, client in pairs(active_clients) do + client.stop(force) + end +end + +function lsp._vim_exit_handler() + log.info("exit_handler", active_clients) + for _, client in pairs(uninitialized_clients) do + client.stop(true) + end + -- TODO handle v:dying differently? + if tbl_isempty(active_clients) then + return + end + for _, client in pairs(active_clients) do + client.stop() + end + local wait_result = wait(500, function() return tbl_isempty(active_clients) end, 50) + if wait_result ~= 0 then + for _, client in pairs(active_clients) do + client.stop(true) + end + end +end + +nvim_command("autocmd VimLeavePre * lua vim.lsp._vim_exit_handler()") + +--- +--- Buffer level client functions. +--- + +--- Send a request to a server and return the response +-- @param bufnr [number] Buffer handle or 0 for current. +-- @param method [string] Request method name +-- @param params [table|nil] Parameters to send to the server +-- @param callback [function|nil] Request callback (or uses the client's callbacks) +-- +-- @returns: client_request_ids, cancel_all_requests +function lsp.buf_request(bufnr, method, params, callback) + validate { + bufnr = { bufnr, 'n', true }; + method = { method, 's' }; + callback = { callback, 'f', true }; + } + local client_request_ids = {} + for_each_buffer_client(bufnr, function(client, client_id) + local request_success, request_id = client.request(method, params, callback) + + -- This could only fail if the client shut down in the time since we looked + -- it up and we did the request, which should be rare. + if request_success then + client_request_ids[client_id] = request_id + end + end) + + local function cancel_all_requests() + for client_id, request_id in pairs(client_request_ids) do + local client = active_clients[client_id] + client.cancel_request(request_id) + end + end + + return client_request_ids, cancel_all_requests +end + +--- Send a request to a server and wait for the response. +-- @param bufnr [number] Buffer handle or 0 for current. +-- @param method [string] Request method name +-- @param params [string] Parameters to send to the server +-- @param timeout_ms [number|100] Maximum ms to wait for a result +-- +-- @returns: The table of {[client_id] = request_result} +function lsp.buf_request_sync(bufnr, method, params, timeout_ms) + local request_results = {} + local result_count = 0 + local function callback(err, _method, result, client_id) + request_results[client_id] = { error = err, result = result } + result_count = result_count + 1 + end + local client_request_ids, cancel = lsp.buf_request(bufnr, method, params, callback) + local expected_result_count = 0 + for _ in pairs(client_request_ids) do + expected_result_count = expected_result_count + 1 + end + local wait_result = wait(timeout_ms or 100, function() + return result_count >= expected_result_count + end, 10) + if wait_result ~= 0 then + cancel() + return nil, wait_result_reason[wait_result] + end + return request_results +end + +--- Send a notification to a server +-- @param bufnr [number] (optional): The number of the buffer +-- @param method [string]: Name of the request method +-- @param params [string]: Arguments to send to the server +-- +-- @returns nil +function lsp.buf_notify(bufnr, method, params) + validate { + bufnr = { bufnr, 'n', true }; + method = { method, 's' }; + } + for_each_buffer_client(bufnr, function(client, _client_id) + client.rpc.notify(method, params) + end) +end + +--- Function which can be called to generate omnifunc compatible completion. +function lsp.omnifunc(findstart, base) + local _ = log.debug() and log.debug("omnifunc.findstart", { findstart = findstart, base = base }) + + local bufnr = resolve_bufnr() + local has_buffer_clients = not tbl_isempty(all_buffer_active_clients[bufnr] or {}) + if not has_buffer_clients then + if findstart == 1 then + return -1 + else + return {} + end + end + + if findstart == 1 then + return vim.fn.col('.') + else + local pos = vim.api.nvim_win_get_cursor(0) + local line = assert(nvim_buf_get_lines(bufnr, pos[1]-1, pos[1], false)[1]) + local _ = log.trace() and log.trace("omnifunc.line", pos, line) + local line_to_cursor = line:sub(1, pos[2]+1) + local _ = log.trace() and log.trace("omnifunc.line_to_cursor", line_to_cursor) + local params = { + textDocument = { + uri = vim.uri_from_bufnr(bufnr); + }; + position = { + -- 0-indexed for both line and character + line = pos[1] - 1, + character = pos[2], + }; + -- The completion context. This is only available if the client specifies + -- to send this using `ClientCapabilities.textDocument.completion.contextSupport === true` + -- context = nil or { + -- triggerKind = protocol.CompletionTriggerKind.Invoked; + -- triggerCharacter = nil or ""; + -- }; + } + -- TODO handle timeout error differently? Like via an error? + local client_responses = lsp.buf_request_sync(bufnr, 'textDocument/completion', params) or {} + local matches = {} + for _, response in pairs(client_responses) do + -- TODO how to handle errors? + if not response.error then + local data = response.result + local completion_items = util.text_document_completion_list_to_complete_items(data or {}, line_to_cursor) + local _ = log.trace() and log.trace("omnifunc.completion_items", completion_items) + vim.list_extend(matches, completion_items) + end + end + return matches + 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 +end + +--- +--- Miscellaneous utilities. +--- + +-- Retrieve a map from client_id to client of all active buffer clients. +-- @param bufnr [number] (optional): buffer handle or 0 for current +function lsp.buf_get_clients(bufnr) + bufnr = resolve_bufnr(bufnr) + local result = {} + for_each_buffer_client(bufnr, function(client, client_id) + result[client_id] = client + end) + return result +end + +-- Print some debug information about the current buffer clients. +-- The output of this function should not be relied upon and may change. +function lsp.buf_print_debug_info(bufnr) + print(vim.inspect(lsp.buf_get_clients(bufnr))) +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 })) +end + +-- Log level dictionary with reverse lookup as well. +-- +-- 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 +lsp.log_levels = log.levels + +-- Set the log level for lsp logging. +-- Levels by name: 'trace', 'debug', 'info', 'warn', 'error' +-- Level numbers begin with 'trace' at 0 +-- @param level [number|string] the case insensitive level name or number @see |vim.lsp.log_levels| +function lsp.set_log_level(level) + if type(level) == 'string' or type(level) == 'number' then + log.set_level(level) + else + error(string.format("Invalid log level: %q", level)) + end +end + +-- Return the path of the logfile used by the LSP client. +function lsp.get_log_path() + return log.get_filename() +end + +return lsp +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/builtin_callbacks.lua b/runtime/lua/vim/lsp/builtin_callbacks.lua new file mode 100644 index 0000000000..cc739ce3ad --- /dev/null +++ b/runtime/lua/vim/lsp/builtin_callbacks.lua @@ -0,0 +1,296 @@ +--- 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/log.lua b/runtime/lua/vim/lsp/log.lua new file mode 100644 index 0000000000..974eaae38c --- /dev/null +++ b/runtime/lua/vim/lsp/log.lua @@ -0,0 +1,95 @@ +-- Logger for language client plugin. + +local log = {} + +-- Log level dictionary with reverse lookup as well. +-- +-- 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 = { + TRACE = 0; + DEBUG = 1; + INFO = 2; + WARN = 3; + ERROR = 4; + -- FATAL = 4; +} + +-- Default log level is warn. +local current_log_level = log.levels.WARN +local log_date_format = "%FT%H:%M:%SZ%z" + +do + local path_sep = vim.loop.os_uname().sysname == "Windows" and "\\" or "/" + local function path_join(...) + return table.concat(vim.tbl_flatten{...}, path_sep) + end + local logfilename = path_join(vim.fn.stdpath('data'), 'vim-lsp.log') + + --- Return the log filename. + function log.get_filename() + return logfilename + end + + vim.fn.mkdir(vim.fn.stdpath('data'), "p") + local logfile = assert(io.open(logfilename, "a+")) + for level, levelnr in pairs(log.levels) do + -- Also export the log level on the root object. + log[level] = levelnr + -- Set the lowercase name as the main use function. + -- If called without arguments, it will check whether the log level is + -- greater than or equal to this one. When called with arguments, it will + -- log at that level (if applicable, it is checked either way). + -- + -- Recommended usage: + -- ``` + -- local _ = log.warn() and log.warn("123") + -- ``` + -- + -- This way you can avoid string allocations if the log level isn't high enough. + log[level:lower()] = function(...) + local argc = select("#", ...) + if levelnr < current_log_level then return false end + if argc == 0 then return true end + local info = debug.getinfo(2, "Sl") + local fileinfo = string.format("%s:%s", info.short_src, info.currentline) + local parts = { table.concat({"[", level, "]", os.date(log_date_format), "]", fileinfo, "]"}, " ") } + for i = 1, argc do + local arg = select(i, ...) + if arg == nil then + table.insert(parts, "nil") + else + table.insert(parts, vim.inspect(arg, {newline=''})) + end + end + logfile:write(table.concat(parts, '\t'), "\n") + logfile:flush() + end + end + -- Add some space to make it easier to distinguish different neovim runs. + logfile:write("\n") +end + +-- This is put here on purpose after the loop above so that it doesn't +-- interfere with iterating the levels +vim.tbl_add_reverse_lookup(log.levels) + +function log.set_level(level) + if type(level) == 'string' then + current_log_level = assert(log.levels[level:upper()], string.format("Invalid log level: %q", level)) + else + assert(type(level) == 'number', "level must be a number or string") + assert(log.levels[level], string.format("Invalid log level: %d", level)) + current_log_level = level + end +end + +-- Return whether the level is sufficient for logging. +-- @param level number log level +function log.should_log(level) + return level >= current_log_level +end + +return log +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua new file mode 100644 index 0000000000..1413a88ce2 --- /dev/null +++ b/runtime/lua/vim/lsp/protocol.lua @@ -0,0 +1,936 @@ +-- Protocol for the Microsoft Language Server Protocol (mslsp) + +local protocol = {} + +local function ifnil(a, b) + if a == nil then return b end + return a +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]] + nvim.command [[silent! '<,'>s/^\(\s*\) \* \=\(.*\)/\1--\2/]] +end +function transform_schema_to_table() + transform_schema_comments() + nvim.command [[silent! '<,'>s/: \S\+//]] + nvim.command [[silent! '<,'>s/export const //]] + nvim.command [[silent! '<,'>s/export namespace \(\S*\)\s*{/protocol.\1 = {/]] + nvim.command [[silent! '<,'>s/namespace \(\S*\)\s*{/protocol.\1 = {/]] +end +--]=] + +local constants = { + DiagnosticSeverity = { + -- Reports an error. + Error = 1; + -- Reports a warning. + Warning = 2; + -- Reports an information. + Information = 3; + -- Reports a hint. + Hint = 4; + }; + + MessageType = { + -- An error message. + Error = 1; + -- A warning message. + Warning = 2; + -- An information message. + Info = 3; + -- A log message. + Log = 4; + }; + + -- The file event type. + FileChangeType = { + -- The file got created. + Created = 1; + -- The file got changed. + Changed = 2; + -- The file got deleted. + Deleted = 3; + }; + + -- The kind of a completion entry. + CompletionItemKind = { + Text = 1; + Method = 2; + Function = 3; + Constructor = 4; + Field = 5; + Variable = 6; + Class = 7; + Interface = 8; + Module = 9; + Property = 10; + Unit = 11; + Value = 12; + Enum = 13; + Keyword = 14; + Snippet = 15; + Color = 16; + File = 17; + Reference = 18; + Folder = 19; + EnumMember = 20; + Constant = 21; + Struct = 22; + Event = 23; + Operator = 24; + TypeParameter = 25; + }; + + -- How a completion was triggered + CompletionTriggerKind = { + -- Completion was triggered by typing an identifier (24x7 code + -- complete), manual invocation (e.g Ctrl+Space) or via API. + Invoked = 1; + -- Completion was triggered by a trigger character specified by + -- the `triggerCharacters` properties of the `CompletionRegistrationOptions`. + TriggerCharacter = 2; + -- Completion was re-triggered as the current completion list is incomplete. + TriggerForIncompleteCompletions = 3; + }; + + -- A document highlight kind. + DocumentHighlightKind = { + -- A textual occurrence. + Text = 1; + -- Read-access of a symbol, like reading a variable. + Read = 2; + -- Write-access of a symbol, like writing to a variable. + Write = 3; + }; + + -- A symbol kind. + SymbolKind = { + File = 1; + Module = 2; + Namespace = 3; + Package = 4; + Class = 5; + Method = 6; + Property = 7; + Field = 8; + Constructor = 9; + Enum = 10; + Interface = 11; + Function = 12; + Variable = 13; + Constant = 14; + String = 15; + Number = 16; + Boolean = 17; + Array = 18; + Object = 19; + Key = 20; + Null = 21; + EnumMember = 22; + Struct = 23; + Event = 24; + Operator = 25; + TypeParameter = 26; + }; + + -- Represents reasons why a text document is saved. + TextDocumentSaveReason = { + -- Manually triggered, e.g. by the user pressing save, by starting debugging, + -- or by an API call. + Manual = 1; + -- Automatic after a delay. + AfterDelay = 2; + -- When the editor lost focus. + FocusOut = 3; + }; + + ErrorCodes = { + -- Defined by JSON RPC + ParseError = -32700; + InvalidRequest = -32600; + MethodNotFound = -32601; + InvalidParams = -32602; + InternalError = -32603; + serverErrorStart = -32099; + serverErrorEnd = -32000; + ServerNotInitialized = -32002; + UnknownErrorCode = -32001; + -- Defined by the protocol. + RequestCancelled = -32800; + ContentModified = -32801; + }; + + -- Describes the content type that a client supports in various + -- result literals like `Hover`, `ParameterInfo` or `CompletionItem`. + -- + -- Please note that `MarkupKinds` must not start with a `$`. This kinds + -- are reserved for internal usage. + MarkupKind = { + -- Plain text is supported as a content format + PlainText = 'plaintext'; + -- Markdown is supported as a content format + Markdown = 'markdown'; + }; + + ResourceOperationKind = { + -- Supports creating new files and folders. + Create = 'create'; + -- Supports renaming existing files and folders. + Rename = 'rename'; + -- Supports deleting existing files and folders. + Delete = 'delete'; + }; + + FailureHandlingKind = { + -- Applying the workspace change is simply aborted if one of the changes provided + -- fails. All operations executed before the failing operation stay executed. + Abort = 'abort'; + -- All operations are executed transactionally. That means they either all + -- succeed or no changes at all are applied to the workspace. + Transactional = 'transactional'; + -- If the workspace edit contains only textual file changes they are executed transactionally. + -- If resource changes (create, rename or delete file) are part of the change the failure + -- handling strategy is abort. + TextOnlyTransactional = 'textOnlyTransactional'; + -- The client tries to undo the operations already executed. But there is no + -- guarantee that this succeeds. + Undo = 'undo'; + }; + + -- Known error codes for an `InitializeError`; + InitializeError = { + -- If the protocol version provided by the client can't be handled by the server. + -- @deprecated This initialize error got replaced by client capabilities. There is + -- no version handshake in version 3.0x + unknownProtocolVersion = 1; + }; + + -- Defines how the host (editor) should sync document changes to the language server. + TextDocumentSyncKind = { + -- Documents should not be synced at all. + None = 0; + -- Documents are synced by always sending the full content + -- of the document. + Full = 1; + -- Documents are synced by sending the full content on open. + -- After that only incremental updates to the document are + -- send. + Incremental = 2; + }; + + WatchKind = { + -- Interested in create events. + Create = 1; + -- Interested in change events + Change = 2; + -- Interested in delete events + Delete = 4; + }; + + -- Defines whether the insert text in a completion item should be interpreted as + -- plain text or a snippet. + InsertTextFormat = { + -- The primary text to be inserted is treated as a plain string. + PlainText = 1; + -- The primary text to be inserted is treated as a snippet. + -- + -- A snippet can define tab stops and placeholders with `$1`, `$2` + -- and `${3:foo};`. `$0` defines the final tab stop, it defaults to + -- the end of the snippet. Placeholders with equal identifiers are linked, + -- that is typing in one will update others too. + Snippet = 2; + }; + + -- A set of predefined code action kinds + CodeActionKind = { + -- Empty kind. + Empty = ''; + -- Base kind for quickfix actions + QuickFix = 'quickfix'; + -- Base kind for refactoring actions + Refactor = 'refactor'; + -- Base kind for refactoring extraction actions + -- + -- Example extract actions: + -- + -- - Extract method + -- - Extract function + -- - Extract variable + -- - Extract interface from class + -- - ... + RefactorExtract = 'refactor.extract'; + -- Base kind for refactoring inline actions + -- + -- Example inline actions: + -- + -- - Inline function + -- - Inline variable + -- - Inline constant + -- - ... + RefactorInline = 'refactor.inline'; + -- Base kind for refactoring rewrite actions + -- + -- Example rewrite actions: + -- + -- - Convert JavaScript function to class + -- - Add or remove parameter + -- - Encapsulate field + -- - Make method static + -- - Move method to base class + -- - ... + RefactorRewrite = 'refactor.rewrite'; + -- Base kind for source actions + -- + -- Source code actions apply to the entire file. + Source = 'source'; + -- Base kind for an organize imports source action + SourceOrganizeImports = 'source.organizeImports'; + }; +} + +for k, v in pairs(constants) do + vim.tbl_add_reverse_lookup(v) + protocol[k] = v +end + +--[=[ +--Text document specific client capabilities. +export interface TextDocumentClientCapabilities { + synchronization?: { + --Whether text document synchronization supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports sending will save notifications. + willSave?: boolean; + --The client supports sending a will save request and + --waits for a response providing text edits which will + --be applied to the document before it is saved. + willSaveWaitUntil?: boolean; + --The client supports did save notifications. + didSave?: boolean; + } + --Capabilities specific to the `textDocument/completion` + completion?: { + --Whether completion supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports the following `CompletionItem` specific + --capabilities. + completionItem?: { + --The client supports snippets as insert text. + -- + --A snippet can define tab stops and placeholders with `$1`, `$2` + --and `${3:foo}`. `$0` defines the final tab stop, it defaults to + --the end of the snippet. Placeholders with equal identifiers are linked, + --that is typing in one will update others too. + snippetSupport?: boolean; + --The client supports commit characters on a completion item. + commitCharactersSupport?: boolean + --The client supports the following content formats for the documentation + --property. The order describes the preferred format of the client. + documentationFormat?: MarkupKind[]; + --The client supports the deprecated property on a completion item. + deprecatedSupport?: boolean; + --The client supports the preselect property on a completion item. + preselectSupport?: boolean; + } + completionItemKind?: { + --The completion item kind values the client supports. When this + --property exists the client also guarantees that it will + --handle values outside its set gracefully and falls back + --to a default value when unknown. + -- + --If this property is not present the client only supports + --the completion items kinds from `Text` to `Reference` as defined in + --the initial version of the protocol. + valueSet?: CompletionItemKind[]; + }, + --The client supports to send additional context information for a + --`textDocument/completion` request. + contextSupport?: boolean; + }; + --Capabilities specific to the `textDocument/hover` + hover?: { + --Whether hover supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports the follow content formats for the content + --property. The order describes the preferred format of the client. + contentFormat?: MarkupKind[]; + }; + --Capabilities specific to the `textDocument/signatureHelp` + signatureHelp?: { + --Whether signature help supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports the following `SignatureInformation` + --specific properties. + signatureInformation?: { + --The client supports the follow content formats for the documentation + --property. The order describes the preferred format of the client. + documentationFormat?: MarkupKind[]; + --Client capabilities specific to parameter information. + parameterInformation?: { + --The client supports processing label offsets instead of a + --simple label string. + -- + --Since 3.14.0 + labelOffsetSupport?: boolean; + } + }; + }; + --Capabilities specific to the `textDocument/references` + references?: { + --Whether references supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/documentHighlight` + documentHighlight?: { + --Whether document highlight supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/documentSymbol` + documentSymbol?: { + --Whether document symbol supports dynamic registration. + dynamicRegistration?: boolean; + --Specific capabilities for the `SymbolKind`. + symbolKind?: { + --The symbol kind values the client supports. When this + --property exists the client also guarantees that it will + --handle values outside its set gracefully and falls back + --to a default value when unknown. + -- + --If this property is not present the client only supports + --the symbol kinds from `File` to `Array` as defined in + --the initial version of the protocol. + valueSet?: SymbolKind[]; + } + --The client supports hierarchical document symbols. + hierarchicalDocumentSymbolSupport?: boolean; + }; + --Capabilities specific to the `textDocument/formatting` + formatting?: { + --Whether formatting supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/rangeFormatting` + rangeFormatting?: { + --Whether range formatting supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/onTypeFormatting` + onTypeFormatting?: { + --Whether on type formatting supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/declaration` + declaration?: { + --Whether declaration supports dynamic registration. If this is set to `true` + --the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + --return value for the corresponding server capability as well. + dynamicRegistration?: boolean; + --The client supports additional metadata in the form of declaration links. + -- + --Since 3.14.0 + linkSupport?: boolean; + }; + --Capabilities specific to the `textDocument/definition`. + -- + --Since 3.14.0 + definition?: { + --Whether definition supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports additional metadata in the form of definition links. + linkSupport?: boolean; + }; + --Capabilities specific to the `textDocument/typeDefinition` + -- + --Since 3.6.0 + typeDefinition?: { + --Whether typeDefinition supports dynamic registration. If this is set to `true` + --the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + --return value for the corresponding server capability as well. + dynamicRegistration?: boolean; + --The client supports additional metadata in the form of definition links. + -- + --Since 3.14.0 + linkSupport?: boolean; + }; + --Capabilities specific to the `textDocument/implementation`. + -- + --Since 3.6.0 + implementation?: { + --Whether implementation supports dynamic registration. If this is set to `true` + --the client supports the new `(TextDocumentRegistrationOptions & StaticRegistrationOptions)` + --return value for the corresponding server capability as well. + dynamicRegistration?: boolean; + --The client supports additional metadata in the form of definition links. + -- + --Since 3.14.0 + linkSupport?: boolean; + }; + --Capabilities specific to the `textDocument/codeAction` + codeAction?: { + --Whether code action supports dynamic registration. + dynamicRegistration?: boolean; + --The client support code action literals as a valid + --response of the `textDocument/codeAction` request. + -- + --Since 3.8.0 + codeActionLiteralSupport?: { + --The code action kind is support with the following value + --set. + codeActionKind: { + --The code action kind values the client supports. When this + --property exists the client also guarantees that it will + --handle values outside its set gracefully and falls back + --to a default value when unknown. + valueSet: CodeActionKind[]; + }; + }; + }; + --Capabilities specific to the `textDocument/codeLens` + codeLens?: { + --Whether code lens supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/documentLink` + documentLink?: { + --Whether document link supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `textDocument/documentColor` and the + --`textDocument/colorPresentation` request. + -- + --Since 3.6.0 + colorProvider?: { + --Whether colorProvider supports dynamic registration. If this is set to `true` + --the client supports the new `(ColorProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions)` + --return value for the corresponding server capability as well. + dynamicRegistration?: boolean; + } + --Capabilities specific to the `textDocument/rename` + rename?: { + --Whether rename supports dynamic registration. + dynamicRegistration?: boolean; + --The client supports testing for validity of rename operations + --before execution. + prepareSupport?: boolean; + }; + --Capabilities specific to `textDocument/publishDiagnostics`. + publishDiagnostics?: { + --Whether the clients accepts diagnostics with related information. + relatedInformation?: boolean; + }; + --Capabilities specific to `textDocument/foldingRange` requests. + -- + --Since 3.10.0 + foldingRange?: { + --Whether implementation supports dynamic registration for folding range providers. If this is set to `true` + --the client supports the new `(FoldingRangeProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions)` + --return value for the corresponding server capability as well. + dynamicRegistration?: boolean; + --The maximum number of folding ranges that the client prefers to receive per document. The value serves as a + --hint, servers are free to follow the limit. + rangeLimit?: number; + --If set, the client signals that it only supports folding complete lines. If set, client will + --ignore specified `startCharacter` and `endCharacter` properties in a FoldingRange. + lineFoldingOnly?: boolean; + }; +} +--]=] + +--[=[ +--Workspace specific client capabilities. +export interface WorkspaceClientCapabilities { + --The client supports applying batch edits to the workspace by supporting + --the request 'workspace/applyEdit' + applyEdit?: boolean; + --Capabilities specific to `WorkspaceEdit`s + workspaceEdit?: { + --The client supports versioned document changes in `WorkspaceEdit`s + documentChanges?: boolean; + --The resource operations the client supports. Clients should at least + --support 'create', 'rename' and 'delete' files and folders. + resourceOperations?: ResourceOperationKind[]; + --The failure handling strategy of a client if applying the workspace edit + --fails. + failureHandling?: FailureHandlingKind; + }; + --Capabilities specific to the `workspace/didChangeConfiguration` notification. + didChangeConfiguration?: { + --Did change configuration notification supports dynamic registration. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `workspace/didChangeWatchedFiles` notification. + didChangeWatchedFiles?: { + --Did change watched files notification supports dynamic registration. Please note + --that the current protocol doesn't support static configuration for file changes + --from the server side. + dynamicRegistration?: boolean; + }; + --Capabilities specific to the `workspace/symbol` request. + symbol?: { + --Symbol request supports dynamic registration. + dynamicRegistration?: boolean; + --Specific capabilities for the `SymbolKind` in the `workspace/symbol` request. + symbolKind?: { + --The symbol kind values the client supports. When this + --property exists the client also guarantees that it will + --handle values outside its set gracefully and falls back + --to a default value when unknown. + -- + --If this property is not present the client only supports + --the symbol kinds from `File` to `Array` as defined in + --the initial version of the protocol. + valueSet?: SymbolKind[]; + } + }; + --Capabilities specific to the `workspace/executeCommand` request. + executeCommand?: { + --Execute command supports dynamic registration. + dynamicRegistration?: boolean; + }; + --The client has support for workspace folders. + -- + --Since 3.6.0 + workspaceFolders?: boolean; + --The client supports `workspace/configuration` requests. + -- + --Since 3.6.0 + configuration?: boolean; +} +--]=] + +function protocol.make_client_capabilities() + return { + textDocument = { + synchronization = { + dynamicRegistration = false; + + -- TODO(ashkan) Send textDocument/willSave before saving (BufWritePre) + willSave = false; + + -- TODO(ashkan) Implement textDocument/willSaveWaitUntil + willSaveWaitUntil = false; + + -- Send textDocument/didSave after saving (BufWritePost) + didSave = true; + }; + completion = { + dynamicRegistration = false; + completionItem = { + + -- TODO(tjdevries): Is it possible to implement this in plain lua? + snippetSupport = false; + commitCharactersSupport = false; + preselectSupport = false; + deprecatedSupport = false; + documentationFormat = { protocol.MarkupKind.Markdown; protocol.MarkupKind.PlainText }; + }; + completionItemKind = { + valueSet = (function() + local res = {} + for k in pairs(protocol.CompletionItemKind) do + if type(k) == 'number' then table.insert(res, k) end + end + return res + end)(); + }; + + -- TODO(tjdevries): Implement this + contextSupport = false; + }; + hover = { + dynamicRegistration = false; + contentFormat = { protocol.MarkupKind.Markdown; protocol.MarkupKind.PlainText }; + }; + signatureHelp = { + dynamicRegistration = false; + signatureInformation = { + documentationFormat = { protocol.MarkupKind.Markdown; protocol.MarkupKind.PlainText }; + -- parameterInformation = { + -- labelOffsetSupport = false; + -- }; + }; + }; + references = { + dynamicRegistration = false; + }; + documentHighlight = { + dynamicRegistration = false + }; + -- documentSymbol = { + -- dynamicRegistration = false; + -- symbolKind = { + -- valueSet = (function() + -- local res = {} + -- for k in pairs(protocol.SymbolKind) do + -- if type(k) == 'string' then table.insert(res, k) end + -- end + -- return res + -- end)(); + -- }; + -- hierarchicalDocumentSymbolSupport = false; + -- }; + }; + workspace = nil; + experimental = nil; + } +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`. + language?: string; + --A Uri [scheme](#Uri.scheme), like `file` or `untitled`. + scheme?: string; + --A glob pattern, like `*.{ts,js}`. + -- + --Glob patterns can have the following syntax: + --- `*` to match one or more characters in a path segment + --- `?` to match on one character in a path segment + --- `**` to match any number of path segments, including none + --- `{}` to group conditions (e.g. `**/*.{ts,js}` matches all TypeScript and JavaScript files) + --- `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) + --- `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) + pattern?: string; +} +--]=] + +--[[ +--Static registration options to be returned in the initialize request. +interface StaticRegistrationOptions { + --The id used to register the request. The id can be used to deregister + --the request again. See also Registration#id. + id?: string; +} + +export interface DocumentFilter { + --A language id, like `typescript`. + language?: string; + --A Uri [scheme](#Uri.scheme), like `file` or `untitled`. + scheme?: string; + --A glob pattern, like `*.{ts,js}`. + -- + --Glob patterns can have the following syntax: + --- `*` to match one or more characters in a path segment + --- `?` to match on one character in a path segment + --- `**` to match any number of path segments, including none + --- `{}` to group conditions (e.g. `**/*.{ts,js}` matches all TypeScript and JavaScript files) + --- `[]` to declare a range of characters to match in a path segment (e.g., `example.[0-9]` to match on `example.0`, `example.1`, …) + --- `[!...]` to negate a range of characters to match in a path segment (e.g., `example.[!0-9]` to match on `example.a`, `example.b`, but not `example.0`) + pattern?: string; +} +export type DocumentSelector = DocumentFilter[]; +export interface TextDocumentRegistrationOptions { + --A document selector to identify the scope of the registration. If set to null + --the document selector provided on the client side will be used. + documentSelector: DocumentSelector | null; +} + +--Code Action options. +export interface CodeActionOptions { + --CodeActionKinds that this server may return. + -- + --The list of kinds may be generic, such as `CodeActionKind.Refactor`, or the server + --may list out every specific kind they provide. + codeActionKinds?: CodeActionKind[]; +} + +interface ServerCapabilities { + --Defines how text documents are synced. Is either a detailed structure defining each notification or + --for backwards compatibility the TextDocumentSyncKind number. If omitted it defaults to `TextDocumentSyncKind.None`. + textDocumentSync?: TextDocumentSyncOptions | number; + --The server provides hover support. + hoverProvider?: boolean; + --The server provides completion support. + completionProvider?: CompletionOptions; + --The server provides signature help support. + signatureHelpProvider?: SignatureHelpOptions; + --The server provides goto definition support. + definitionProvider?: boolean; + --The server provides Goto Type Definition support. + -- + --Since 3.6.0 + typeDefinitionProvider?: boolean | (TextDocumentRegistrationOptions & StaticRegistrationOptions); + --The server provides Goto Implementation support. + -- + --Since 3.6.0 + implementationProvider?: boolean | (TextDocumentRegistrationOptions & StaticRegistrationOptions); + --The server provides find references support. + referencesProvider?: boolean; + --The server provides document highlight support. + documentHighlightProvider?: boolean; + --The server provides document symbol support. + documentSymbolProvider?: boolean; + --The server provides workspace symbol support. + workspaceSymbolProvider?: boolean; + --The server provides code actions. The `CodeActionOptions` return type is only + --valid if the client signals code action literal support via the property + --`textDocument.codeAction.codeActionLiteralSupport`. + codeActionProvider?: boolean | CodeActionOptions; + --The server provides code lens. + codeLensProvider?: CodeLensOptions; + --The server provides document formatting. + documentFormattingProvider?: boolean; + --The server provides document range formatting. + documentRangeFormattingProvider?: boolean; + --The server provides document formatting on typing. + documentOnTypeFormattingProvider?: DocumentOnTypeFormattingOptions; + --The server provides rename support. RenameOptions may only be + --specified if the client states that it supports + --`prepareSupport` in its initial `initialize` request. + renameProvider?: boolean | RenameOptions; + --The server provides document link support. + documentLinkProvider?: DocumentLinkOptions; + --The server provides color provider support. + -- + --Since 3.6.0 + colorProvider?: boolean | ColorProviderOptions | (ColorProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions); + --The server provides folding provider support. + -- + --Since 3.10.0 + foldingRangeProvider?: boolean | FoldingRangeProviderOptions | (FoldingRangeProviderOptions & TextDocumentRegistrationOptions & StaticRegistrationOptions); + --The server provides go to declaration support. + -- + --Since 3.14.0 + declarationProvider?: boolean | (TextDocumentRegistrationOptions & StaticRegistrationOptions); + --The server provides execute command support. + executeCommandProvider?: ExecuteCommandOptions; + --Workspace specific server capabilities + workspace?: { + --The server supports workspace folder. + -- + --Since 3.6.0 + workspaceFolders?: { + * The server has support for workspace folders + supported?: boolean; + * Whether the server wants to receive workspace folder + * change notifications. + * + * If a strings is provided the string is treated as a ID + * under which the notification is registered on the client + * side. The ID can be used to unregister for these events + * using the `client/unregisterCapability` request. + changeNotifications?: string | boolean; + } + } + --Experimental server capabilities. + experimental?: any; +} +--]] +function protocol.resolve_capabilities(server_capabilities) + local general_properties = {} + local text_document_sync_properties + do + local TextDocumentSyncKind = protocol.TextDocumentSyncKind + local textDocumentSync = server_capabilities.textDocumentSync + if textDocumentSync == nil then + -- Defaults if omitted. + text_document_sync_properties = { + text_document_open_close = false; + text_document_did_change = TextDocumentSyncKind.None; +-- text_document_did_change = false; + text_document_will_save = false; + text_document_will_save_wait_until = false; + text_document_save = false; + text_document_save_include_text = false; + } + elseif type(textDocumentSync) == 'number' then + -- Backwards compatibility + if not TextDocumentSyncKind[textDocumentSync] then + return nil, "Invalid server TextDocumentSyncKind for textDocumentSync" + end + text_document_sync_properties = { + text_document_open_close = true; + text_document_did_change = textDocumentSync; + text_document_will_save = false; + text_document_will_save_wait_until = false; + text_document_save = false; + text_document_save_include_text = false; + } + elseif type(textDocumentSync) == 'table' then + text_document_sync_properties = { + text_document_open_close = ifnil(textDocumentSync.openClose, false); + text_document_did_change = ifnil(textDocumentSync.change, TextDocumentSyncKind.None); + text_document_will_save = ifnil(textDocumentSync.willSave, false); + text_document_will_save_wait_until = ifnil(textDocumentSync.willSaveWaitUntil, false); + text_document_save = ifnil(textDocumentSync.save, false); + text_document_save_include_text = ifnil(textDocumentSync.save and textDocumentSync.save.includeText, false); + } + else + return nil, string.format("Invalid type for textDocumentSync: %q", type(textDocumentSync)) + end + end + general_properties.hover = server_capabilities.hoverProvider or false + general_properties.goto_definition = server_capabilities.definitionProvider or false + general_properties.find_references = server_capabilities.referencesProvider or false + general_properties.document_highlight = server_capabilities.documentHighlightProvider or false + general_properties.document_symbol = server_capabilities.documentSymbolProvider or false + general_properties.workspace_symbol = server_capabilities.workspaceSymbolProvider or false + general_properties.document_formatting = server_capabilities.documentFormattingProvider or false + general_properties.document_range_formatting = server_capabilities.documentRangeFormattingProvider or false + + if server_capabilities.codeActionProvider == nil then + general_properties.code_action = false + elseif type(server_capabilities.codeActionProvider) == 'boolean' then + general_properties.code_action = server_capabilities.codeActionProvider + elseif type(server_capabilities.codeActionProvider) == 'table' then + -- TODO(ashkan) support CodeActionKind + general_properties.code_action = false + else + error("The server sent invalid codeActionProvider") + end + + if server_capabilities.implementationProvider == nil then + general_properties.implementation = false + elseif type(server_capabilities.implementationProvider) == 'boolean' then + general_properties.implementation = server_capabilities.implementationProvider + elseif type(server_capabilities.implementationProvider) == 'table' then + -- TODO(ashkan) support more detailed implementation options. + general_properties.implementation = false + else + error("The server sent invalid implementationProvider") + end + + local signature_help_properties + if server_capabilities.signatureHelpProvider == nil then + signature_help_properties = { + signature_help = false; + signature_help_trigger_characters = {}; + } + elseif type(server_capabilities.signatureHelpProvider) == 'table' then + signature_help_properties = { + signature_help = true; + -- The characters that trigger signature help automatically. + signature_help_trigger_characters = server_capabilities.signatureHelpProvider.triggerCharacters or {}; + } + else + error("The server sent invalid signatureHelpProvider") + end + + return vim.tbl_extend("error" + , text_document_sync_properties + , signature_help_properties + , general_properties + ) +end + +return protocol +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua new file mode 100644 index 0000000000..e0ec8863d6 --- /dev/null +++ b/runtime/lua/vim/lsp/rpc.lua @@ -0,0 +1,451 @@ +local uv = vim.loop +local log = require('vim.lsp.log') +local protocol = require('vim.lsp.protocol') +local validate, schedule, schedule_wrap = vim.validate, vim.schedule, vim.schedule_wrap + +-- TODO replace with a better implementation. +local function json_encode(data) + local status, result = pcall(vim.fn.json_encode, data) + if status then + return result + else + return nil, result + end +end +local function json_decode(data) + local status, result = pcall(vim.fn.json_decode, data) + if status then + return result + else + return nil, result + end +end + +local function is_dir(filename) + local stat = vim.loop.fs_stat(filename) + return stat and stat.type == 'directory' or false +end + +local NIL = vim.NIL +local function convert_NIL(v) + if v == NIL then return nil end + return v +end + +-- If a dictionary is passed in, turn it into a list of string of "k=v" +-- Accepts a table which can be composed of k=v strings or map-like +-- specification, such as: +-- +-- ``` +-- { +-- "PRODUCTION=false"; +-- "PATH=/usr/bin/"; +-- PORT = 123; +-- HOST = "0.0.0.0"; +-- } +-- ``` +-- +-- Non-string values will be cast with `tostring` +local function force_env_list(final_env) + if final_env then + local env = final_env + final_env = {} + for k,v in pairs(env) do + -- If it's passed in as a dict, then convert to list of "k=v" + if type(k) == "string" then + table.insert(final_env, k..'='..tostring(v)) + elseif type(v) == 'string' then + table.insert(final_env, v) + else + -- TODO is this right or should I exception here? + -- Try to coerce other values to string. + table.insert(final_env, tostring(v)) + end + end + return final_env + end +end + +local function format_message_with_content_length(encoded_message) + return table.concat { + 'Content-Length: '; tostring(#encoded_message); '\r\n\r\n'; + encoded_message; + } +end + +--- Parse an LSP Message's header +-- @param header: The header to parse. +local function parse_headers(header) + if type(header) ~= 'string' then + return nil + end + local headers = {} + for line in vim.gsplit(header, '\r\n', true) do + if line == '' then + break + end + local key, value = line:match("^%s*(%S+)%s*:%s*(.+)%s*$") + if key then + key = key:lower():gsub('%-', '_') + headers[key] = value + else + local _ = log.error() and log.error("invalid header line %q", line) + error(string.format("invalid header line %q", line)) + end + end + headers.content_length = tonumber(headers.content_length) + or error(string.format("Content-Length not found in headers. %q", header)) + return headers +end + +-- This is the start of any possible header patterns. The gsub converts it to a +-- case insensitive pattern. +local header_start_pattern = ("content"):gsub("%w", function(c) return "["..c..c:upper().."]" end) + +local function request_parser_loop() + local buffer = '' + while true do + -- A message can only be complete if it has a double CRLF and also the full + -- payload, so first let's check for the CRLFs + local start, finish = buffer:find('\r\n\r\n', 1, true) + -- Start parsing the headers + if start then + -- This is a workaround for servers sending initial garbage before + -- sending headers, such as if a bash script sends stdout. It assumes + -- that we know all of the headers ahead of time. At this moment, the + -- only valid headers start with "Content-*", so that's the thing we will + -- be searching for. + -- TODO(ashkan) I'd like to remove this, but it seems permanent :( + local buffer_start = buffer:find(header_start_pattern) + local headers = parse_headers(buffer:sub(buffer_start, start-1)) + buffer = buffer:sub(finish+1) + local content_length = headers.content_length + -- Keep waiting for data until we have enough. + while #buffer < content_length do + buffer = buffer..(coroutine.yield() + or error("Expected more data for the body. The server may have died.")) -- TODO hmm. + end + local body = buffer:sub(1, content_length) + buffer = buffer:sub(content_length + 1) + -- Yield our data. + buffer = buffer..(coroutine.yield(headers, body) + or error("Expected more data for the body. The server may have died.")) -- TODO hmm. + else + -- Get more data since we don't have enough. + buffer = buffer..(coroutine.yield() + or error("Expected more data for the header. The server may have died.")) -- TODO hmm. + end + end +end + +local client_errors = vim.tbl_add_reverse_lookup { + INVALID_SERVER_MESSAGE = 1; + INVALID_SERVER_JSON = 2; + NO_RESULT_CALLBACK_FOUND = 3; + READ_ERROR = 4; + NOTIFICATION_HANDLER_ERROR = 5; + SERVER_REQUEST_HANDLER_ERROR = 6; + SERVER_RESULT_CALLBACK_ERROR = 7; +} + +local function format_rpc_error(err) + validate { + err = { err, 't' }; + } + local code_name = assert(protocol.ErrorCodes[err.code], "err.code is invalid") + local message_parts = {"RPC", code_name} + if err.message then + table.insert(message_parts, "message = ") + table.insert(message_parts, string.format("%q", err.message)) + end + if err.data then + table.insert(message_parts, "data = ") + table.insert(message_parts, vim.inspect(err.data)) + end + return table.concat(message_parts, ' ') +end + +local function rpc_response_error(code, message, data) + -- TODO should this error or just pick a sane error (like InternalError)? + local code_name = assert(protocol.ErrorCodes[code], 'Invalid rpc error code') + return setmetatable({ + code = code; + message = message or code_name; + data = data; + }, { + __tostring = format_rpc_error; + }) +end + +local default_handlers = {} +function default_handlers.notification(method, params) + local _ = log.debug() and log.debug('notification', method, params) +end +function default_handlers.server_request(method, params) + local _ = log.debug() and log.debug('server_request', method, params) + return nil, rpc_response_error(protocol.ErrorCodes.MethodNotFound) +end +function default_handlers.on_exit(code, signal) + local _ = log.info() and log.info("client exit", { code = code, signal = signal }) +end +function default_handlers.on_error(code, err) + local _ = log.error() and log.error('client_error:', client_errors[code], err) +end + +--- Create and start an RPC client. +-- @param cmd [ +local function create_and_start_client(cmd, cmd_args, handlers, extra_spawn_params) + local _ = log.info() and log.info("Starting RPC client", {cmd = cmd, args = cmd_args, extra = extra_spawn_params}) + validate { + cmd = { cmd, 's' }; + cmd_args = { cmd_args, 't' }; + handlers = { handlers, 't', true }; + } + + if not (vim.fn.executable(cmd) == 1) then + error(string.format("The given command %q is not executable.", cmd)) + end + if handlers then + local user_handlers = handlers + handlers = {} + for handle_name, default_handler in pairs(default_handlers) do + local user_handler = user_handlers[handle_name] + if user_handler then + if type(user_handler) ~= 'function' then + error(string.format("handler.%s must be a function", handle_name)) + end + -- server_request is wrapped elsewhere. + if not (handle_name == 'server_request' + or handle_name == 'on_exit') -- TODO this blocks the loop exiting for some reason. + then + user_handler = schedule_wrap(user_handler) + end + handlers[handle_name] = user_handler + else + handlers[handle_name] = default_handler + end + end + else + handlers = default_handlers + end + + local stdin = uv.new_pipe(false) + local stdout = uv.new_pipe(false) + local stderr = uv.new_pipe(false) + + local message_index = 0 + local message_callbacks = {} + + local handle, pid + do + local function onexit(code, signal) + stdin:close() + stdout:close() + stderr:close() + handle:close() + -- Make sure that message_callbacks can be gc'd. + message_callbacks = nil + handlers.on_exit(code, signal) + end + local spawn_params = { + args = cmd_args; + stdio = {stdin, stdout, stderr}; + } + if extra_spawn_params then + spawn_params.cwd = extra_spawn_params.cwd + if spawn_params.cwd then + assert(is_dir(spawn_params.cwd), "cwd must be a directory") + end + spawn_params.env = force_env_list(extra_spawn_params.env) + end + handle, pid = uv.spawn(cmd, spawn_params, onexit) + end + + local function encode_and_send(payload) + local _ = log.debug() and log.debug("rpc.send.payload", payload) + if handle:is_closing() then return false end + -- TODO(ashkan) remove this once we have a Lua json_encode + schedule(function() + local encoded = assert(json_encode(payload)) + stdin:write(format_message_with_content_length(encoded)) + end) + return true + end + + local function send_notification(method, params) + local _ = log.debug() and log.debug("rpc.notify", method, params) + return encode_and_send { + jsonrpc = "2.0"; + method = method; + params = params; + } + end + + local function send_response(request_id, err, result) + return encode_and_send { + id = request_id; + jsonrpc = "2.0"; + error = err; + result = result; + } + end + + local function send_request(method, params, callback) + validate { + callback = { callback, 'f' }; + } + message_index = message_index + 1 + local message_id = message_index + local result = encode_and_send { + id = message_id; + jsonrpc = "2.0"; + method = method; + params = params; + } + if result then + message_callbacks[message_id] = schedule_wrap(callback) + return result, message_id + else + return false + end + end + + stderr:read_start(function(_err, chunk) + if chunk then + local _ = log.error() and log.error("rpc", cmd, "stderr", chunk) + end + end) + + local function on_error(errkind, ...) + assert(client_errors[errkind]) + -- TODO what to do if this fails? + pcall(handlers.on_error, errkind, ...) + end + local function pcall_handler(errkind, status, head, ...) + if not status then + on_error(errkind, head, ...) + return status, head + end + return status, head, ... + end + local function try_call(errkind, fn, ...) + return pcall_handler(errkind, pcall(fn, ...)) + end + + -- TODO periodically check message_callbacks for old requests past a certain + -- time and log them. This would require storing the timestamp. I could call + -- them with an error then, perhaps. + + local function handle_body(body) + local decoded, err = json_decode(body) + if not decoded then + on_error(client_errors.INVALID_SERVER_JSON, err) + end + local _ = log.debug() and log.debug("decoded", decoded) + + if type(decoded.method) == 'string' and decoded.id then + -- Server Request + decoded.params = convert_NIL(decoded.params) + -- Schedule here so that the users functions don't trigger an error and + -- we can still use the result. + schedule(function() + local status, result + status, result, err = try_call(client_errors.SERVER_REQUEST_HANDLER_ERROR, + handlers.server_request, decoded.method, decoded.params) + local _ = log.debug() and log.debug("server_request: callback result", { status = status, result = result, err = err }) + if status then + if not (result or err) then + -- TODO this can be a problem if `null` is sent for result. needs vim.NIL + error(string.format("method %q: either a result or an error must be sent to the server in response", decoded.method)) + end + if err then + assert(type(err) == 'table', "err must be a table. Use rpc_response_error to help format errors.") + local code_name = assert(protocol.ErrorCodes[err.code], "Errors must use protocol.ErrorCodes. Use rpc_response_error to help format errors.") + err.message = err.message or code_name + end + else + -- On an exception, result will contain the error message. + err = rpc_response_error(protocol.ErrorCodes.InternalError, result) + result = nil + end + 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 + -- 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) + local callback = message_callbacks[result_id] + if callback then + message_callbacks[result_id] = nil + validate { + callback = { callback, 'f' }; + } + if decoded.error then + decoded.error = setmetatable(decoded.error, { + __tostring = format_rpc_error; + }) + end + try_call(client_errors.SERVER_RESULT_CALLBACK_ERROR, + callback, decoded.error, decoded.result) + else + on_error(client_errors.NO_RESULT_CALLBACK_FOUND, decoded) + local _ = log.error() and log.error("No callback found for server response id "..result_id) + end + elseif type(decoded.method) == 'string' then + -- Notification + decoded.params = convert_NIL(decoded.params) + try_call(client_errors.NOTIFICATION_HANDLER_ERROR, + handlers.notification, decoded.method, decoded.params) + else + -- Invalid server message + on_error(client_errors.INVALID_SERVER_MESSAGE, decoded) + end + end + -- TODO(ashkan) remove this once we have a Lua json_decode + handle_body = schedule_wrap(handle_body) + + local request_parser = coroutine.wrap(request_parser_loop) + request_parser() + stdout:read_start(function(err, chunk) + if err then + -- TODO better handling. Can these be intermittent errors? + on_error(client_errors.READ_ERROR, err) + return + end + -- This should signal that we are done reading from the client. + if not chunk then return end + -- Flush anything in the parser by looping until we don't get a result + -- anymore. + while true do + local headers, body = request_parser(chunk) + -- If we successfully parsed, then handle the response. + if headers then + handle_body(body) + -- Set chunk to empty so that we can call request_parser to get + -- anything existing in the parser to flush. + chunk = '' + else + break + end + end + end) + + return { + pid = pid; + handle = handle; + request = send_request; + notify = send_notification; + } +end + +return { + start = create_and_start_client; + rpc_response_error = rpc_response_error; + format_rpc_error = format_rpc_error; + client_errors = client_errors; +} +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua new file mode 100644 index 0000000000..f96e0f01a8 --- /dev/null +++ b/runtime/lua/vim/lsp/util.lua @@ -0,0 +1,557 @@ +local protocol = require 'vim.lsp.protocol' +local validate = vim.validate +local api = vim.api + +local M = {} + +local split = vim.split +local function split_lines(value) + return split(value, '\n', true) +end + +local list_extend = vim.list_extend + +--- Find the longest shared prefix between prefix and word. +-- e.g. remove_prefix("123tes", "testing") == "ting" +local function remove_prefix(prefix, word) + local max_prefix_length = math.min(#prefix, #word) + local prefix_length = 0 + for i = 1, max_prefix_length do + local current_line_suffix = prefix:sub(-i) + local word_prefix = word:sub(1, i) + if current_line_suffix == word_prefix then + prefix_length = i + end + end + 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() + end + return bufnr +end + +-- local valid_windows_path_characters = "[^<>:\"/\\|?*]" +-- local valid_unix_path_characters = "[^/]" +-- https://github.com/davidm/lua-glob-pattern +-- https://stackoverflow.com/questions/1976007/what-characters-are-forbidden-in-windows-and-linux-directory-names +-- 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) + if type(result) == 'table' and result.items then + return result.items + elseif result ~= nil then + return result + else + return {} + end +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) + end +end + +function M.get_current_line_to_cursor() + local pos = api.nvim_win_get_cursor(0) + local line = assert(api.nvim_buf_get_lines(0, pos[1]-1, pos[1], false)[1]) + return line:sub(pos[2]+1) +end + +--- Getting vim complete-items with incomplete flag. +-- @params CompletionItem[], CompletionList or nil (https://microsoft.github.io/language-server-protocol/specification#textDocument_completion) +-- @return { matches = complete-items table, incomplete = boolean } +function M.text_document_completion_list_to_complete_items(result, line_prefix) + local items = M.extract_completion_items(result) + if vim.tbl_isempty(items) then + return {} + end + -- Only initialize if we have some items. + if not line_prefix then + line_prefix = M.get_current_line_to_cursor() + end + + local matches = {} + + for _, completion_item in ipairs(items) do + local info = ' ' + local documentation = completion_item.documentation + if documentation then + if type(documentation) == 'string' and documentation ~= '' then + info = documentation + elseif type(documentation) == 'table' and type(documentation.value) == 'string' then + info = documentation.value + -- else + -- TODO(ashkan) Validation handling here? + end + end + + local word = completion_item.insertText or completion_item.label + + -- Ref: `:h complete-items` + table.insert(matches, { + word = remove_prefix(line_prefix, word), + abbr = completion_item.label, + kind = protocol.CompletionItemKind[completion_item.kind] or '', + menu = completion_item.detail or '', + info = info, + icase = 1, + dup = 0, + empty = 1, + }) + end + + return matches +end + +-- @params WorkspaceEdit [table] see https://microsoft.github.io/language-server-protocol/specification +function M.workspace_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) + end + end + return + end + + if workspace_edit.changes == nil or #workspace_edit.changes == 0 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 + end +end + +--- Convert any of MarkedString | MarkedString[] | MarkupContent into markdown text lines +-- see https://microsoft.github.io/language-server-protocol/specifications/specification-3-14/#textDocument_hover +-- Useful for textDocument/hover, textDocument/signatureHelp, and potentially others. +function M.convert_input_to_markdown_lines(input, contents) + contents = contents or {} + -- MarkedString variation 1 + if type(input) == 'string' then + list_extend(contents, split_lines(input)) + else + 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. + + -- Some servers send input.value as empty, so let's ignore this :( + -- assert(type(input.value) == 'string') + list_extend(contents, split_lines(input.value or '')) + -- MarkupString variation 2 + elseif input.language then + -- Some servers send input.value as empty, so let's ignore this :( + -- assert(type(input.value) == 'string') + table.insert(contents, "```"..input.language) + list_extend(contents, split_lines(input.value or '')) + table.insert(contents, "```") + -- By deduction, this must be MarkedString[] + else + -- Use our existing logic to handle MarkedString + for _, marked_string in ipairs(input) do + M.convert_input_to_markdown_lines(marked_string, contents) + end + end + end + if contents[1] == '' or contents[1] == nil then + return {} + end + return contents +end + +function M.make_floating_popup_options(width, height, opts) + validate { + opts = { opts, 't', true }; + } + opts = opts or {} + validate { + ["opts.offset_x"] = { opts.offset_x, 'n', true }; + ["opts.offset_y"] = { opts.offset_y, 'n', true }; + } + + local anchor = '' + local row, col + + if vim.fn.winline() <= height then + anchor = anchor..'N' + row = 1 + else + anchor = anchor..'S' + row = 0 + end + + if vim.fn.wincol() + width <= api.nvim_get_option('columns') then + anchor = anchor..'W' + col = 0 + else + anchor = anchor..'E' + col = 1 + end + + return { + anchor = anchor, + col = col + (opts.offset_x or 0), + height = height, + relative = 'cursor', + row = row + (opts.offset_y or 0), + style = 'minimal', + width = width, + } +end + +function M.open_floating_preview(contents, filetype, opts) + validate { + contents = { contents, 't' }; + filetype = { filetype, 's', true }; + opts = { opts, 't', true }; + } + + -- Trim empty lines from the end. + for i = #contents, 1, -1 do + if #contents[i] == 0 then + table.remove(contents) + else + break + end + 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) + 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 filetype == 'markdown' then + api.nvim_win_set_option(floating_winnr, 'conceallevel', 2) + 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)") + return floating_bufnr, floating_winnr +end + +local function validate_lsp_position(pos) + validate { pos = {pos, 't'} } + validate { + line = {pos.line, 'n'}; + character = {pos.character, 'n'}; + } + return true +end + +function M.open_floating_peek_preview(bufnr, start, finish, opts) + validate { + bufnr = {bufnr, 'n'}; + start = {start, validate_lsp_position, 'valid start Position'}; + finish = {finish, validate_lsp_position, 'valid finish Position'}; + opts = { opts, 't', true }; + } + local width = math.max(finish.character - start.character + 1, 1) + local height = math.max(finish.line - start.line + 1, 1) + local floating_winnr = api.nvim_open_win(bufnr, false, M.make_floating_popup_options(width, height, opts)) + api.nvim_win_set_cursor(floating_winnr, {start.line+1, start.character}) + api.nvim_command("autocmd CursorMoved * ++once lua pcall(vim.api.nvim_win_close, "..floating_winnr..", true)") + return floating_winnr +end + + +local function highlight_range(bufnr, ns, hiname, start, finish) + if start[1] == finish[1] then + -- TODO care about encoding here since this is in byte index? + api.nvim_buf_add_highlight(bufnr, ns, hiname, start[1], start[2], finish[2]) + else + api.nvim_buf_add_highlight(bufnr, ns, hiname, start[1], start[2], -1) + for line = start[1] + 1, finish[1] - 1 do + api.nvim_buf_add_highlight(bufnr, ns, hiname, line, 0, -1) + end + api.nvim_buf_add_highlight(bufnr, ns, hiname, finish[1], 0, finish[2]) + end +end + +do + local all_buffer_diagnostics = {} + + local diagnostic_ns = api.nvim_create_namespace("vim_lsp_diagnostics") + + local default_severity_highlight = { + [protocol.DiagnosticSeverity.Error] = { guifg = "Red" }; + [protocol.DiagnosticSeverity.Warning] = { guifg = "Orange" }; + [protocol.DiagnosticSeverity.Information] = { guifg = "LightBlue" }; + [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 + end + end + + function M.buf_clear_diagnostics(bufnr) + validate { bufnr = {bufnr, 'n', true} } + bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr + 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 + + function M.show_line_diagnostics() + local bufnr = api.nvim_get_current_buf() + local line = api.nvim_win_get_cursor(0)[1] - 1 + -- local marks = api.nvim_buf_get_extmarks(bufnr, diagnostic_ns, {line, 0}, {line, -1}, {}) + -- if #marks == 0 then + -- return + -- end + -- local buffer_diagnostics = all_buffer_diagnostics[bufnr] + local lines = {"Diagnostics:"} + local highlights = {{0, "Bold"}} + + local buffer_diagnostics = all_buffer_diagnostics[bufnr] + if not buffer_diagnostics then return end + local line_diagnostics = buffer_diagnostics[line] + if not line_diagnostics then return end + + for i, diagnostic in ipairs(line_diagnostics) do + -- for i, mark in ipairs(marks) do + -- local mark_id = mark[1] + -- local diagnostic = buffer_diagnostics[mark_id] + + -- TODO(ashkan) make format configurable? + local prefix = string.format("%d. ", i) + local hiname = severity_highlights[diagnostic.severity] + local message_lines = split_lines(diagnostic.message) + table.insert(lines, prefix..message_lines[1]) + table.insert(highlights, {#prefix + 1, hiname}) + for j = 2, #message_lines do + table.insert(lines, message_lines[j]) + table.insert(highlights, {0, hiname}) + end + end + local popup_bufnr, winnr = M.open_floating_preview(lines, 'plaintext') + for i, hi in ipairs(highlights) do + local prefixlen, hiname = unpack(hi) + -- Start highlight after the prefix + api.nvim_buf_add_highlight(popup_bufnr, -1, hiname, i-1, prefixlen, -1) + end + return popup_bufnr, winnr + end + + function M.buf_diagnostics_save_positions(bufnr, diagnostics) + validate { + bufnr = {bufnr, 'n', true}; + diagnostics = {diagnostics, 't', true}; + } + if not diagnostics then return end + bufnr = bufnr == 0 and api.nvim_get_current_buf() or bufnr + + if not all_buffer_diagnostics[bufnr] then + -- Clean up our data when the buffer unloads. + api.nvim_buf_attach(bufnr, false, { + on_detach = function(b) + all_buffer_diagnostics[b] = nil + end + }) + end + all_buffer_diagnostics[bufnr] = {} + local buffer_diagnostics = all_buffer_diagnostics[bufnr] + + for _, diagnostic in ipairs(diagnostics) do + local start = diagnostic.range.start + -- local mark_id = api.nvim_buf_set_extmark(bufnr, diagnostic_ns, 0, start.line, 0, {}) + -- buffer_diagnostics[mark_id] = diagnostic + local line_diagnostics = buffer_diagnostics[start.line] + if not line_diagnostics then + line_diagnostics = {} + buffer_diagnostics[start.line] = line_diagnostics + end + table.insert(line_diagnostics, diagnostic) + end + end + + + function M.buf_diagnostics_underline(bufnr, diagnostics) + for _, diagnostic in ipairs(diagnostics) do + local start = diagnostic.range.start + local finish = diagnostic.range["end"] + + -- TODO care about encoding here since this is in byte index? + highlight_range(bufnr, diagnostic_ns, underline_highlight_name, + {start.line, start.character}, + {finish.line, finish.character} + ) + end + end + + function M.buf_diagnostics_virtual_text(bufnr, diagnostics) + local buffer_line_diagnostics = all_buffer_diagnostics[bufnr] + if not buffer_line_diagnostics then + M.buf_diagnostics_save_positions(bufnr, diagnostics) + end + buffer_line_diagnostics = all_buffer_diagnostics[bufnr] + if not buffer_line_diagnostics then + return + end + for line, line_diags in pairs(buffer_line_diagnostics) do + local virt_texts = {} + for i = 1, #line_diags - 1 do + table.insert(virt_texts, {"■", severity_highlights[line_diags[i].severity]}) + end + local last = line_diags[#line_diags] + -- TODO(ashkan) use first line instead of subbing 2 spaces? + table.insert(virt_texts, {"■ "..last.message:gsub("\r", ""):gsub("\n", " "), severity_highlights[last.severity]}) + api.nvim_buf_set_virtual_text(bufnr, diagnostic_ns, line, virt_texts, {}) + end + 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 items = {} + local path = api.nvim_buf_get_name(bufnr) + 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, + }) + end + vim.fn.setloclist(targetwin, items, ' ', 'Language Server') +end + +return M +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/shared.lua b/runtime/lua/vim/shared.lua index 7727fdbab0..ff89acc524 100644 --- a/runtime/lua/vim/shared.lua +++ b/runtime/lua/vim/shared.lua @@ -47,9 +47,7 @@ end)() --@param plain If `true` use `sep` literally (passed to String.find) --@returns Iterator over the split components function vim.gsplit(s, sep, plain) - assert(type(s) == "string", string.format("Expected string, got %s", type(s))) - assert(type(sep) == "string", string.format("Expected string, got %s", type(sep))) - assert(type(plain) == "boolean" or type(plain) == "nil", string.format("Expected boolean or nil, got %s", type(plain))) + vim.validate{s={s,'s'},sep={sep,'s'},plain={plain,'b',true}} local start = 1 local done = false @@ -100,13 +98,45 @@ function vim.split(s,sep,plain) return t end +--- Return a list of all keys used in a table. +--- However, the order of the return table of keys is not guaranteed. +--- +--@see From https://github.com/premake/premake-core/blob/master/src/base/table.lua +--- +--@param t Table +--@returns list of keys +function vim.tbl_keys(t) + assert(type(t) == 'table', string.format("Expected table, got %s", type(t))) + + local keys = {} + for k, _ in pairs(t) do + table.insert(keys, k) + end + return keys +end + +--- Return a list of all values used in a table. +--- However, the order of the return table of values is not guaranteed. +--- +--@param t Table +--@returns list of values +function vim.tbl_values(t) + assert(type(t) == 'table', string.format("Expected table, got %s", type(t))) + + local values = {} + for _, v in pairs(t) do + table.insert(values, v) + end + return values +end + --- Checks if a list-like (vector) table contains `value`. --- --@param t Table to check --@param value Value to compare --@returns true if `t` contains `value` function vim.tbl_contains(t, value) - assert(type(t) == 'table', string.format("Expected table, got %s", type(t))) + vim.validate{t={t,'t'}} for _,v in ipairs(t) do if v == value then @@ -116,6 +146,16 @@ function vim.tbl_contains(t, value) return false end +-- Returns true if the table is empty, and contains no indexed or keyed values. +-- +--@see From https://github.com/premake/premake-core/blob/master/src/base/table.lua +-- +--@param t Table to check +function vim.tbl_isempty(t) + assert(type(t) == 'table', string.format("Expected table, got %s", type(t))) + return next(t) == nil +end + --- Merges two or more map-like tables. --- --@see |extend()| @@ -147,13 +187,69 @@ function vim.tbl_extend(behavior, ...) return ret end +--- Deep compare values for equality +function vim.deep_equal(a, b) + if a == b then return true end + if type(a) ~= type(b) then return false end + if type(a) == 'table' then + -- TODO improve this algorithm's performance. + for k, v in pairs(a) do + if not vim.deep_equal(v, b[k]) then + return false + end + end + for k, v in pairs(b) do + if not vim.deep_equal(v, a[k]) then + return false + end + end + return true + end + return false +end + +--- Add the reverse lookup values to an existing table. +--- For example: +--- `tbl_add_reverse_lookup { A = 1 } == { [1] = 'A', A = 1 }` +-- +--Do note that it *modifies* the input. +--@param o table The table to add the reverse to. +function vim.tbl_add_reverse_lookup(o) + local keys = vim.tbl_keys(o) + for _, k in ipairs(keys) do + local v = o[k] + if o[v] then + error(string.format("The reverse lookup found an existing value for %q while processing key %q", tostring(v), tostring(k))) + end + o[v] = k + end + return o +end + +--- Extends a list-like table with the values of another list-like table. +--- +--NOTE: This *mutates* dst! +--@see |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) + end + return dst +end + --- Creates a copy of a list-like table such that any nested tables are --- "unrolled" and appended to the result. --- +--@see From https://github.com/premake/premake-core/blob/master/src/base/table.lua +--- --@param t List-like table --@returns Flattened copy of the given list-like table. function vim.tbl_flatten(t) - -- From https://github.com/premake/premake-core/blob/master/src/base/table.lua local result = {} local function _tbl_flatten(_t) local n = #_t @@ -170,13 +266,39 @@ function vim.tbl_flatten(t) return result end +-- Determine whether a Lua table can be treated as an array. +--- +--@params Table +--@returns true: A non-empty array, false: A non-empty table, nil: An empty table +function vim.tbl_islist(t) + if type(t) ~= 'table' then + return false + end + + local count = 0 + + for k, _ in pairs(t) do + if type(k) == "number" then + count = count + 1 + else + return false + end + end + + if count > 0 then + return true + else + return nil + end +end + --- Trim whitespace (Lua pattern "%s") from both sides of a string. --- --@see https://www.lua.org/pil/20.2.html --@param s String to trim --@returns String with whitespace removed from its beginning and end function vim.trim(s) - assert(type(s) == 'string', string.format("Expected string, got %s", type(s))) + vim.validate{s={s,'s'}} return s:match('^%s*(.*%S)') or '' end @@ -186,8 +308,99 @@ end --@param s String to escape --@returns %-escaped pattern string function vim.pesc(s) - assert(type(s) == 'string', string.format("Expected string, got %s", type(s))) + vim.validate{s={s,'s'}} return s:gsub('[%(%)%.%%%+%-%*%?%[%]%^%$]', '%%%1') end +--- Validates a parameter specification (types and values). +--- +--- Usage example: +--- <pre> +--- function user.new(name, age, hobbies) +--- vim.validate{ +--- name={name, 'string'}, +--- age={age, 'number'}, +--- hobbies={hobbies, 'table'}, +--- } +--- ... +--- end +--- </pre> +--- +--- Examples with explicit argument values (can be run directly): +--- <pre> +--- vim.validate{arg1={{'foo'}, 'table'}, arg2={'foo', 'string'}} +--- => NOP (success) +--- +--- vim.validate{arg1={1, 'table'}} +--- => error('arg1: expected table, got number') +--- +--- vim.validate{arg1={3, function(a) return (a % 2) == 0 end, 'even number'}} +--- => error('arg1: expected even number, got 3') +--- </pre> +--- +--@param opt Map of parameter names to validations. Each key is a parameter +--- name; each value is a tuple in one of these forms: +--- 1. (arg_value, type_name, optional) +--- - arg_value: argument value +--- - type_name: string type name, one of: ("table", "t", "string", +--- "s", "number", "n", "boolean", "b", "function", "f", "nil", +--- "thread", "userdata") +--- - optional: (optional) boolean, if true, `nil` is valid +--- 2. (arg_value, fn, msg) +--- - arg_value: argument value +--- - fn: any function accepting one argument, returns true if and +--- only if the argument is valid +--- - msg: (optional) error string if validation fails +function vim.validate(opt) end -- luacheck: no unused +vim.validate = (function() + local type_names = { + t='table', s='string', n='number', b='boolean', f='function', c='callable', + ['table']='table', ['string']='string', ['number']='number', + ['boolean']='boolean', ['function']='function', ['callable']='callable', + ['nil']='nil', ['thread']='thread', ['userdata']='userdata', + } + local function _type_name(t) + local tname = type_names[t] + if tname == nil then + error(string.format('invalid type name: %s', tostring(t))) + end + return tname + end + local function _is_type(val, t) + return t == 'callable' and vim.is_callable(val) or type(val) == t + end + + return function(opt) + assert(type(opt) == 'table', string.format('opt: expected table, got %s', type(opt))) + for param_name, spec in pairs(opt) do + assert(type(spec) == 'table', string.format('%s: expected table, got %s', param_name, type(spec))) + + local val = spec[1] -- Argument value. + local t = spec[2] -- Type name, or callable. + local optional = (true == spec[3]) + + if not vim.is_callable(t) then -- Check type name. + if (not optional or val ~= nil) and not _is_type(val, _type_name(t)) then + error(string.format("%s: expected %s, got %s", param_name, _type_name(t), type(val))) + end + elseif not t(val) then -- Check user-provided validation function. + error(string.format("%s: expected %s, got %s", param_name, (spec[3] or '?'), val)) + end + end + return true + end +end)() + +--- Returns true if object `f` can be called as a function. +--- +--@param f Any object +--@return true if `f` is callable, else false +function vim.is_callable(f) + if type(f) == 'function' then return true end + local m = getmetatable(f) + if m == nil then return false end + return type(m.__call) == 'function' +end + return vim +-- vim:sw=2 ts=2 et diff --git a/runtime/lua/vim/uri.lua b/runtime/lua/vim/uri.lua new file mode 100644 index 0000000000..0a6e0fcb97 --- /dev/null +++ b/runtime/lua/vim/uri.lua @@ -0,0 +1,89 @@ +--- TODO: This is implemented only for files now. +-- https://tools.ietf.org/html/rfc3986 +-- https://tools.ietf.org/html/rfc2732 +-- https://tools.ietf.org/html/rfc2396 + + +local uri_decode +do + local schar = string.char + local function hex_to_char(hex) + return schar(tonumber(hex, 16)) + end + uri_decode = function(str) + return str:gsub("%%([a-fA-F0-9][a-fA-F0-9])", hex_to_char) + end +end + +local uri_encode +do + local PATTERNS = { + --- RFC 2396 + -- https://tools.ietf.org/html/rfc2396#section-2.2 + rfc2396 = "^A-Za-z0-9%-_.!~*'()"; + --- RFC 2732 + -- https://tools.ietf.org/html/rfc2732 + rfc2732 = "^A-Za-z0-9%-_.!~*'()[]"; + --- RFC 3986 + -- https://tools.ietf.org/html/rfc3986#section-2.2 + rfc3986 = "^A-Za-z0-9%-._~!$&'()*+,;=:@/"; + } + local sbyte, tohex = string.byte + if jit then + tohex = require'bit'.tohex + else + tohex = function(b) return string.format("%02x", b) end + end + local function percent_encode_char(char) + return "%"..tohex(sbyte(char), 2) + end + uri_encode = function(text, rfc) + if not text then return end + local pattern = PATTERNS[rfc] or PATTERNS.rfc3986 + return text:gsub("(["..pattern.."])", percent_encode_char) + end +end + + +local function is_windows_file_uri(uri) + return uri:match('^file:///[a-zA-Z]:') ~= nil +end + +local function uri_from_fname(path) + local volume_path, fname = path:match("^([a-zA-Z]:)(.*)") + local is_windows = volume_path ~= nil + if is_windows then + path = volume_path..uri_encode(fname:gsub("\\", "/")) + else + path = uri_encode(path) + end + local uri_parts = {"file://"} + if is_windows then + table.insert(uri_parts, "/") + end + table.insert(uri_parts, path) + return table.concat(uri_parts) +end + +local function uri_from_bufnr(bufnr) + return uri_from_fname(vim.api.nvim_buf_get_name(bufnr)) +end + +local function uri_to_fname(uri) + -- TODO improve this. + if is_windows_file_uri(uri) then + uri = uri:gsub('^file:///', '') + uri = uri:gsub('/', '\\') + else + uri = uri:gsub('^file://', '') + end + + return uri_decode(uri) +end + +return { + uri_from_fname = uri_from_fname, + uri_from_bufnr = uri_from_bufnr, + uri_to_fname = uri_to_fname, +} +-- vim:sw=2 ts=2 et |