neovim

Neovim text editor
git clone https://git.dasho.dev/neovim.git
Log | Files | Refs | README

commit 02def1a32ef7acb6b7d940193815ba06e3775ecf
parent 89d26d61d271231b7753186de898bb95d2a83372
Author: Justin M. Keyes <justinkz@gmail.com>
Date:   Tue, 16 Dec 2025 13:48:43 -0500

Merge #36925 :lsp command


Diffstat:
Mruntime/doc/index.txt | 1+
Mruntime/doc/lsp.txt | 29+++++++++++++++++++++++------
Mruntime/doc/news.txt | 2++
Mruntime/doc/vim_diff.txt | 1+
Aruntime/lua/vim/_core/ex_cmd/lsp.lua | 212+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mruntime/lua/vim/lsp.lua | 8--------
Msrc/nvim/cmdexpand.c | 43+++++++++++++++++++++++++++++++++++++++++++
Msrc/nvim/cmdexpand_defs.h | 1+
Msrc/nvim/ex_cmds.lua | 6++++++
Msrc/nvim/ex_docmd.c | 15+++++++++++++++
Atest/functional/ex_cmds/lsp_spec.lua | 129+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mtest/old/testdir/test_cmdline.vim | 2+-
12 files changed, 434 insertions(+), 15 deletions(-)

diff --git a/runtime/doc/index.txt b/runtime/doc/index.txt @@ -1420,6 +1420,7 @@ Tag Command Action ~ |:lpfile| :lpf[ile] go to last location in previous file |:lrewind| :lr[ewind] go to the specified location, default first one |:ls| :ls list all buffers +|:lsp| :lsp language server protocol |:ltag| :lt[ag] jump to tag and add matching tags to the location list |:lunmap| :lu[nmap] like ":unmap!" but includes Lang-Arg mode diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt @@ -117,6 +117,29 @@ To remove or override BUFFER-LOCAL defaults, define a |LspAttach| handler: >lua }) < ============================================================================== +COMMANDS *:lsp* + +: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 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 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* You can configure LSP behavior statically via vim.lsp.config(), and @@ -1023,12 +1046,6 @@ enable({name}, {enable}) *vim.lsp.enable()* vim.lsp.enable({'lua_ls', 'pyright'}) < - Example: *lsp-restart* Passing `false` stops and detaches the client(s). - Thus you can "restart" LSP by disabling and re-enabling a given config: >lua - vim.lsp.enable('clangd', false) - vim.lsp.enable('clangd', true) -< - Example: To dynamically decide whether LSP is activated, define a |lsp-root_dir()| function which calls `on_dir()` only when you want that config to activate: >lua diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt @@ -291,6 +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| for interactively enabling, disabling, restarting, and stopping lsp + clients. LUA diff --git a/runtime/doc/vim_diff.txt b/runtime/doc/vim_diff.txt @@ -307,6 +307,7 @@ Commands: - |:EditQuery| - |:Inspect| - |:InspectTree| +- |:lsp| - |:Man| is available by default, with many improvements such as completion - |:match| can be invoked before highlight group is defined - |:restart| diff --git a/runtime/lua/vim/_core/ex_cmd/lsp.lua 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.lua b/runtime/lua/vim/lsp.lua @@ -528,14 +528,6 @@ end --- vim.lsp.enable({'lua_ls', 'pyright'}) --- ``` --- ---- Example: [lsp-restart]() Passing `false` stops and detaches the client(s). Thus you can ---- "restart" LSP by disabling and re-enabling a given config: ---- ---- ```lua ---- vim.lsp.enable('clangd', false) ---- vim.lsp.enable('clangd', true) ---- ``` ---- --- Example: To _dynamically_ decide whether LSP is activated, define a |lsp-root_dir()| function --- which calls `on_dir()` only when you want that config to activate: --- diff --git a/src/nvim/cmdexpand.c b/src/nvim/cmdexpand.c @@ -1301,6 +1301,7 @@ char *addstar(char *fname, size_t len, int context) || ((context == EXPAND_TAGS_LISTFILES || context == EXPAND_TAGS) && fname[0] == '/') || context == EXPAND_CHECKHEALTH + || context == EXPAND_LSP || context == EXPAND_LUA) { retval = xstrnsave(fname, len); } else { @@ -2311,6 +2312,10 @@ static const char *set_context_by_cmdname(const char *cmd, cmdidx_T cmdidx, expa xp->xp_context = EXPAND_CHECKHEALTH; break; + case CMD_lsp: + xp->xp_context = EXPAND_LSP; + break; + case CMD_retab: xp->xp_context = EXPAND_RETAB; xp->xp_pattern = (char *)arg; @@ -2849,6 +2854,43 @@ static char *get_healthcheck_names(expand_T *xp FUNC_ATTR_UNUSED, int idx) return NULL; } +/// Completion for |:lsp| command. +/// +/// Given to ExpandGeneric() to obtain `:lsp` completion. +/// @param[in] idx Index of the item. +/// @param[in] xp Not used. +static char *get_lsp_arg(expand_T *xp FUNC_ATTR_UNUSED, int idx) +{ + static Object names = OBJECT_INIT; + static char *last_xp_line = NULL; + static unsigned last_gen = 0; + + if (last_xp_line == NULL || strcmp(last_xp_line, + xp->xp_line) != 0 + || last_gen != get_cmdline_last_prompt_id()) { + xfree(last_xp_line); + last_xp_line = xstrdup(xp->xp_line); + MAXSIZE_TEMP_ARRAY(args, 1); + Error err = ERROR_INIT; + + 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._core.ex_cmd.lsp'.lsp_complete(...)", args, + kRetObject, NULL, + &err); + api_clear_error(&err); + api_free_object(names); + names = res; + last_gen = get_cmdline_last_prompt_id(); + } + + if (names.type == kObjectTypeArray && idx < (int)names.data.array.size + && names.data.array.items[idx].type == kObjectTypeString) { + return names.data.array.items[idx].data.string.data; + } + return NULL; +} + /// Do the expansion based on xp->xp_context and "rmp". static int ExpandOther(char *pat, expand_T *xp, regmatch_T *rmp, char ***matches, int *numMatches) { @@ -2891,6 +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 }, + { EXPAND_LSP, get_lsp_arg, true, false }, }; int ret = FAIL; diff --git a/src/nvim/cmdexpand_defs.h b/src/nvim/cmdexpand_defs.h @@ -117,6 +117,7 @@ enum { EXPAND_RETAB, EXPAND_CHECKHEALTH, EXPAND_LUA, + EXPAND_LSP, }; /// Type used by ExpandGeneric() diff --git a/src/nvim/ex_cmds.lua b/src/nvim/ex_cmds.lua @@ -1671,6 +1671,12 @@ M.cmds = { func = 'buflist_list', }, { + command = 'lsp', + flags = bit.bor(NEEDARG, EXTRA), + addr_type = 'ADDR_NONE', + func = 'ex_lsp', + }, + { command = 'move', flags = bit.bor(RANGE, WHOLEFOLD, EXTRA, TRLBAR, CMDWIN, LOCK_OK, MODIFY), addr_type = 'ADDR_LINES', diff --git a/src/nvim/ex_docmd.c b/src/nvim/ex_docmd.c @@ -8048,6 +8048,21 @@ static void ex_terminal(exarg_T *eap) do_cmdline_cmd(ex_cmd); } +/// ":lsp {subcmd} {clients}" +static void ex_lsp(exarg_T *eap) +{ + Error err = ERROR_INIT; + MAXSIZE_TEMP_ARRAY(args, 1); + + ADD_C(args, CSTR_AS_OBJ(eap->arg)); + + 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); +} + /// ":fclose" static void ex_fclose(exarg_T *eap) { diff --git a/test/functional/ex_cmds/lsp_spec.lua 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 "<nvim>"]:0: module 'vim._core.ex_cmd.lsp' not found:]], + vim.split(t.pcall_err(n.command, 'lsp enable dummy'), '\n')[1] + ) + end) +end) diff --git a/test/old/testdir/test_cmdline.vim b/test/old/testdir/test_cmdline.vim @@ -1289,7 +1289,7 @@ func Test_cmdline_complete_various() " completion for a command with a trailing command call feedkeys(":ls | ls\<C-A>\<C-B>\"\<CR>", 'xt') - call assert_equal("\"ls | ls", @:) + call assert_equal("\"ls | ls lsp", @:) " completion for a command with an CTRL-V escaped argument call feedkeys(":ls \<C-V>\<C-V>a\<C-A>\<C-B>\"\<CR>", 'xt')