diff options
Diffstat (limited to 'runtime/lua/vim/lsp/client.lua')
-rw-r--r-- | runtime/lua/vim/lsp/client.lua | 262 |
1 files changed, 240 insertions, 22 deletions
diff --git a/runtime/lua/vim/lsp/client.lua b/runtime/lua/vim/lsp/client.lua index 7bf83f4d2c..58db4387b6 100644 --- a/runtime/lua/vim/lsp/client.lua +++ b/runtime/lua/vim/lsp/client.lua @@ -4,6 +4,30 @@ local lsp = vim.lsp local log = lsp.log local ms = lsp.protocol.Methods local changetracking = lsp._changetracking +local validate = vim.validate + +--- @class lsp.ClientConfig +--- @field cmd (string[]|fun(dispatchers: table):table) +--- @field cmd_cwd string +--- @field cmd_env (table) +--- @field detached boolean +--- @field workspace_folders (table) +--- @field capabilities lsp.ClientCapabilities +--- @field handlers table<string,function> +--- @field settings table +--- @field commands table +--- @field init_options table +--- @field name? string +--- @field get_language_id fun(bufnr: integer, filetype: string): string +--- @field offset_encoding string +--- @field on_error fun(code: integer) +--- @field before_init fun(params: lsp.InitializeParams, config: lsp.ClientConfig) +--- @field on_init fun(client: lsp.Client, initialize_result: lsp.InitializeResult) +--- @field on_exit fun(code: integer, signal: integer, client_id: integer) +--- @field on_attach fun(client: lsp.Client, bufnr: integer) +--- @field trace 'off'|'messages'|'verbose'|nil +--- @field flags table +--- @field root_dir string --- @class lsp.Client.Progress: vim.Ringbuf<{token: integer|string, value: any}> --- @field pending table<lsp.ProgressToken,lsp.LSPAny> @@ -51,7 +75,6 @@ local changetracking = lsp._changetracking --- @field initialized true? --- @field workspace_folders lsp.WorkspaceFolder[]? --- @field attached_buffers table<integer,true> ---- @field commands table<string,function> --- @field private _log_prefix string --- Track this so that we can escalate automatically if we've already tried a --- graceful shutdown @@ -119,27 +142,131 @@ local function method_wrapper(cls, meth) end end +local client_index = 0 + +--- Checks whether a given path is a directory. +--- @param filename (string) path to check +--- @return boolean # true if {filename} exists and is a directory, false otherwise +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 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', +} + +--- Normalizes {encoding} to valid LSP encoding names. +--- @param encoding string? Encoding to normalize +--- @return string # normalized encoding name +local function validate_encoding(encoding) + validate({ + encoding = { encoding, 's', true }, + }) + if not encoding then + return valid_encodings.UTF16 + end + 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 + +--- Augments a validator function with support for optional (nil) values. +--- @param fn (fun(v): boolean) The original validator function; should return a +--- bool. +--- @return fun(v): boolean # The augmented function. Also returns true if {v} is +--- `nil`. +local function optional_validator(fn) + return function(v) + return v == nil or fn(v) + end +end + +--- Validates a client configuration as given to |vim.lsp.start_client()|. +--- @param config lsp.ClientConfig +local function process_client_config(config) + validate({ + config = { config, 't' }, + }) + validate({ + handlers = { config.handlers, 't', true }, + capabilities = { config.capabilities, 't', true }, + cmd_cwd = { config.cmd_cwd, optional_validator(is_dir), 'directory' }, + cmd_env = { config.cmd_env, 't', true }, + detached = { config.detached, 'b', 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 }, + settings = { config.settings, 't', true }, + commands = { config.commands, 't', true }, + before_init = { config.before_init, 'f', true }, + offset_encoding = { config.offset_encoding, 's', true }, + flags = { config.flags, 't', true }, + get_language_id = { config.get_language_id, 'f', true }, + }) + assert( + ( + not config.flags + or not config.flags.debounce_text_changes + or type(config.flags.debounce_text_changes) == 'number' + ), + 'flags.debounce_text_changes must be a number with the debounce time in milliseconds' + ) + + if not config.name and type(config.cmd) == 'table' then + config.name = config.cmd[1] and vim.fs.basename(config.cmd[1]) or nil + end + + config.offset_encoding = validate_encoding(config.offset_encoding) + config.flags = config.flags or {} + config.settings = config.settings or {} + config.handlers = config.handlers or {} + + -- By default, get_language_id just returns the exact filetype it is passed. + -- It is possible to pass in something that will calculate a different filetype, + -- to be sent by the client. + config.get_language_id = config.get_language_id or function(_, filetype) + return filetype + end + + config.capabilities = config.capabilities or lsp.protocol.make_client_capabilities() + config.commands = config.commands or {} +end + --- @package ---- @param id integer ---- @param rpc vim.lsp.rpc.PublicClient ---- @param handlers table<string,lsp.Handler> ---- @param offset_encoding string --- @param config lsp.ClientConfig ---- @return lsp.Client -function Client.new(id, rpc, handlers, offset_encoding, config) +--- @return lsp.Client? +function Client.start(config) + process_client_config(config) + + client_index = client_index + 1 + local id = client_index + local name = config.name or tostring(id) --- @class lsp.Client local self = { id = id, config = config, - handlers = handlers, - rpc = rpc, - offset_encoding = offset_encoding, + handlers = config.handlers, + offset_encoding = config.offset_encoding, name = name, _log_prefix = string.format('LSP[%s]', name), requests = {}, - commands = config.commands or {}, attached_buffers = {}, server_capabilities = {}, dynamic_capabilities = vim.lsp._dynamic.new(id), @@ -165,15 +292,46 @@ function Client.new(id, rpc, handlers, offset_encoding, config) self.on_attach = method_wrapper(self, Client._on_attach) self.supports_method = method_wrapper(self, Client._supports_method) - ---@type table<string|integer, string> title of unfinished progress sequences by token + --- @type table<string|integer, string> title of unfinished progress sequences by token self.progress.pending = {} - return setmetatable(self, Client) + --- @type vim.lsp.rpc.Dispatchers + local dispatchers = { + notification = method_wrapper(self, Client._notification), + server_request = method_wrapper(self, Client._server_request), + on_error = method_wrapper(self, Client._on_error), + on_exit = method_wrapper(self, Client._on_exit), + } + + -- Start the RPC client. + local rpc --- @type vim.lsp.rpc.PublicClient? + local config_cmd = config.cmd + if type(config_cmd) == 'function' then + rpc = config_cmd(dispatchers) + else + rpc = lsp.rpc.start(config_cmd, dispatchers, { + cwd = config.cmd_cwd, + env = config.cmd_env, + detached = config.detached, + }) + end + + -- Return nil if the rpc client fails to start + if not rpc then + return + end + + self.rpc = rpc + + setmetatable(self, Client) + + self:initialize() + + return self end --- @private ---- @param cb fun() -function Client:initialize(cb) +function Client:initialize() local valid_traces = { off = 'off', messages = 'messages', @@ -282,8 +440,6 @@ function Client:initialize(cb) 'server_capabilities', { server_capabilities = self.server_capabilities } ) - - cb() end) end @@ -302,7 +458,7 @@ end --- @param bufnr (integer|nil) Buffer number to resolve. Defaults to current buffer --- @return integer bufnr local function resolve_bufnr(bufnr) - vim.validate({ bufnr = { bufnr, 'n', true } }) + validate({ bufnr = { bufnr, 'n', true } }) if bufnr == nil or bufnr == 0 then return api.nvim_get_current_buf() end @@ -374,10 +530,9 @@ end -- TODO(lewis6991): duplicated from lsp.lua local wait_result_reason = { [-1] = 'timeout', [-2] = 'interrupted', [-3] = 'error' } --- TODO(lewis6991): duplicated from lsp.lua --- Concatenates and writes a list of strings to the Vim error buffer. --- ----@param ... string List to write to the buffer +--- @param ... string List to write to the buffer local function err_message(...) api.nvim_err_writeln(table.concat(vim.tbl_flatten({ ... }))) api.nvim_command('redraw') @@ -461,7 +616,7 @@ end --- @return boolean status true if notification was successful. false otherwise --- @see |vim.lsp.client.notify()| function Client:_cancel_request(id) - vim.validate({ id = { id, 'n' } }) + validate({ id = { id, 'n' } }) local request = self.requests[id] if request and request.type == 'pending' then request.type = 'cancel' @@ -527,7 +682,7 @@ function Client:_exec_cmd(command, context, handler) context.bufnr = context.bufnr or api.nvim_get_current_buf() context.client_id = self.id local cmdname = command.command - local fn = self.commands[cmdname] or lsp.commands[cmdname] + local fn = self.config.commands[cmdname] or lsp.commands[cmdname] if fn then fn(command, context) return @@ -654,4 +809,67 @@ function Client:_supports_method(method, opts) end end +--- @private +--- Handles a notification sent by an LSP server by invoking the +--- corresponding handler. +--- +--- @param method string LSP method name +--- @param params table The parameters for that method. +function Client:_notification(method, params) + log.trace('notification', method, params) + local handler = self:_resolve_handler(method) + if handler then + -- Method name is provided here for convenience. + handler(nil, params, { method = method, client_id = self.id }) + end +end + +--- @private +--- Handles a request from an LSP server by invoking the corresponding handler. +--- +--- @param method (string) LSP method name +--- @param params (table) The parameters for that method +--- @return any result +--- @return lsp.ResponseError error code and message set in case an exception happens during the request. +function Client:_server_request(method, params) + log.trace('server_request', method, params) + local handler = self:_resolve_handler(method) + if handler then + log.trace('server_request: found handler for', method) + return handler(nil, params, { method = method, client_id = self.id }) + end + log.warn('server_request: no handler found for', method) + return nil, lsp.rpc_response_error(lsp.protocol.ErrorCodes.MethodNotFound) +end + +--- @private +--- Invoked when the client operation throws an error. +--- +--- @param code integer Error code +--- @param err any Other arguments may be passed depending on the error kind +--- @see vim.lsp.rpc.client_errors for possible errors. Use +--- `vim.lsp.rpc.client_errors[code]` to get a human-friendly name. +function Client:_on_error(code, err) + self:write_error(code, err) + if self.config.on_error then + --- @type boolean, string + local status, usererr = pcall(self.config.on_error, code, err) + if not status then + log.error(self._log_prefix, 'user on_error failed', { err = usererr }) + err_message(self._log_prefix, ' user on_error failed: ', tostring(usererr)) + end + end +end + +--- @private +--- Invoked on client exit. +--- +--- @param code integer) exit code of the process +--- @param signal integer the signal used to terminate (if any) +function Client:_on_exit(code, signal) + if self.config.on_exit then + pcall(self.config.on_exit, code, signal, self.id) + end +end + return Client |