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
This commit is contained in:
Olivia Kinnear
2025-12-11 21:16:10 -06:00
committed by Justin M. Keyes
parent 63abb1a88f
commit bd225422a5
8 changed files with 368 additions and 158 deletions

View File

@@ -116,21 +116,28 @@ To remove or override BUFFER-LOCAL defaults, define a |LspAttach| handler: >lua
end, end,
}) })
< <
==============================================================================
COMMANDS *:lsp* COMMANDS *:lsp*
:lsp restart {names} *:lsp-restart* :lsp enable {name}? *:lsp-enable*
Restarts the given language servers. If no names are given, all active Enables the given lsp clients. If no names are given, all clients
servers are restarted. 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* :lsp disable {name}? *:lsp-disable*
Starts the given language servers. If no names are given, all configured Disables (and stops) the given lsp clients. If no names are given,
with |vim.lsp.config()| with filetype matching the current buffer are all clients attached to the current buffer are disabled. Use
started. |vim.lsp.enable()| with `enable=false` for non-interactive use.
:lsp stop {names} *:lsp-stop* :lsp restart {client}? *:lsp-restart*
Stops and disables the given language servers. If no names are given, all Restarts the given lsp clients. If no client names are given, all active
servers on the current buffer are stopped. 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* CONFIG *lsp-config*

View File

@@ -291,7 +291,8 @@ LSP
• Support for `workspace/diagnostic/refresh`: • Support for `workspace/diagnostic/refresh`:
https://microsoft.github.io/language-server-protocol/specification/#diagnostic_refresh https://microsoft.github.io/language-server-protocol/specification/#diagnostic_refresh
- Support for dynamic registration for `textDocument/diagnostic` - 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 LUA

View File

@@ -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

View File

@@ -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

View File

@@ -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)); ADD_C(args, CSTR_AS_OBJ(xp->xp_line));
// Build the current command line as a Lua string argument // 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, kRetObject, NULL,
&err); &err);
api_clear_error(&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_SCRIPTNAMES, get_scriptnames_arg, true, false },
{ EXPAND_RETAB, get_retab_arg, true, true }, { EXPAND_RETAB, get_retab_arg, true, true },
{ EXPAND_CHECKHEALTH, get_healthcheck_names, true, false }, { 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; int ret = FAIL;

View File

@@ -1672,7 +1672,7 @@ M.cmds = {
}, },
{ {
command = 'lsp', command = 'lsp',
flags = bit.bor(NEEDARG, EXTRA, TRLBAR), flags = bit.bor(NEEDARG, EXTRA),
addr_type = 'ADDR_NONE', addr_type = 'ADDR_NONE',
func = 'ex_lsp', func = 'ex_lsp',
}, },

View File

@@ -8056,7 +8056,10 @@ static void ex_lsp(exarg_T *eap)
ADD_C(args, CSTR_AS_OBJ(eap->arg)); 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); api_clear_error(&err);
} }

View File

@@ -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)