From 3f788e73b34521f093846d362bf51e68bc9a3827 Mon Sep 17 00:00:00 2001 From: TheLeoP <53507599+TheLeoP@users.noreply.github.com> Date: Tue, 2 Jan 2024 04:08:36 -0500 Subject: [PATCH] feat(lsp): support connect via named pipes/unix domain sockets (#26032) Closes https://github.com/neovim/neovim/issues/26031 Co-authored-by: Mathias Fussenegger --- runtime/doc/lsp.txt | 48 ++++-- runtime/doc/news.txt | 2 + runtime/lua/vim/lsp/rpc.lua | 253 ++++++++++++++++++++-------- test/functional/plugin/lsp_spec.lua | 33 ++++ 4 files changed, 255 insertions(+), 81 deletions(-) diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index aaec58633f..d53a7b4c11 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -2082,11 +2082,26 @@ connect({host}, {port}) *vim.lsp.rpc.connect()* and port Parameters: ~ - • {host} (string) - • {port} (integer) + • {host} (string) host to connect to + • {port} (integer) port to connect to Return: ~ - (function) + fun(dispatchers: vim.lsp.rpc.Dispatchers ): vim.lsp.rpc.PublicClient function intended to be passed to |vim.lsp.start_client()| or + |vim.lsp.start()| on the field cmd + + *vim.lsp.rpc.domain_socket_connect()* +domain_socket_connect({pipe_path}) + Create a LSP RPC client factory that connects via named pipes (Windows) or + unix domain sockets (Unix) to the given pipe_path (file path on Unix and + name on Windows) + + Parameters: ~ + • {pipe_path} (string) file path of the domain socket (Unix) or name of + the named pipe (Windows) to connect to + + Return: ~ + fun(dispatchers: vim.lsp.rpc.Dispatchers ): vim.lsp.rpc.PublicClient::function intended to be passed to + |vim.lsp.start_client()| or |vim.lsp.start()| on the field cmd format_rpc_error({err}) *vim.lsp.rpc.format_rpc_error()* Constructs an error message from an LSP error object. @@ -2095,14 +2110,14 @@ format_rpc_error({err}) *vim.lsp.rpc.format_rpc_error()* • {err} (table) The error object Return: ~ - (string) The formatted error message + string::The formatted error message notify({method}, {params}) *vim.lsp.rpc.notify()* Sends a notification to the LSP server. Parameters: ~ • {method} (string) The invoked LSP method - • {params} (table|nil) Parameters for the invoked LSP method + • {params} (table?) Parameters for the invoked LSP method Return: ~ (boolean) `true` if notification could be sent, `false` if not @@ -2113,12 +2128,12 @@ request({method}, {params}, {callback}, {notify_reply_callback}) Parameters: ~ • {method} (string) The invoked LSP method - • {params} (table|nil) Parameters for the invoked LSP + • {params} (table?) Parameters for the invoked LSP method • {callback} fun(err: lsp.ResponseError | nil, result: any) Callback to invoke - • {notify_reply_callback} (function|nil) Callback to invoke as soon as - a request is no longer pending + • {notify_reply_callback} (function?) Callback to invoke as soon as a + request is no longer pending Return: ~ (boolean) success, integer|nil request_id true, message_id if request @@ -2129,11 +2144,13 @@ rpc_response_error({code}, {message}, {data}) Creates an RPC response object/table. Parameters: ~ - • {code} (integer) RPC error code defined in - `vim.lsp.protocol.ErrorCodes` + • {code} (integer) RPC error code defined by JSON RPC • {message} (string|nil) arbitrary message to send to server • {data} any|nil arbitrary data to send to server + Return: ~ + vim.lsp.rpc.Error + *vim.lsp.rpc.start()* start({cmd}, {cmd_args}, {dispatchers}, {extra_spawn_params}) Starts an LSP server process and create an LSP RPC client object to @@ -2143,7 +2160,7 @@ start({cmd}, {cmd_args}, {dispatchers}, {extra_spawn_params}) Parameters: ~ • {cmd} (string) Command to start the LSP server. - • {cmd_args} (table) List of additional string arguments to + • {cmd_args} string[] List of additional string arguments to pass to {cmd}. • {dispatchers} (table|nil) Dispatchers for LSP message types. Valid dispatcher names are: @@ -2155,11 +2172,14 @@ start({cmd}, {cmd_args}, {dispatchers}, {extra_spawn_params}) server process. May contain: • {cwd} (string) Working directory for the LSP server process - • {env} (table) Additional environment variables - for LSP server process + • {detached?} (boolean) Detach the LSP server + process from the current process. Defaults to + false on Windows and true otherwise. + • {env?} (table) Additional environment + variables for LSP server process Return: ~ - RpcClientPublic|nil Client RPC object, with these methods: + (table|nil) client RPC object, with these methods: • `notify()` |vim.lsp.rpc.notify()| • `request()` |vim.lsp.rpc.request()| • `is_closing()` returns a boolean indicating if the RPC is closing. diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 8efad2454b..03e1989e62 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -193,6 +193,8 @@ The following new APIs and features were added. windows. • |vim.lsp.util.locations_to_items()| sets the `user_data` of each item to the original LSP `Location` or `LocationLink`. + • Added support for connecting to servers using named pipes (Windows) or + unix domain sockets (Unix) via |vim.lsp.rpc.domain_socket_connect()|. • Treesitter • Bundled parsers and queries (highlight, folds) for Markdown, Python, and diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index 61ad1e479c..b0d98829a6 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -5,8 +5,31 @@ local validate, schedule, schedule_wrap = vim.validate, vim.schedule, vim.schedu local is_win = uv.os_uname().version:find('Windows') +---@alias vim.lsp.rpc.Dispatcher fun(method: string, params: table):nil, vim.lsp.rpc.Error? +---@alias vim.lsp.rpc.on_error fun(code: integer, ...: any) +---@alias vim.lsp.rpc.on_exit fun(code: integer, signal: integer) + +---@class vim.lsp.rpc.Dispatchers +---@field notification vim.lsp.rpc.Dispatcher +---@field server_request vim.lsp.rpc.Dispatcher +---@field on_exit vim.lsp.rpc.on_error +---@field on_error vim.lsp.rpc.on_exit + +---@class vim.lsp.rpc.PublicClient +---@field request fun(method: string, params?: table, callback: fun(err: lsp.ResponseError | nil, result: any), notify_reply_callback:function?) +---@field notify fun(method: string, params: any) +---@field is_closing fun(): boolean +---@field terminate fun(): nil + +---@class vim.lsp.rpc.Client +---@field message_index integer +---@field message_callbacks table dict of message_id to callback +---@field notify_reply_callbacks table dict of message_id to callback +---@field transport vim.lsp.rpc.Transport +---@field dispatchers vim.lsp.rpc.Dispatchers + --- Checks whether a given path exists and is a directory. ----@param filename (string) path to check +---@param filename string path to check ---@return boolean local function is_dir(filename) local stat = uv.fs_stat(filename) @@ -15,14 +38,14 @@ end --- Embeds the given string into a table and correctly computes `Content-Length`. --- ----@param encoded_message (string) ----@return string containing encoded message and `Content-Length` attribute -local function format_message_with_content_length(encoded_message) +---@param message string +---@return string message with `Content-Length` attribute +local function format_message_with_content_length(message) return table.concat({ 'Content-Length: ', - tostring(#encoded_message), + tostring(#message), '\r\n\r\n', - encoded_message, + message, }) end @@ -44,13 +67,17 @@ local function log_debug(...) end end +---@class vim.lsp.rpc.Headers: {string: any} +---@field content_length integer + --- Parses an LSP Message's header --- ----@param header string: The header to parse. ----@return table # parsed headers +---@param header string The header to parse. +---@return vim.lsp.rpc.Headers#parsed headers local function parse_headers(header) assert(type(header) == 'string', 'header must be a string') - local headers = {} --- @type table + --- @type vim.lsp.rpc.Headers + local headers = {} for line in vim.gsplit(header, '\r\n', { plain = true }) do if line == '' then break @@ -92,15 +119,25 @@ local function request_parser_loop() -- be searching for. -- TODO(ashkan) I'd like to remove this, but it seems permanent :( local buffer_start = buffer:find(header_start_pattern) + if not buffer_start then + error( + string.format( + "Headers were expected, a different response was received. The server response was '%s'.", + buffer + ) + ) + end local headers = parse_headers(buffer:sub(buffer_start, start - 1)) local content_length = headers.content_length -- Use table instead of just string to buffer the message. It prevents -- a ton of strings allocating. -- ref. http://www.lua.org/pil/11.6.html + ---@type string[] local body_chunks = { buffer:sub(finish + 1) } local body_length = #body_chunks[1] -- Keep waiting for data until we have enough. while body_length < content_length do + ---@type string local chunk = coroutine.yield() or error('Expected more data for the body. The server may have died.') -- TODO hmm. table.insert(body_chunks, chunk) @@ -148,8 +185,8 @@ M.client_errors = vim.tbl_add_reverse_lookup(M.client_errors) --- Constructs an error message from an LSP error object. --- ----@param err (table) The error object ----@returns (string) The formatted error message +---@param err table The error object +---@return string#The formatted error message function M.format_rpc_error(err) validate({ err = { err, 't' }, @@ -176,11 +213,17 @@ function M.format_rpc_error(err) return table.concat(message_parts, ' ') end +---@class vim.lsp.rpc.Error +---@field code integer RPC error code defined by JSON RPC, see `vim.lsp.protocol.ErrorCodes` +---@field message? string arbitrary message to send to server +---@field data? any arbitrary data to send to server + --- Creates an RPC response object/table. --- ----@param code integer RPC error code defined in `vim.lsp.protocol.ErrorCodes` ----@param message string|nil arbitrary message to send to server ----@param data any|nil arbitrary data to send to server +---@param code integer RPC error code defined by JSON RPC +---@param message? string arbitrary message to send to server +---@param data? any arbitrary data to send to server +---@return vim.lsp.rpc.Error function M.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') @@ -204,7 +247,7 @@ local default_dispatchers = { --- Default dispatcher for notifications sent to an LSP server. --- ---@param method (string) The invoked LSP method - ---@param params (table): Parameters for the invoked LSP method + ---@param params (table) Parameters for the invoked LSP method notification = function(method, params) log_debug('notification', method, params) end, @@ -212,9 +255,9 @@ local default_dispatchers = { --- Default dispatcher for requests sent to an LSP server. --- ---@param method (string) The invoked LSP method - ---@param params (table): Parameters for the invoked LSP method + ---@param params (table) Parameters for the invoked LSP method ---@return nil - ---@return table, `vim.lsp.protocol.ErrorCodes.MethodNotFound` + ---@return vim.lsp.rpc.Error#`vim.lsp.protocol.ErrorCodes.MethodNotFound` server_request = function(method, params) log_debug('server_request', method, params) return nil, M.rpc_response_error(protocol.ErrorCodes.MethodNotFound) @@ -222,8 +265,8 @@ local default_dispatchers = { --- Default dispatcher for when a client exits. --- - ---@param code (integer): Exit code - ---@param signal (integer): Number describing the signal used to terminate (if + ---@param code (integer) Exit code + ---@param signal (integer) Number describing the signal used to terminate (if ---any) on_exit = function(code, signal) log_info('client_exit', { code = code, signal = signal }) @@ -231,17 +274,19 @@ local default_dispatchers = { --- Default dispatcher for client errors. --- - ---@param code (integer): Error code - ---@param err (any): Details about the error + ---@param code (integer) Error code + ---@param err (any) Details about the error ---any) on_error = function(code, err) log_error('client_error:', M.client_errors[code], err) end, } +---@cast default_dispatchers vim.lsp.rpc.Dispatchers + ---@private function M.create_read_loop(handle_body, on_no_chunk, on_error) - local parse_chunk = coroutine.wrap(request_parser_loop) + local parse_chunk = coroutine.wrap(request_parser_loop) --[[@as fun(chunk: string?): vim.lsp.rpc.Headers?, string?]] parse_chunk() return function(err, chunk) if err then @@ -268,14 +313,7 @@ function M.create_read_loop(handle_body, on_no_chunk, on_error) end end ----@class RpcClient ----@field message_index integer ----@field message_callbacks table ----@field notify_reply_callbacks table ----@field transport table ----@field dispatchers table - ----@class RpcClient +---@class vim.lsp.rpc.Client local Client = {} ---@private @@ -284,15 +322,18 @@ function Client:encode_and_send(payload) if self.transport.is_closing() then return false end - local encoded = vim.json.encode(payload) - self.transport.write(format_message_with_content_length(encoded)) + local jsonstr = assert( + vim.json.encode(payload), + string.format("Couldn't encode payload '%s'", vim.inspect(payload)) + ) + self.transport.write(format_message_with_content_length(jsonstr)) return true end ---@package --- Sends a notification to the LSP server. ----@param method (string) The invoked LSP method ----@param params (any): Parameters for the invoked LSP method +---@param method string The invoked LSP method +---@param params any Parameters for the invoked LSP method ---@return boolean `true` if notification could be sent, `false` if not function Client:notify(method, params) return self:encode_and_send({ @@ -316,10 +357,10 @@ end ---@package --- Sends a request to the LSP server and runs {callback} upon response. --- ----@param method (string) The invoked LSP method ----@param params (table|nil) Parameters for the invoked LSP method ----@param callback fun(err: lsp.ResponseError|nil, result: any) Callback to invoke ----@param notify_reply_callback (function|nil) Callback to invoke as soon as a request is no longer pending +---@param method string The invoked LSP method +---@param params? table Parameters for the invoked LSP method +---@param callback fun(err?: lsp.ResponseError, result: any) Callback to invoke +---@param notify_reply_callback? function Callback to invoke as soon as a request is no longer pending ---@return boolean success, integer|nil request_id true, request_id if request could be sent, `false` if not function Client:request(method, params, callback, notify_reply_callback) validate({ @@ -352,6 +393,8 @@ function Client:request(method, params, callback, notify_reply_callback) end ---@package +---@param errkind integer +---@param ... any function Client:on_error(errkind, ...) assert(M.client_errors[errkind]) -- TODO what to do if this fails? @@ -359,6 +402,13 @@ function Client:on_error(errkind, ...) end ---@private +---@param errkind integer +---@param status boolean +---@param head any +---@param ... any +---@return boolean status +---@return any head +---@return any|nil ... function Client:pcall_handler(errkind, status, head, ...) if not status then self:on_error(errkind, head, ...) @@ -368,6 +418,12 @@ function Client:pcall_handler(errkind, status, head, ...) end ---@private +---@param errkind integer +---@param fn function +---@param ... any +---@return boolean status +---@return any head +---@return any|nil ... function Client:try_call(errkind, fn, ...) return self:pcall_handler(errkind, pcall(fn, ...)) end @@ -386,7 +442,7 @@ function Client:handle_body(body) log_debug('rpc.receive', decoded) if type(decoded.method) == 'string' and decoded.id then - local err --- @type table? + local err --- @type vim.lsp.rpc.Error? -- Schedule here so that the users functions don't trigger an error and -- we can still use the result. schedule(function() @@ -412,6 +468,7 @@ function Client:handle_body(body) ) end if err then + ---@cast err vim.lsp.rpc.Error assert( type(err) == 'table', 'err must be a table. Use rpc_response_error to help format errors.' @@ -504,7 +561,14 @@ function Client:handle_body(body) end end ----@return RpcClient +---@class vim.lsp.rpc.Transport +---@field write fun(msg: string): nil +---@field is_closing fun(): boolean|nil +---@field terminate fun(): nil + +---@param dispatchers vim.lsp.rpc.Dispatchers +---@param transport vim.lsp.rpc.Transport +---@return vim.lsp.rpc.Client local function new_client(dispatchers, transport) local state = { message_index = 0, @@ -516,14 +580,8 @@ local function new_client(dispatchers, transport) return setmetatable(state, { __index = Client }) end ---- @class RpcClientPublic ---- @field is_closing fun(): boolean ---- @field terminate fun() ---- @field request fun(method: string, params: table?, callback: function, notify_reply_callbacks?: function) ---- @field notify fun(methid: string, params: table?): boolean - ----@param client RpcClient ----@return RpcClientPublic +---@param client vim.lsp.rpc.Client +---@return vim.lsp.rpc.PublicClient local function public_client(client) local result = {} @@ -540,9 +598,9 @@ local function public_client(client) --- Sends a request to the LSP server and runs {callback} upon response. --- ---@param method (string) The invoked LSP method - ---@param params (table|nil) Parameters for the invoked LSP method + ---@param params (table?) Parameters for the invoked LSP method ---@param callback fun(err: lsp.ResponseError | nil, result: any) Callback to invoke - ---@param notify_reply_callback (function|nil) Callback to invoke as soon as a request is no longer pending + ---@param notify_reply_callback (function?) Callback to invoke as soon as a request is no longer pending ---@return boolean success, integer|nil request_id true, message_id if request could be sent, `false` if not function result.request(method, params, callback, notify_reply_callback) return client:request(method, params, callback, notify_reply_callback) @@ -550,7 +608,7 @@ local function public_client(client) --- Sends a notification to the LSP server. ---@param method (string) The invoked LSP method - ---@param params (table|nil): Parameters for the invoked LSP method + ---@param params (table?) Parameters for the invoked LSP method ---@return boolean `true` if notification could be sent, `false` if not function result.notify(method, params) return client:notify(method, params) @@ -559,13 +617,15 @@ local function public_client(client) return result end ---- @param dispatchers vim.rpc.Dispatchers? ---- @return vim.rpc.Dispatchers +---@param dispatchers? vim.lsp.rpc.Dispatchers +---@return vim.lsp.rpc.Dispatchers local function merge_dispatchers(dispatchers) if dispatchers then local user_dispatchers = dispatchers dispatchers = {} for dispatch_name, default_dispatch in pairs(default_dispatchers) do + ---@cast dispatch_name string + ---@cast default_dispatch function local user_dispatcher = user_dispatchers[dispatch_name] --- @type function if user_dispatcher then if type(user_dispatcher) ~= 'function' then @@ -593,9 +653,9 @@ end --- Create a LSP RPC client factory that connects via TCP to the given host --- and port --- ----@param host string ----@param port integer ----@return function +---@param host string host to connect to +---@param port integer port to connect to +---@return fun(dispatchers: vim.lsp.rpc.Dispatchers): vim.lsp.rpc.PublicClient # function intended to be passed to |vim.lsp.start_client()| or |vim.lsp.start()| on the field cmd function M.connect(host, port) return function(dispatchers) dispatchers = merge_dispatchers(dispatchers) @@ -640,23 +700,82 @@ function M.connect(host, port) end end +--- Create a LSP RPC client factory that connects via named pipes (Windows) +--- or unix domain sockets (Unix) to the given pipe_path (file path on +--- Unix and name on Windows) +--- +---@param pipe_path string file path of the domain socket (Unix) or name of the named pipe (Windows) to connect to +---@return fun(dispatchers: vim.lsp.rpc.Dispatchers): vim.lsp.rpc.PublicClient#function intended to be passed to |vim.lsp.start_client()| or |vim.lsp.start()| on the field cmd +function M.domain_socket_connect(pipe_path) + return function(dispatchers) + dispatchers = merge_dispatchers(dispatchers) + local pipe = + assert(uv.new_pipe(false), string.format('pipe with name %s could not be opened.', pipe_path)) + local closing = false + local transport = { + write = vim.schedule_wrap(function(msg) + pipe:write(msg) + end), + is_closing = function() + return closing + end, + terminate = function() + if not closing then + closing = true + pipe:shutdown() + pipe:close() + dispatchers.on_exit(0, 0) + end + end, + } + local client = new_client(dispatchers, transport) + pipe:connect(pipe_path, function(err) + if err then + vim.schedule(function() + vim.notify( + string.format('Could not connect to :%s, reason: %s', pipe_path, vim.inspect(err)), + vim.log.levels.WARN + ) + end) + return + end + local handle_body = function(body) + client:handle_body(body) + end + pipe:read_start(M.create_read_loop(handle_body, transport.terminate, function(read_err) + client:on_error(M.client_errors.READ_ERROR, read_err) + end)) + end) + + return public_client(client) + end +end + +---@class vim.lsp.rpc.ExtraSpawnParams +---@field cwd? string Working directory for the LSP server process +---@field detached? boolean Detach the LSP server process from the current process +---@field env? table Additional environment variables for LSP server process. See |vim.system| + --- Starts an LSP server process and create an LSP RPC client object to --- interact with it. Communication with the spawned process happens via stdio. For --- communication via TCP, spawn a process manually and use |vim.lsp.rpc.connect()| --- ----@param cmd (string) Command to start the LSP server. ----@param cmd_args (table) List of additional string arguments to pass to {cmd}. ----@param dispatchers table|nil Dispatchers for LSP message types. Valid ----dispatcher names are: ---- - `"notification"` ---- - `"server_request"` ---- - `"on_error"` ---- - `"on_exit"` ----@param extra_spawn_params table|nil Additional context for the LSP +---@param cmd string Command to start the LSP server. +---@param cmd_args string[] List of additional string arguments to pass to {cmd}. +--- +---@param dispatchers? vim.lsp.rpc.Dispatchers (table|nil) Dispatchers for LSP message types. +--- Valid dispatcher names are: +--- - `"notification"` +--- - `"server_request"` +--- - `"on_error"` +--- - `"on_exit"` +--- +---@param extra_spawn_params? vim.lsp.rpc.ExtraSpawnParams (table|nil) Additional context for the LSP --- server process. May contain: --- - {cwd} (string) Working directory for the LSP server process ---- - {env} (table) Additional environment variables for LSP server process ----@return RpcClientPublic|nil Client RPC object, with these methods: +--- - {detached?} (boolean) Detach the LSP server process from the current process. Defaults to false on Windows and true otherwise. +--- - {env?} (table) Additional environment variables for LSP server process +---@return vim.lsp.rpc.PublicClient? (table|nil) client RPC object, with these methods: --- - `notify()` |vim.lsp.rpc.notify()| --- - `request()` |vim.lsp.rpc.request()| --- - `is_closing()` returns a boolean indicating if the RPC is closing. diff --git a/test/functional/plugin/lsp_spec.lua b/test/functional/plugin/lsp_spec.lua index 0026a7caa7..a6b3835dbc 100644 --- a/test/functional/plugin/lsp_spec.lua +++ b/test/functional/plugin/lsp_spec.lua @@ -3861,6 +3861,39 @@ describe('LSP', function() ]] eq(result.method, "initialize") end) + it('can connect to lsp server via rpc.domain_socket_connect', function() + local tmpfile + if is_os("win") then + tmpfile = "\\\\.\\\\pipe\\pipe.test" + else + tmpfile = helpers.tmpname() + os.remove(tmpfile) + end + local result = exec_lua([[ + local SOCK = ... + local uv = vim.uv + local server = uv.new_pipe(false) + server:bind(SOCK) + local init = nil + + server:listen(127, function(err) + assert(not err, err) + local client = uv.new_pipe() + server:accept(client) + client:read_start(require("vim.lsp.rpc").create_read_loop(function(body) + init = body + client:close() + end)) + end) + vim.lsp.start({ name = "dummy", cmd = vim.lsp.rpc.domain_socket_connect(SOCK) }) + vim.wait(1000, function() return init ~= nil end) + assert(init, "server must receive `initialize` request") + server:close() + server:shutdown() + return vim.json.decode(init) + ]], tmpfile) + eq(result.method, "initialize") + end) end) describe('handlers', function()