commit bd225422a5b75ea8064603d21f7b38da30007910
parent 63abb1a88f2cd042667e6d314794d6aa0d382c9a
Author: Olivia Kinnear <git@superatomic.dev>
Date: Thu, 11 Dec 2025 21:16:10 -0600
fix(lsp): tests for :lsp, rename start/stop
- Rename :lsp start/stop to enable/disable
- Move lua section of `:lsp` to `vim/_core`
- Add tests
Diffstat:
8 files changed, 370 insertions(+), 160 deletions(-)
diff --git 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 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 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 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*
diff --git 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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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)