commit 63abb1a88f2cd042667e6d314794d6aa0d382c9a
parent 89d26d61d271231b7753186de898bb95d2a83372
Author: brianhuster <phambinhanctb2004@gmail.com>
Date: Sat, 26 Jul 2025 18:37:31 +0700
feat(lsp): builtin :lsp command
Problem:
- Despite [nvim-lspconfig](https://github.com/neovim/nvim-lspconfig)
claims to be a "data-only" plugin, in fact it still provides some
user-facing commands because they haven't been upstreamed to Nvim.
Solution:
- Upstream `:LspRestart`, `:LspStart` and `:LspStop` commands as `:lsp
restart`, `:lsp start` and `:lsp stop` respectively.
Co-authored-by: glepnir <glephunter@gmail.com>
Diffstat:
11 files changed, 224 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
@@ -116,6 +116,22 @@ 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.
+
==============================================================================
CONFIG *lsp-config*
@@ -1023,12 +1039,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,7 @@ 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.
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/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/runtime/lua/vim/lsp/_cmd.lua b/runtime/lua/vim/lsp/_cmd.lua
@@ -0,0 +1,142 @@
+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
@@ -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.lsp._cmd'._ex_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 },
+ [32] = { 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, TRLBAR),
+ 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,18 @@ 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.lsp._cmd'._ex_lsp(...)", args, kRetNilBool, NULL, &err);
+ api_clear_error(&err);
+}
+
/// ":fclose"
static void ex_fclose(exarg_T *eap)
{
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')