From 130f4344cf1a8fdafcf62b392ead863d1a1379f3 Mon Sep 17 00:00:00 2001 From: Lewis Russell Date: Sun, 8 Dec 2024 10:32:29 +0000 Subject: [PATCH] refactor(lsp/rpc): move transport logic to separate module --- runtime/doc/lsp.txt | 18 +- runtime/lua/vim/lsp/_transport.lua | 182 ++++++++++++++++++ runtime/lua/vim/lsp/rpc.lua | 288 +++++++++-------------------- scripts/luacats_grammar.lua | 4 +- 4 files changed, 274 insertions(+), 218 deletions(-) create mode 100644 runtime/lua/vim/lsp/_transport.lua diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 64145ebf11..83d191ed48 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -2433,14 +2433,15 @@ should_log({level}) *vim.lsp.log.should_log()* Lua module: vim.lsp.rpc *lsp-rpc* *vim.lsp.rpc.PublicClient* + Client RPC object Fields: ~ - • {request} (`fun(method: string, params: table?, callback: fun(err: lsp.ResponseError?, result: any), notify_reply_callback: fun(message_id: integer)?):boolean,integer?`) - see |vim.lsp.rpc.request()| - • {notify} (`fun(method: string, params: any):boolean`) see + • {request} (`fun(method: string, params: table?, callback: fun(err?: lsp.ResponseError, result: any), notify_reply_callback?: fun(message_id: integer)):boolean,integer?`) + See |vim.lsp.rpc.request()| + • {notify} (`fun(method: string, params: any): boolean`) See |vim.lsp.rpc.notify()| - • {is_closing} (`fun(): boolean`) - • {terminate} (`fun()`) + • {is_closing} (`fun(): boolean`) Indicates if the RPC is closing. + • {terminate} (`fun()`) Terminates the RPC client. connect({host_or_path}, {port}) *vim.lsp.rpc.connect()* @@ -2541,12 +2542,7 @@ start({cmd}, {dispatchers}, {extra_spawn_params}) *vim.lsp.rpc.start()* See |vim.system()| Return: ~ - (`vim.lsp.rpc.PublicClient`) 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. - • `terminate()` terminates the RPC client. See - |vim.lsp.rpc.PublicClient|. + (`vim.lsp.rpc.PublicClient`) See |vim.lsp.rpc.PublicClient|. ============================================================================== diff --git a/runtime/lua/vim/lsp/_transport.lua b/runtime/lua/vim/lsp/_transport.lua new file mode 100644 index 0000000000..19ff2a8ab0 --- /dev/null +++ b/runtime/lua/vim/lsp/_transport.lua @@ -0,0 +1,182 @@ +local uv = vim.uv +local log = require('vim.lsp.log') + +local is_win = vim.fn.has('win32') == 1 + +--- Checks whether a given path exists and is a directory. +---@param filename string path to check +---@return boolean +local function is_dir(filename) + local stat = uv.fs_stat(filename) + return stat and stat.type == 'directory' or false +end + +--- @class (private) vim.lsp.rpc.Transport +--- @field write fun(self: vim.lsp.rpc.Transport, msg: string) +--- @field is_closing fun(self: vim.lsp.rpc.Transport): boolean +--- @field terminate fun(self: vim.lsp.rpc.Transport) + +--- @class (private,exact) vim.lsp.rpc.Transport.Run : vim.lsp.rpc.Transport +--- @field new fun(): vim.lsp.rpc.Transport.Run +--- @field sysobj? vim.SystemObj +local TransportRun = {} + +--- @return vim.lsp.rpc.Transport.Run +function TransportRun.new() + return setmetatable({}, { __index = TransportRun }) +end + +--- @param cmd string[] Command to start the LSP server. +--- @param extra_spawn_params? vim.lsp.rpc.ExtraSpawnParams +--- @param on_read fun(err: any, data: string) +--- @param on_exit fun(code: integer, signal: integer) +function TransportRun:run(cmd, extra_spawn_params, on_read, on_exit) + local function on_stderr(_, chunk) + if chunk then + log.error('rpc', cmd[1], 'stderr', chunk) + end + end + + extra_spawn_params = extra_spawn_params or {} + + if extra_spawn_params.cwd then + assert(is_dir(extra_spawn_params.cwd), 'cwd must be a directory') + end + + local detached = not is_win + if extra_spawn_params.detached ~= nil then + detached = extra_spawn_params.detached + end + + local ok, sysobj_or_err = pcall(vim.system, cmd, { + stdin = true, + stdout = on_read, + stderr = on_stderr, + cwd = extra_spawn_params.cwd, + env = extra_spawn_params.env, + detach = detached, + }, function(obj) + on_exit(obj.code, obj.signal) + end) + + if not ok then + local err = sysobj_or_err --[[@as string]] + local sfx = err:match('ENOENT') + and '. The language server is either not installed, missing from PATH, or not executable.' + or string.format(' with error message: %s', err) + + error(('Spawning language server with cmd: `%s` failed%s'):format(vim.inspect(cmd), sfx)) + end + + self.sysobj = sysobj_or_err --[[@as vim.SystemObj]] +end + +function TransportRun:write(msg) + assert(self.sysobj):write(msg) +end + +function TransportRun:is_closing() + return self.sysobj == nil or self.sysobj:is_closing() +end + +function TransportRun:terminate() + assert(self.sysobj):kill(15) +end + +--- @class (private,exact) vim.lsp.rpc.Transport.Connect : vim.lsp.rpc.Transport +--- @field new fun(): vim.lsp.rpc.Transport.Connect +--- @field handle? uv.uv_pipe_t|uv.uv_tcp_t +--- Connect returns a PublicClient synchronously so the caller +--- can immediately send messages before the connection is established +--- -> Need to buffer them until that happens +--- @field connected boolean +--- @field closing boolean +--- @field msgbuf vim.Ringbuf +--- @field on_exit? fun(code: integer, signal: integer) +local TransportConnect = {} + +--- @return vim.lsp.rpc.Transport.Connect +function TransportConnect.new() + return setmetatable({ + connected = false, + -- size should be enough because the client can't really do anything until initialization is done + -- which required a response from the server - implying the connection got established + msgbuf = vim.ringbuf(10), + closing = false, + }, { __index = TransportConnect }) +end + +--- @param host_or_path string +--- @param port? integer +--- @param on_read fun(err: any, data: string) +--- @param on_exit? fun(code: integer, signal: integer) +function TransportConnect:connect(host_or_path, port, on_read, on_exit) + self.on_exit = on_exit + self.handle = ( + port and assert(uv.new_tcp(), 'Could not create new TCP socket') + or assert(uv.new_pipe(false), 'Pipe could not be opened.') + ) + + local function on_connect(err) + if err then + local address = not port and host_or_path or (host_or_path .. ':' .. port) + vim.schedule(function() + vim.notify( + string.format('Could not connect to %s, reason: %s', address, vim.inspect(err)), + vim.log.levels.WARN + ) + end) + return + end + self.handle:read_start(on_read) + self.connected = true + for msg in self.msgbuf do + self.handle:write(msg) + end + end + + if not port then + self.handle:connect(host_or_path, on_connect) + return + end + + --- @diagnostic disable-next-line:param-type-mismatch bad UV typing + local info = uv.getaddrinfo(host_or_path, nil) + local resolved_host = info and info[1] and info[1].addr or host_or_path + self.handle:connect(resolved_host, port, on_connect) +end + +function TransportConnect:write(msg) + if self.connected then + local _, err = self.handle:write(msg) + if err and not self.closing then + log.error('Error on handle:write: %q', err) + end + return + end + + self.msgbuf:push(msg) +end + +function TransportConnect:is_closing() + return self.closing +end + +function TransportConnect:terminate() + if self.closing then + return + end + self.closing = true + if self.handle then + self.handle:shutdown() + self.handle:close() + end + if self.on_exit then + self.on_exit(0, 0) + end +end + +return { + TransportRun = TransportRun, + TransportConnect = TransportConnect, +} diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index 2327a37ab1..a0d1fe776b 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -1,18 +1,8 @@ -local uv = vim.uv local log = require('vim.lsp.log') local protocol = require('vim.lsp.protocol') +local lsp_transport = require('vim.lsp._transport') local validate, schedule_wrap = vim.validate, vim.schedule_wrap -local is_win = vim.fn.has('win32') == 1 - ---- Checks whether a given path exists and is a directory. ----@param filename string path to check ----@return boolean -local function is_dir(filename) - local stat = uv.fs_stat(filename) - return stat and stat.type == 'directory' or false -end - --- Embeds the given string into a table and correctly computes `Content-Length`. --- ---@param message string @@ -242,8 +232,11 @@ local default_dispatchers = { end, } ----@private -function M.create_read_loop(handle_body, on_no_chunk, on_error) +--- @private +--- @param handle_body fun(body: string) +--- @param on_exit? fun() +--- @param on_error fun(err: any) +function M.create_read_loop(handle_body, on_exit, on_error) local parse_chunk = coroutine.wrap(request_parser_loop) --[[@as fun(chunk: string?): vim.lsp.rpc.Headers?, string?]] parse_chunk() return function(err, chunk) @@ -253,8 +246,8 @@ function M.create_read_loop(handle_body, on_no_chunk, on_error) end if not chunk then - if on_no_chunk then - on_no_chunk() + if on_exit then + on_exit() end return end @@ -262,7 +255,7 @@ function M.create_read_loop(handle_body, on_no_chunk, on_error) while true do local headers, body = parse_chunk(chunk) if headers then - handle_body(body) + handle_body(assert(body)) chunk = '' else break @@ -282,14 +275,14 @@ local Client = {} ---@private function Client:encode_and_send(payload) log.debug('rpc.send', payload) - if self.transport.is_closing() then + if self.transport:is_closing() then return false end 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)) + self.transport:write(format_message_with_content_length(jsonstr)) return true end @@ -323,7 +316,7 @@ end ---@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 fun(message_id: integer)|nil Callback to invoke as soon as a request is no longer pending +---@param notify_reply_callback? fun(message_id: integer) Callback to invoke as soon as a request is no longer pending ---@return boolean success `true` if request could be sent, `false` if not ---@return integer? message_id if request could be sent, `nil` if not function Client:request(method, params, callback, notify_reply_callback) @@ -337,21 +330,16 @@ function Client:request(method, params, callback, notify_reply_callback) method = method, params = params, }) - local message_callbacks = self.message_callbacks - local notify_reply_callbacks = self.notify_reply_callbacks - if result then - if message_callbacks then - message_callbacks[message_id] = schedule_wrap(callback) - else - return false, nil - end - if notify_reply_callback and notify_reply_callbacks then - notify_reply_callbacks[message_id] = schedule_wrap(notify_reply_callback) - end - return result, message_id - else - return false, nil + + if not result then + return false end + + self.message_callbacks[message_id] = schedule_wrap(callback) + if notify_reply_callback then + self.notify_reply_callbacks[message_id] = schedule_wrap(notify_reply_callback) + end + return result, message_id end ---@package @@ -370,7 +358,7 @@ end ---@param ... any ---@return boolean status ---@return any head ----@return any|nil ... +---@return any? ... function Client:pcall_handler(errkind, status, head, ...) if not status then self:on_error(errkind, head, ...) @@ -385,7 +373,7 @@ end ---@param ... any ---@return boolean status ---@return any head ----@return any|nil ... +---@return any? ... function Client:try_call(errkind, fn, ...) return self:pcall_handler(errkind, pcall(fn, ...)) end @@ -394,7 +382,8 @@ end -- time and log them. This would require storing the timestamp. I could call -- them with an error then, perhaps. ----@package +--- @package +--- @param body string function Client:handle_body(body) local ok, decoded = pcall(vim.json.decode, body, { luanil = { object = true } }) if not ok then @@ -406,7 +395,7 @@ function Client:handle_body(body) if type(decoded) ~= 'table' then self:on_error(M.client_errors.INVALID_SERVER_MESSAGE, decoded) elseif type(decoded.method) == 'string' and decoded.id then - local err --- @type lsp.ResponseError|nil + local err --- @type lsp.ResponseError? -- Schedule here so that the users functions don't trigger an error and -- we can still use the result. vim.schedule(coroutine.wrap(function() @@ -453,45 +442,36 @@ function Client:handle_body(body) local result_id = assert(tonumber(decoded.id), 'response id must be a number') -- Notify the user that a response was received for the request - local notify_reply_callbacks = self.notify_reply_callbacks - local notify_reply_callback = notify_reply_callbacks and notify_reply_callbacks[result_id] + local notify_reply_callback = self.notify_reply_callbacks[result_id] if notify_reply_callback then validate('notify_reply_callback', notify_reply_callback, 'function') notify_reply_callback(result_id) - notify_reply_callbacks[result_id] = nil + self.notify_reply_callbacks[result_id] = nil end - local message_callbacks = self.message_callbacks - -- Do not surface RequestCancelled to users, it is RPC-internal. if decoded.error then - local mute_error = false + assert(type(decoded.error) == 'table') if decoded.error.code == protocol.ErrorCodes.RequestCancelled then log.debug('Received cancellation ack', decoded) - mute_error = true - end - - if mute_error then -- Clear any callback since this is cancelled now. -- This is safe to do assuming that these conditions hold: -- - The server will not send a result callback after this cancellation. -- - If the server sent this cancellation ACK after sending the result, the user of this RPC -- client will ignore the result themselves. - if result_id and message_callbacks then - message_callbacks[result_id] = nil + if result_id then + self.message_callbacks[result_id] = nil end return end end - local callback = message_callbacks and message_callbacks[result_id] + local callback = self.message_callbacks[result_id] if callback then - message_callbacks[result_id] = nil + self.message_callbacks[result_id] = nil validate('callback', callback, 'function') if decoded.error then - decoded.error = setmetatable(decoded.error, { - __tostring = M.format_rpc_error, - }) + setmetatable(decoded.error, { __tostring = M.format_rpc_error }) end self:try_call( M.client_errors.SERVER_RESULT_CALLBACK_ERROR, @@ -517,11 +497,6 @@ function Client:handle_body(body) end end ----@class (private) vim.lsp.rpc.Transport ----@field write fun(msg: string) ----@field is_closing fun(): boolean ----@field terminate fun() - ---@param dispatchers vim.lsp.rpc.Dispatchers ---@param transport vim.lsp.rpc.Transport ---@return vim.lsp.rpc.Client @@ -536,11 +511,20 @@ local function new_client(dispatchers, transport) return setmetatable(state, { __index = Client }) end ----@class vim.lsp.rpc.PublicClient ----@field request fun(method: string, params: table?, callback: fun(err: lsp.ResponseError|nil, result: any), notify_reply_callback: fun(message_id: integer)|nil):boolean,integer? see |vim.lsp.rpc.request()| ----@field notify fun(method: string, params: any):boolean see |vim.lsp.rpc.notify()| ----@field is_closing fun(): boolean ----@field terminate fun() +--- Client RPC object +--- @class vim.lsp.rpc.PublicClient +--- +--- See [vim.lsp.rpc.request()] +--- @field request fun(method: string, params: table?, callback: fun(err?: lsp.ResponseError, result: any), notify_reply_callback?: fun(message_id: integer)):boolean,integer? +--- +--- See [vim.lsp.rpc.notify()] +--- @field notify fun(method: string, params: any): boolean +--- +--- Indicates if the RPC is closing. +--- @field is_closing fun(): boolean +--- +--- Terminates the RPC client. +--- @field terminate fun() ---@param client vim.lsp.rpc.Client ---@return vim.lsp.rpc.PublicClient @@ -551,20 +535,20 @@ local function public_client(client) ---@private function result.is_closing() - return client.transport.is_closing() + return client.transport:is_closing() end ---@private function result.terminate() - client.transport.terminate() + client.transport:terminate() end --- Sends a request to the LSP server and runs {callback} upon response. --- ---@param method (string) 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 fun(message_id: integer)|nil Callback to invoke as soon as a request is no longer pending + ---@param callback fun(err: lsp.ResponseError?, result: any) Callback to invoke + ---@param notify_reply_callback? fun(message_id: integer) Callback to invoke as soon as a request is no longer pending ---@return boolean success `true` if request could be sent, `false` if not ---@return integer? message_id if request could be sent, `nil` if not function result.request(method, params, callback, notify_reply_callback) @@ -610,6 +594,21 @@ local function merge_dispatchers(dispatchers) return merged end +--- @param client vim.lsp.rpc.Client +--- @param on_exit? fun() +local function create_client_read_loop(client, on_exit) + --- @param body string + local function handle_body(body) + client:handle_body(body) + end + + local function on_error(err) + client:on_error(M.client_errors.READ_ERROR, err) + end + + return M.create_read_loop(handle_body, on_exit, on_error) +end + --- Create a LSP RPC client factory that connects to either: --- --- - a named pipe (windows) @@ -623,77 +622,20 @@ end ---@param port integer? TCP port to connect to. If absent the first argument must be a pipe ---@return fun(dispatchers: vim.lsp.rpc.Dispatchers): vim.lsp.rpc.PublicClient function M.connect(host_or_path, port) + validate('host_or_path', host_or_path, 'string') + validate('port', port, 'number', true) + return function(dispatchers) + validate('dispatchers', dispatchers, 'table', true) + dispatchers = merge_dispatchers(dispatchers) - local handle = ( - port == nil - and assert( - uv.new_pipe(false), - string.format('Pipe with name %s could not be opened.', host_or_path) - ) - or assert(uv.new_tcp(), 'Could not create new TCP socket') - ) - local closing = false - -- Connect returns a PublicClient synchronously so the caller - -- can immediately send messages before the connection is established - -- -> Need to buffer them until that happens - local connected = false - -- size should be enough because the client can't really do anything until initialization is done - -- which required a response from the server - implying the connection got established - local msgbuf = vim.ringbuf(10) - local transport = { - write = function(msg) - if connected then - local _, err = handle:write(msg) - if err and not closing then - log.error('Error on handle:write: %q', err) - end - else - msgbuf:push(msg) - end - end, - is_closing = function() - return closing - end, - terminate = function() - if not closing then - closing = true - handle:shutdown() - handle:close() - dispatchers.on_exit(0, 0) - end - end, - } + + local transport = lsp_transport.TransportConnect.new() local client = new_client(dispatchers, transport) - local function on_connect(err) - if err then - local address = port == nil and host_or_path or (host_or_path .. ':' .. port) - vim.schedule(function() - vim.notify( - string.format('Could not connect to %s, reason: %s', address, vim.inspect(err)), - vim.log.levels.WARN - ) - end) - return - end - local handle_body = function(body) - client:handle_body(body) - end - handle:read_start(M.create_read_loop(handle_body, transport.terminate, function(read_err) - client:on_error(M.client_errors.READ_ERROR, read_err) - end)) - connected = true - for msg in msgbuf do - handle:write(msg) - end - end - if port == nil then - handle:connect(host_or_path, on_connect) - else - local info = uv.getaddrinfo(host_or_path, nil) - local resolved_host = info and info[1] and info[1].addr or host_or_path - handle:connect(resolved_host, port, on_connect) - end + local on_read = create_client_read_loop(client, function() + transport:terminate() + end) + transport:connect(host_or_path, port, on_read, dispatchers.on_exit) return public_client(client) end @@ -713,83 +655,19 @@ end --- @param cmd string[] Command to start the LSP server. --- @param dispatchers? vim.lsp.rpc.Dispatchers --- @param extra_spawn_params? vim.lsp.rpc.ExtraSpawnParams ---- @return vim.lsp.rpc.PublicClient : 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. ---- - `terminate()` terminates the RPC client. +--- @return vim.lsp.rpc.PublicClient function M.start(cmd, dispatchers, extra_spawn_params) log.info('Starting RPC client', { cmd = cmd, extra = extra_spawn_params }) validate('cmd', cmd, 'table') validate('dispatchers', dispatchers, 'table', true) - extra_spawn_params = extra_spawn_params or {} - - if extra_spawn_params.cwd then - assert(is_dir(extra_spawn_params.cwd), 'cwd must be a directory') - end - dispatchers = merge_dispatchers(dispatchers) - local sysobj ---@type vim.SystemObj - - local client = new_client(dispatchers, { - write = function(msg) - sysobj:write(msg) - end, - is_closing = function() - return sysobj == nil or sysobj:is_closing() - end, - terminate = function() - sysobj:kill(15) - end, - }) - - local handle_body = function(body) - client:handle_body(body) - end - - local stdout_handler = M.create_read_loop(handle_body, nil, function(err) - client:on_error(M.client_errors.READ_ERROR, err) - end) - - local stderr_handler = function(_, chunk) - if chunk then - log.error('rpc', cmd[1], 'stderr', chunk) - end - end - - local detached = not is_win - if extra_spawn_params.detached ~= nil then - detached = extra_spawn_params.detached - end - - local ok, sysobj_or_err = pcall(vim.system, cmd, { - stdin = true, - stdout = stdout_handler, - stderr = stderr_handler, - cwd = extra_spawn_params.cwd, - env = extra_spawn_params.env, - detach = detached, - }, function(obj) - dispatchers.on_exit(obj.code, obj.signal) - end) - - if not ok then - local err = sysobj_or_err --[[@as string]] - local sfx --- @type string - if string.match(err, 'ENOENT') then - sfx = '. The language server is either not installed, missing from PATH, or not executable.' - else - sfx = string.format(' with error message: %s', err) - end - local msg = - string.format('Spawning language server with cmd: `%s` failed%s', vim.inspect(cmd), sfx) - error(msg) - end - - sysobj = sysobj_or_err --[[@as vim.SystemObj]] + local transport = lsp_transport.TransportRun.new() + local client = new_client(dispatchers, transport) + local on_read = create_client_read_loop(client) + transport:run(cmd, extra_spawn_params, on_read, dispatchers.on_exit) return public_client(client) end diff --git a/scripts/luacats_grammar.lua b/scripts/luacats_grammar.lua index 34c1470fea..b700bcf58f 100644 --- a/scripts/luacats_grammar.lua +++ b/scripts/luacats_grammar.lua @@ -160,9 +160,9 @@ local typedef = P({ return vim.trim(match):gsub('^%((.*)%)$', '%1'):gsub('%?+', '?') end -local opt_exact = opt(Cg(Pf('(exact)'), 'access')) local access = P('private') + P('protected') + P('package') local caccess = Cg(access, 'access') +local cattr = Cg(comma(access + P('exact')), 'access') local desc_delim = Sf '#:' + ws local desc = Cg(rep(any), 'desc') local opt_desc = opt(desc_delim * desc) @@ -178,7 +178,7 @@ local grammar = P { + annot('type', comma1(Ct(v.ctype)) * opt_desc) + annot('cast', ty_name * ws * opt(Sf('+-')) * v.ctype) + annot('generic', ty_name * opt(colon * v.ctype)) - + annot('class', opt_exact * opt(paren(caccess)) * fill * ty_name * opt_parent) + + annot('class', opt(paren(cattr)) * fill * ty_name * opt_parent) + annot('field', opt(caccess * ws) * v.field_name * ws * v.ctype * opt_desc) + annot('operator', ty_name * opt(paren(Cg(v.ctype, 'argtype'))) * colon * v.ctype) + annot(access)