diff options
Diffstat (limited to 'runtime/lua/vim/lsp.lua')
-rw-r--r-- | runtime/lua/vim/lsp.lua | 1013 |
1 files changed, 1013 insertions, 0 deletions
diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua new file mode 100644 index 0000000000..6fe1d15b7e --- /dev/null +++ b/runtime/lua/vim/lsp.lua @@ -0,0 +1,1013 @@ +local default_callbacks = require 'vim.lsp.callbacks' +local log = require 'vim.lsp.log' +local lsp_rpc = require 'vim.lsp.rpc' +local protocol = require 'vim.lsp.protocol' +local util = require 'vim.lsp.util' + +local vim = vim +local nvim_err_writeln, nvim_buf_get_lines, nvim_command, nvim_buf_get_option + = vim.api.nvim_err_writeln, vim.api.nvim_buf_get_lines, vim.api.nvim_command, vim.api.nvim_buf_get_option +local uv = vim.loop +local tbl_isempty, tbl_extend = vim.tbl_isempty, vim.tbl_extend +local validate = vim.validate + +local lsp = { + protocol = protocol; + callbacks = default_callbacks; + buf = require'vim.lsp.buf'; + 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 improve handling of scratch buffers with LSP attached. + +local function err_message(...) + nvim_err_writeln(table.concat(vim.tbl_flatten{...})) + nvim_command("redraw") +end + +local function resolve_bufnr(bufnr) + validate { bufnr = { bufnr, 'n', true } } + if bufnr == nil or bufnr == 0 then + 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 + +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 + local client = active_clients[client_id] + if client then + callback(client, client_id, bufnr) + 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 + +function lsp._cmd_parts(input) + vim.validate{cmd={ + input, + function() return vim.tbl_islist(input) end, + "list"}} + + local cmd = input[1] + local cmd_args = {} + -- Don't mutate our input. + for i, v in ipairs(input) do + vim.validate{["cmd argument"]={v, "s"}} + if i > 1 then + table.insert(cmd_args, v) + end + 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_cwd = { config.cmd_cwd, optional_validator(is_dir), "directory" }; + cmd_env = { config.cmd_env, "t", true }; + name = { config.name, 's', true }; + on_error = { config.on_error, "f", true }; + on_exit = { config.on_exit, "f", true }; + on_init = { config.on_init, "f", true }; + before_init = { config.before_init, "f", true }; + offset_encoding = { config.offset_encoding, "s", true }; + } + local cmd, cmd_args = lsp._cmd_parts(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 buf_get_full_text(bufnr) + local text = table.concat(nvim_buf_get_lines(bufnr, 0, -1, true), '\n') + if nvim_buf_get_option(bufnr, 'eol') then + text = text .. '\n' + end + return text +end + +local function text_document_did_open_handler(bufnr, client) + if not client.resolved_capabilities.text_document_open_close then + return + 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 = buf_get_full_text(bufnr); + } + } + client.notify('textDocument/didOpen', params) + util.buf_versions[bufnr] = params.textDocument.version +end + +--- LSP client object. +--- +--- - Methods: +--- +--- - request(method, params, [callback]) +--- Send a request to the server. If callback is not specified, it will use +--- {client.callbacks} to try to find a callback. If one is not found there, +--- then an error will occur. +--- This is a thin wrapper around {client.rpc.request} with some additional +--- checking. +--- Returns a boolean to indicate if the notification was successful. If it +--- is false, then it will always be false (the client has shutdown). +--- If it was successful, then it will return the request id as the second +--- result. You can use this with `notify("$/cancel", { id = request_id })` +--- to cancel the request. This helper is made automatically with +--- |vim.lsp.buf_request()| +--- Returns: status, [client_id] +--- +--- - notify(method, params) +--- This is just {client.rpc.notify}() +--- Returns a boolean to indicate if the notification was successful. If it +--- is false, then it will always be false (the client has shutdown). +--- Returns: status +--- +--- - cancel_request(id) +--- This is just {client.rpc.notify}("$/cancelRequest", { id = id }) +--- Returns the same as `notify()`. +--- +--- - stop([force]) +--- Stop a client, optionally with force. +--- 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 escalate and force shutdown. +--- +--- - is_stopped() +--- Returns true if the client is fully stopped. +--- +--- - Members +--- - id (number): The id allocated to the client. +--- +--- - name (string): If a name is specified on creation, that will be +--- used. Otherwise it is just the client id. This is used for +--- logs and messages. +--- +--- - offset_encoding (string): The encoding used for communicating +--- with the server. You can modify this in the `on_init` method +--- before text is sent to the server. +--- +--- - callbacks (table): The callbacks used by the client as +--- described in |lsp-callbacks|. +--- +--- - config (table): copy of the table that was passed by the user +--- to |vim.lsp.start_client()|. +--- +--- - server_capabilities (table): Response from the server sent on +--- `initialize` describing the server's capabilities. +--- +--- - resolved_capabilities (table): Normalized table of +--- capabilities that we have detected based on the initialize +--- response from the server in `server_capabilities`. +function lsp.client() + error() +end + +--- Starts and initializes a client with the given configuration. +--- +--- Parameters `cmd` and `root_dir` are required. +--- +--@param root_dir: (required, string) Directory where the LSP server will base +--- its rootUri on initialization. +--- +--@param cmd: (required, string or list treated like |jobstart()|) Base command +--- that initiates the LSP client. +--- +--@param cmd_cwd: (string, default=|getcwd()|) Directory to launch +--- the `cmd` process. Not related to `root_dir`. +--- +--@param cmd_env: (table) Environment flags to pass to the LSP on +--- spawn. Can be specified using keys like a map or as a list with `k=v` +--- pairs or both. Non-string values are coerced to string. +--- Example: +--- <pre> +--- { "PRODUCTION=true"; "TEST=123"; PORT = 8080; HOST = "0.0.0.0"; } +--- </pre> +--- +--@param capabilities Map overriding the default capabilities defined by +--- |vim.lsp.protocol.make_client_capabilities()|, passed to the language +--- server on initialization. Hint: use make_client_capabilities() and modify +--- its result. +--- - Note: To send an empty dictionary use +--- `{[vim.type_idx]=vim.types.dictionary}`, else it will be encoded as an +--- array. +--- +--@param callbacks Map of language server method names to +--- `function(err, method, params, client_id)` handler. Invoked 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` where err must be shaped like a RPC error, +--- i.e. `{ code, message, data? }`. Use |vim.lsp.rpc_response_error()| to +--- help with this. +--- - Default callback for client requests not explicitly specifying +--- a callback. +--- +--@param init_options Values to pass in the initialization request +--- as `initializationOptions`. See `initialize` in the LSP spec. +--- +--@param name (string, default=client-id) Name in log messages. +--- +--@param offset_encoding (default="utf-16") One of "utf-8", "utf-16", +--- or "utf-32" which is the encoding that the LSP server expects. Client does +--- not verify this is correct. +--- +--@param on_error Callback with parameters (code, ...), invoked +--- when the client operation throws an error. `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. +--- Use `vim.lsp.client_errors[code]` to get human-friendly name. +--- +--@param before_init Callback with parameters (initialize_params, config) +--- invoked before the LSP "initialize" phase, where `params` contains the +--- parameters being sent to the server and `config` is the config that was +--- passed to `start_client()`. You can use this to modify parameters before +--- they are sent. +--- +--@param on_init Callback (client, initialize_result) invoked after LSP +--- "initialize", where `result` is a table of `capabilities` and anything else +--- the server may send. For example, clangd sends +--- `initialize_result.offsetEncoding` if `capabilities.offsetEncoding` was +--- sent to it. You can only modify the `client.offset_encoding` here before +--- any notifications are sent. +--- +--@param on_exit Callback (code, signal, client_id) invoked on client +--- exit. +--- - code: exit code of the process +--- - signal: number describing the signal used to terminate (if any) +--- - client_id: client handle +--- +--@param on_attach Callback (client, bufnr) invoked when client +--- attaches to a buffer. +--- +--@param 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. |vim.lsp.get_client_by_id()| Note: client is only +--- available after it has been initialized, which may happen after a small +--- delay (or never if there is an error). 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 = config.callbacks or {} + local name = config.name or tostring(client_id) + local log_prefix = string.format("LSP[%s]", name) + + local handlers = {} + + local function resolve_callback(method) + return callbacks[method] or default_callbacks[method] + end + + function handlers.notification(method, params) + local _ = log.debug() and log.debug('notification', method, params) + local callback = resolve_callback(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 = resolve_callback(method) + if callback then + local _ = log.debug() and log.debug("server_request: found callback for", method) + return callback(nil, method, params, client_id) + 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 }) + err_message(log_prefix, ': Error ', lsp.client_errors[code], ': ', vim.inspect(err)) + if config.on_error then + local status, usererr = pcall(config.on_error, code, err) + if not status then + local _ = log.error() and log.error(log_prefix, "user on_error failed", { err = usererr }) + err_message(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 + local active_buffers = {} + for bufnr, client_ids in pairs(all_buffer_active_clients) do + if client_ids[client_id] then + table.insert(active_buffers, bufnr) + end + client_ids[client_id] = nil + end + -- Buffer level cleanup + vim.schedule(function() + for _, bufnr in ipairs(active_buffers) do + util.buf_clear_diagnostics(bufnr) + end + end) + if config.on_exit then + pcall(config.on_exit, code, signal, client_id) + end + 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 = config.root_dir; + -- 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); + -- 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; + } + if config.before_init then + -- TODO(ashkan) handle errors here. + pcall(config.before_init, initialize_params, config) + end + local _ = log.debug() and log.debug(log_prefix, "initialize_params", initialize_params) + rpc.request('initialize', initialize_params, function(init_err, result) + assert(not init_err, tostring(init_err)) + assert(result, "server sent empty result") + rpc.notify('initialized', {[vim.type_idx]=vim.types.dictionary}) + client.initialized = true + uninitialized_clients[client_id] = nil + client.server_capabilities = assert(result.capabilities, "initialize result doesn't contain capabilities") + -- 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) + err_message(msg) + return lsp.rpc_response_error(protocol.ErrorCodes.MethodNotFound, msg) + end + + --- Checks capabilities before rpc.request-ing. + function client.request(method, params, callback, bufnr) + if not callback then + callback = resolve_callback(method) + or error(string.format("not found: %q request callback for client %q.", method, client.name)) + end + local _ = log.debug() and log.debug(log_prefix, "client.request", client_id, method, params, callback, bufnr) + -- 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') + or (not client.resolved_capabilities.declaration and method == 'textDocument/declaration') + or (not client.resolved_capabilities.type_definition and method == 'textDocument/typeDefinition') + or (not client.resolved_capabilities.document_symbol and method == 'textDocument/documentSymbol') + or (not client.resolved_capabilities.workspace_symbol and method == 'textDocument/workspaceSymbol') + or (not client.resolved_capabilities.call_hierarchy and method == 'textDocument/prepareCallHierarchy') + then + callback(unsupported_method(method), method, nil, client_id, bufnr) + return + end + return rpc.request(method, params, function(err, result) + callback(err, method, result, client_id, bufnr) + 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)) + + -- Don't do anything if there are no clients attached. + if tbl_isempty(all_buffer_active_clients[bufnr] or {}) then + return + end + + util.buf_versions[bufnr] = changedtick + -- 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 = buf_get_full_text(bufnr); + }; + 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. +--- +--- Without calling this, the server won't be notified of changes to a buffer. +--- +--- @param bufnr (number) Buffer handle, or 0 for current +--- @param client_id (number) 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) + util.buf_versions[bufnr] = nil + 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 + +--- Checks 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 + +--- Gets an active client by id, or nil if the id is invalid or the +--- client is not yet initialized. +--- +--@param client_id client id number +--- +--@return |vim.lsp.client| object, or nil +function lsp.get_client_by_id(client_id) + return active_clients[client_id] +end + +--- Stops a client(s). +--- +--- You can also use the `stop()` function on a |vim.lsp.client| object. +--- To stop all clients: +--- +--- <pre> +--- vim.lsp.stop_client(lsp.get_active_clients()) +--- </pre> +--- +--- By default asks the server to shutdown, unless stop was requested +--- already for this client, then force-shutdown is attempted. +--- +--@param client_id client id or |vim.lsp.client| object, or list thereof +--@param force boolean (optional) shutdown forcefully +function lsp.stop_client(client_id, force) + local ids = type(client_id) == 'table' and client_id or {client_id} + for _, id in ipairs(ids) do + if type(id) == 'table' and id.stop ~= nil then + id.stop(force) + elseif active_clients[id] then + active_clients[id].stop(force) + elseif uninitialized_clients[id] then + uninitialized_clients[id].stop(true) + end + end +end + +--- Gets all active clients. +--- +--@return Table of |vim.lsp.client| objects +function lsp.get_active_clients() + return vim.tbl_values(active_clients) +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 + + if not vim.wait(500, function() return tbl_isempty(active_clients) end, 50) then + for _, client in pairs(active_clients) do + client.stop(true) + end + end +end + +nvim_command("autocmd VimLeavePre * lua vim.lsp._vim_exit_handler()") + + +--- Sends an async request for all active clients attached to the +--- buffer. +--- +--@param bufnr (number) Buffer handle, or 0 for current. +--@param method (string) LSP method name +--@param params (optional, table) Parameters to send to the server +--@param callback (optional, functionnil) Handler +-- `function(err, method, params, client_id)` for this request. Defaults +-- to the client callback in `client.callbacks`. See |lsp-callbacks|. +-- +--@returns 2-tuple: +--- - Map of client-id:request-id pairs for all successful requests. +--- - Function which can be used to cancel all the requests. You could instead +--- iterate all clients and call their `cancel_request()` methods. +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, resolved_bufnr) + local request_success, request_id = client.request(method, params, callback, resolved_bufnr) + + -- 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 + +--- Sends a request to a server and waits for the response. +--- +--- Calls |vim.lsp.buf_request()| but blocks Nvim while awaiting the result. +--- Parameters are the same as |vim.lsp.buf_request()| but the return result is +--- different. Wait maximum of {timeout_ms} (default 100) ms. +--- +--@param bufnr (number) Buffer handle, or 0 for current. +--@param method (string) LSP method name +--@param params (optional, table) Parameters to send to the server +--@param timeout_ms (optional, number, default=100) Maximum time in +--- milliseconds to wait for a result. +--- +--@returns Map of client_id:request_result. On timeout, cancel or error, +--- returns `(nil, err)` where `err` is a string describing the failure +--- reason. +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, reason = vim.wait(timeout_ms or 100, function() + return result_count >= expected_result_count + end, 10) + + if not wait_result then + cancel() + return nil, wait_result_reason[reason] + 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 true if any client returns true; false otherwise +function lsp.buf_notify(bufnr, method, params) + validate { + bufnr = { bufnr, 'n', true }; + method = { method, 's' }; + } + local resp = false + for_each_buffer_client(bufnr, function(client, _client_id, _resolved_bufnr) + if client.rpc.notify(method, params) then resp = true end + end) + return resp +end + +--- Implements 'omnifunc' compatible LSP completion. +--- +--@see |complete-functions| +--@see |complete-items| +--@see |CompleteDone| +--- +--@param findstart 0 or 1, decides behavior +--@param base If findstart=0, text to match against +--- +--@return (number) Decided by `findstart`: +--- - findstart=0: column where the completion starts, or -2 or -3 +--- - findstart=1: list of matches (actually just calls |complete()|) +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 + + -- Then, perform standard completion request + local _ = log.info() and log.info("base ", base) + + local pos = vim.api.nvim_win_get_cursor(0) + local line = vim.api.nvim_get_current_line() + local line_to_cursor = line:sub(1, pos[2]) + local _ = log.trace() and log.trace("omnifunc.line", pos, line) + + -- Get the start position of the current keyword + local textMatch = vim.fn.match(line_to_cursor, '\\k*$') + local prefix = line_to_cursor:sub(textMatch+1) + + local params = util.make_position_params() + + local items = {} + lsp.buf_request(bufnr, 'textDocument/completion', params, function(err, _, result) + if err or not result then return end + local matches = util.text_document_completion_list_to_complete_items(result, prefix) + -- TODO(ashkan): is this the best way to do this? + vim.list_extend(items, matches) + vim.fn.complete(textMatch+1, items) + end) + + -- Return -2 to signal that we should continue completion so that we can + -- async complete. + return -2 +end + +function lsp.client_is_stopped(client_id) + return active_clients[client_id] == nil +end + +--- Gets a map of client_id:client pairs for the given buffer, where each value +--- is a |vim.lsp.client| object. +--- +--@param bufnr (optional, number): 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 + +-- 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 + +--- Sets the global log level for LSP logging. +--- +--- Levels by name: "trace", "debug", "info", "warn", "error" +--- Level numbers begin with "trace" at 0 +--- +--- Use `lsp.log_levels` for reverse lookup. +--- +--@see |vim.lsp.log_levels| +--- +--@param level [number|string] the case insensitive level name or number +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 + +--- Gets the path of the logfile used by the LSP client. +function lsp.get_log_path() + return log.get_filename() +end + +-- Define the LspDiagnostics signs if they're not defined already. +do + local function define_default_sign(name, properties) + if vim.tbl_isempty(vim.fn.sign_getdefined(name)) then + vim.fn.sign_define(name, properties) + end + end + define_default_sign('LspDiagnosticsErrorSign', {text='E', texthl='LspDiagnosticsErrorSign', linehl='', numhl=''}) + define_default_sign('LspDiagnosticsWarningSign', {text='W', texthl='LspDiagnosticsWarningSign', linehl='', numhl=''}) + define_default_sign('LspDiagnosticsInformationSign', {text='I', texthl='LspDiagnosticsInformationSign', linehl='', numhl=''}) + define_default_sign('LspDiagnosticsHintSign', {text='H', texthl='LspDiagnosticsHintSign', linehl='', numhl=''}) +end + +return lsp +-- vim:sw=2 ts=2 et |