diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 82e09758de..88ece33286 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -116,21 +116,28 @@ To remove or override BUFFER-LOCAL defaults, define a |LspAttach| handler: >lua end, }) < - +============================================================================== COMMANDS *:lsp* -:lsp restart {names} *:lsp-restart* - Restarts the given language servers. If no names are given, all active - servers are restarted. +:lsp enable {name}? *:lsp-enable* + Enables the given lsp clients. If no names are given, all clients + configured with |vim.lsp.config()| with a filetype matching the current + buffer's filetype are enabled. Use |vim.lsp.enable()| for non-interactive + use. -:lsp start {names} *:lsp-start* - Starts the given language servers. If no names are given, all configured - with |vim.lsp.config()| with filetype matching the current buffer are - started. +:lsp disable {name}? *:lsp-disable* + Disables (and stops) the given lsp clients. If no names are given, + all clients attached to the current buffer are disabled. Use + |vim.lsp.enable()| with `enable=false` for non-interactive use. -:lsp stop {names} *:lsp-stop* - Stops and disables the given language servers. If no names are given, all - servers on the current buffer are stopped. +:lsp restart {client}? *:lsp-restart* + Restarts the given lsp clients. If no client names are given, all active + clients attached to the current buffer are restarted. + +:lsp stop {client}? *:lsp-stop* + Stops the given lsp clients. If no client names are given, all active + clients attached to the current buffer are stopped. Use |Client:stop()| + for non-interactive use. ============================================================================== CONFIG *lsp-config* diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 4259f035f1..93ab71269f 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -291,7 +291,8 @@ LSP • Support for `workspace/diagnostic/refresh`: https://microsoft.github.io/language-server-protocol/specification/#diagnostic_refresh - Support for dynamic registration for `textDocument/diagnostic` -• |:lsp| command to restart/stop LSP servers. +• |:lsp| for interactively enabling, disabling, restarting, and stopping lsp + clients. LUA diff --git a/runtime/lua/vim/_core/ex_cmd/lsp.lua b/runtime/lua/vim/_core/ex_cmd/lsp.lua new file mode 100644 index 0000000000..56096e86b4 --- /dev/null +++ b/runtime/lua/vim/_core/ex_cmd/lsp.lua @@ -0,0 +1,212 @@ +local api = vim.api +local lsp = vim.lsp + +local M = {} + +--- @return string[] +local function get_client_names() + local client_names = vim + .iter(lsp.get_clients()) + :map(function(client) + return client.name + end) + :totable() + return vim.list.unique(client_names) +end + +--- @return string[] +local function get_config_names() + local config_names = vim + .iter(api.nvim_get_runtime_file('lsp/*.lua', true)) + --- @param path string + :map(function(path) + local file_name = path:match('[^/]*.lua$') + return file_name:sub(0, #file_name - 4) + end) + :totable() + + --- @diagnostic disable-next-line + vim.list_extend(config_names, vim.tbl_keys(lsp.config._configs)) + + return vim + .iter(vim.list.unique(config_names)) + --- @param name string + :filter(function(name) + return name ~= '*' + end) + :totable() +end + +--- @param filter fun(string):boolean +--- @return fun():string[] +local function filtered_config_names(filter) + return function() + return vim.iter(get_config_names()):filter(filter):totable() + end +end + +local complete_args = { + enable = filtered_config_names(function(name) + return not lsp.is_enabled(name) + end), + disable = filtered_config_names(function(name) + return lsp.is_enabled(name) + end), + restart = get_client_names, + stop = get_client_names, +} + +--- @param names string[] +--- @param enable? boolean +local function checked_enable(names, enable) + for _, name in ipairs(names) do + if name:find('*') == nil and lsp.config[name] ~= nil then + lsp.enable(name, enable) + else + vim.notify(("No client config named '%s'"):format(name), vim.log.levels.ERROR) + end + end +end + +--- @param config_names string[] +local function ex_lsp_enable(config_names) + -- Default to enabling all clients matching the filetype of the current buffer. + if #config_names == 0 then + local filetype = vim.bo.filetype + for _, name in ipairs(get_config_names()) do + local filetypes = lsp.config[name].filetypes + if filetypes and vim.tbl_contains(filetypes, filetype) then + table.insert(config_names, name) + end + end + end + + checked_enable(config_names) +end + +--- @param config_names string[] +local function ex_lsp_disable(config_names) + -- Default to disabling all clients attached to the current buffer. + if #config_names == 0 then + config_names = vim + .iter(lsp.get_clients { bufnr = api.nvim_get_current_buf() }) + :map(function(client) + return client.name + end) + :filter(function(name) + return lsp.config[name] ~= nil + end) + :totable() + end + + checked_enable(config_names, false) +end + +--- @param client_names string[] +--- @return vim.lsp.Client[] +local function get_clients_from_names(client_names) + -- Default to stopping all active clients attached to the current buffer. + if #client_names == 0 then + return lsp.get_clients { bufnr = api.nvim_get_current_buf() } + else + return vim + .iter(client_names) + :map(function(name) + local clients = lsp.get_clients { name = name } + if #clients == 0 then + vim.notify(("No active clients named '%s'"):format(name), vim.log.levels.ERROR) + end + return clients + end) + :flatten() + :totable() + end +end + +--- @param client_names string[] +local function ex_lsp_restart(client_names) + local clients = get_clients_from_names(client_names) + + for _, client in ipairs(clients) do + --- @type integer[] + local attached_buffers = vim.tbl_keys(client.attached_buffers) + + -- Reattach new client once the old one exits + api.nvim_create_autocmd('LspDetach', { + group = api.nvim_create_augroup('nvim.lsp.ex_restart_' .. client.id, {}), + callback = function(info) + if info.data.client_id ~= client.id then + return + end + + local new_client_id = lsp.start(client.config, { attach = false }) + if new_client_id then + for _, buffer in ipairs(attached_buffers) do + lsp.buf_attach_client(buffer, new_client_id) + end + end + + return true -- Delete autocmd + end, + }) + + client:stop(client.exit_timeout) + end +end + +--- @param client_names string[] +local function ex_lsp_stop(client_names) + local clients = get_clients_from_names(client_names) + + for _, client in ipairs(clients) do + client:stop(client.exit_timeout) + end +end + +local actions = { + enable = ex_lsp_enable, + disable = ex_lsp_disable, + restart = ex_lsp_restart, + stop = ex_lsp_stop, +} + +local available_subcmds = vim.tbl_keys(actions) + +--- Implements command: `:lsp {subcmd} {name}?`. +--- @param args string +M.ex_lsp = function(args) + local fargs = api.nvim_parse_cmd('lsp ' .. args, {}).args + if not fargs then + return + end + local subcmd = fargs[1] + if not vim.list_contains(available_subcmds, subcmd) then + vim.notify(("Invalid subcommand '%s'"):format(subcmd), vim.log.levels.ERROR) + return + end + + local clients = { unpack(fargs, 2) } + + actions[subcmd](clients) +end + +--- Completion logic for `:lsp` command +--- @param line string content of the current command line +--- @return string[] list of completions +function M.lsp_complete(line) + local split = vim.split(line, '%s+') + if #split == 2 then + return available_subcmds + else + local subcmd = split[2] + return vim + .iter(complete_args[subcmd]()) + --- @param n string + :map(function(n) + return vim.fn.escape(n, ' \t') + end) + :totable() + end +end + +return M diff --git a/runtime/lua/vim/lsp/_cmd.lua b/runtime/lua/vim/lsp/_cmd.lua deleted file mode 100644 index 91b9b365f1..0000000000 --- a/runtime/lua/vim/lsp/_cmd.lua +++ /dev/null @@ -1,142 +0,0 @@ -local lsp = vim.lsp - -local M = {} - ---- @param filter? vim.lsp.get_clients.Filter ---- @return string[] -local function get_client_names(filter) - return vim - .iter(lsp.get_clients(filter)) - :map(function(client) - return client.name - end) - :filter(function(name) - return vim.lsp.config[name] ~= nil - end) - :totable() -end - ----@return string[] -local function get_config_names() - local config_names = vim - .iter(vim.api.nvim_get_runtime_file('lsp/*.lua', true)) - ---@param path string - :map(function(path) - local file_name = path:match('[^/]*.lua$') - return file_name:sub(0, #file_name - 4) - end) - :totable() - - ---@diagnostic disable-next-line: invisible - vim.list_extend(config_names, vim.tbl_keys(vim.lsp.config._configs)) - return vim.list.unique(config_names) -end - -local complete_args = { - start = get_config_names, - stop = get_client_names, - restart = get_client_names, -} - -local function ex_lsp_start(servers) - -- Default to enabling all servers matching the filetype of the current buffer. - -- This assumes that they've been explicitly configured through `vim.lsp.config`, - -- otherwise they won't be present in the private `vim.lsp.config._configs` table. - if #servers == 0 then - local filetype = vim.bo.filetype - ---@diagnostic disable-next-line: invisible - for name, _ in pairs(vim.lsp.config._configs) do - local filetypes = vim.lsp.config[name].filetypes - if filetypes and vim.tbl_contains(filetypes, filetype) then - table.insert(servers, name) - end - end - end - - vim.lsp.enable(servers) -end - ----@param clients string[] -local function ex_lsp_stop(clients) - -- Default to disabling all servers on current buffer - if #clients == 0 then - clients = get_client_names { bufnr = vim.api.nvim_get_current_buf() } - end - - for _, name in ipairs(clients) do - if vim.lsp.config[name] == nil then - vim.notify(("Invalid server name '%s'"):format(name)) - else - vim.lsp.enable(name, false) - end - end -end - ----@param clients string[] -local function ex_lsp_restart(clients) - -- Default to restarting all active servers - if #clients == 0 then - clients = get_client_names() - end - - for _, name in ipairs(clients) do - if vim.lsp.config[name] == nil then - vim.notify(("Invalid server name '%s'"):format(name)) - else - vim.lsp.enable(name, false) - end - end - - local timer = assert(vim.uv.new_timer()) - timer:start(500, 0, function() - for _, name in ipairs(clients) do - vim.schedule_wrap(function(x) - vim.lsp.enable(x) - end)(name) - end - end) -end - -local actions = { - start = ex_lsp_start, - restart = ex_lsp_restart, - stop = ex_lsp_stop, -} - -local available_subcmds = vim.tbl_keys(actions) - ---- Use for `:lsp {subcmd} {clients}` command ----@param args string -M._ex_lsp = function(args) - local fargs = vim.api.nvim_parse_cmd('lsp ' .. args, {}).args - if not fargs then - return - end - local subcmd = fargs[1] - if not vim.list_contains(available_subcmds, subcmd) then - vim.notify(("Invalid subcommand '%s'"):format(subcmd), vim.log.levels.ERROR) - return - end - - local clients = { unpack(fargs, 2) } - - actions[subcmd](clients) -end - ---- Completion logic for `:lsp` command ---- @param line string content of the current command line ---- @return string[] list of completions -function M._ex_lsp_complete(line) - local splited = vim.split(line, '%s+') - if #splited == 2 then - return available_subcmds - else - local subcmd = splited[2] - ---@param n string - return vim.tbl_map(function(n) - return vim.fn.escape(n, [[" |]]) - end, complete_args[subcmd]()) - end -end - -return M diff --git a/src/nvim/cmdexpand.c b/src/nvim/cmdexpand.c index 4f6be69c3b..af56fd4ffe 100644 --- a/src/nvim/cmdexpand.c +++ b/src/nvim/cmdexpand.c @@ -2875,7 +2875,7 @@ static char *get_lsp_arg(expand_T *xp FUNC_ATTR_UNUSED, int idx) ADD_C(args, CSTR_AS_OBJ(xp->xp_line)); // Build the current command line as a Lua string argument - Object res = NLUA_EXEC_STATIC("return require'vim.lsp._cmd'._ex_lsp_complete(...)", args, + Object res = NLUA_EXEC_STATIC("return require'vim._core.ex_cmd.lsp'.lsp_complete(...)", args, kRetObject, NULL, &err); api_clear_error(&err); @@ -2933,7 +2933,7 @@ static int ExpandOther(char *pat, expand_T *xp, regmatch_T *rmp, char ***matches { EXPAND_SCRIPTNAMES, get_scriptnames_arg, true, false }, { EXPAND_RETAB, get_retab_arg, true, true }, { EXPAND_CHECKHEALTH, get_healthcheck_names, true, false }, - [32] = { EXPAND_LSP, get_lsp_arg, true, false }, + { EXPAND_LSP, get_lsp_arg, true, false }, }; int ret = FAIL; diff --git a/src/nvim/ex_cmds.lua b/src/nvim/ex_cmds.lua index 1b5ce946d8..a24db86b90 100644 --- a/src/nvim/ex_cmds.lua +++ b/src/nvim/ex_cmds.lua @@ -1672,7 +1672,7 @@ M.cmds = { }, { command = 'lsp', - flags = bit.bor(NEEDARG, EXTRA, TRLBAR), + flags = bit.bor(NEEDARG, EXTRA), addr_type = 'ADDR_NONE', func = 'ex_lsp', }, diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c index bf9c5e3b78..75a0db3e3b 100644 --- a/src/nvim/ex_docmd.c +++ b/src/nvim/ex_docmd.c @@ -8056,7 +8056,10 @@ static void ex_lsp(exarg_T *eap) ADD_C(args, CSTR_AS_OBJ(eap->arg)); - NLUA_EXEC_STATIC("require'vim.lsp._cmd'._ex_lsp(...)", args, kRetNilBool, NULL, &err); + NLUA_EXEC_STATIC("require'vim._core.ex_cmd.lsp'.ex_lsp(...)", args, kRetNilBool, NULL, &err); + if (ERROR_SET(&err)) { + emsg(err.msg); + } api_clear_error(&err); } diff --git a/test/functional/ex_cmds/lsp_spec.lua b/test/functional/ex_cmds/lsp_spec.lua new file mode 100644 index 0000000000..297e1d321e --- /dev/null +++ b/test/functional/ex_cmds/lsp_spec.lua @@ -0,0 +1,129 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() +local t_lsp = require('test.functional.plugin.lsp.testutil') + +local clear = n.clear +local eq = t.eq +local exec_lua = n.exec_lua + +local create_server_definition = t_lsp.create_server_definition + +describe(':lsp', function() + before_each(function() + clear() + exec_lua(create_server_definition) + exec_lua(function() + local server = _G._create_server() + vim.lsp.config('dummy', { + filetypes = { 'lua' }, + cmd = server.cmd, + }) + vim.cmd('set ft=lua') + end) + end) + + for _, test_with_arguments in ipairs({ true, false }) do + local test_message_suffix, lsp_command_suffix + if test_with_arguments then + test_message_suffix = ' with arguments' + lsp_command_suffix = ' dummy' + else + test_message_suffix = ' without arguments' + lsp_command_suffix = '' + end + + it('enable' .. test_message_suffix, function() + local is_enabled = exec_lua(function() + vim.cmd('lsp enable' .. lsp_command_suffix) + return vim.lsp.is_enabled('dummy') + end) + eq(true, is_enabled) + end) + + it('disable' .. test_message_suffix, function() + local is_enabled = exec_lua(function() + vim.lsp.enable('dummy') + vim.cmd('lsp disable' .. lsp_command_suffix) + return vim.lsp.is_enabled('dummy') + end) + eq(false, is_enabled) + end) + + it('restart' .. test_message_suffix, function() + local ids_differ = exec_lua(function() + vim.lsp.enable('dummy') + local old_id = vim.lsp.get_clients()[1].id + + vim.cmd('lsp restart' .. lsp_command_suffix) + vim.wait(1000, function() + return old_id ~= vim.lsp.get_clients()[1].id + end) + local new_id = vim.lsp.get_clients()[1].id + return old_id ~= new_id + end) + eq(true, ids_differ) + end) + + it('stop' .. test_message_suffix, function() + local running_clients = exec_lua(function() + vim.lsp.enable('dummy') + vim.cmd('lsp stop' .. lsp_command_suffix) + vim.wait(1000, function() + return #vim.lsp.get_clients() == 0 + end) + return #vim.lsp.get_clients() + end) + eq(0, running_clients) + end) + end + + it('subcommand completion', function() + local completions = exec_lua(function() + return vim.fn.getcompletion('lsp ', 'cmdline') + end) + eq({ 'disable', 'enable', 'restart', 'stop' }, completions) + end) + + it('argument completion', function() + local completions = exec_lua(function() + return vim.fn.getcompletion('lsp enable ', 'cmdline') + end) + eq({ 'dummy' }, completions) + end) + + it('argument completion with spaces', function() + local cmd_length = exec_lua(function() + local server = _G._create_server() + vim.lsp.config('client name with space', { + cmd = server.cmd, + }) + local completion = vim.fn.getcompletion('lsp enable cl ', 'cmdline')[1] + return #vim.api.nvim_parse_cmd('lsp enable ' .. completion, {}).args + end) + eq(2, cmd_length) + end) + + it('argument completion with special characters', function() + local cmd_length = exec_lua(function() + local server = _G._create_server() + vim.lsp.config('client"name|with\tsymbols', { + cmd = server.cmd, + }) + local completion = vim.fn.getcompletion('lsp enable cl ', 'cmdline')[1] + return #vim.api.nvim_parse_cmd('lsp enable ' .. completion, {}).args + end) + eq(2, cmd_length) + end) + + it('fail with no runtime without crashing', function() + clear { + args_rm = { '-u' }, + args = { '-u', 'NONE' }, + env = { VIMRUNTIME = 'non-existent' }, + } + eq( + [[Vim(lsp):Lua: [string ""]:0: module 'vim._core.ex_cmd.lsp' not found:]], + vim.split(t.pcall_err(n.command, 'lsp enable dummy'), '\n')[1] + ) + end) +end)