feat(lsp): workspace_required (#33608)

Problem:
Some language servers do not work properly without a workspace folder.

Solution:
Add `workspace_required`, which skips starting the lsp client if no
workspace folder is found.

Co-authored-by: Michael Strobel <71396679+Kibadda@users.noreply.github.com>
This commit is contained in:
Justin M. Keyes
2025-04-24 07:06:30 -07:00
committed by GitHub
parent 448c2cec9d
commit 66953b16a2
5 changed files with 173 additions and 118 deletions

View File

@ -1235,125 +1235,130 @@ Lua module: vim.lsp.client *lsp-client*
*vim.lsp.ClientConfig* *vim.lsp.ClientConfig*
Fields: ~ Fields: ~
• {cmd} (`string[]|fun(dispatchers: vim.lsp.rpc.Dispatchers): vim.lsp.rpc.PublicClient`) • {cmd} (`string[]|fun(dispatchers: vim.lsp.rpc.Dispatchers): vim.lsp.rpc.PublicClient`)
command string[] that launches the language command string[] that launches the language
server (treated as in |jobstart()|, must be server (treated as in |jobstart()|, must be
absolute or on `$PATH`, shell constructs like absolute or on `$PATH`, shell constructs like
"~" are not expanded), or function that creates "~" are not expanded), or function that creates
an RPC client. Function receives a `dispatchers` an RPC client. Function receives a
table and returns a table with member functions `dispatchers` table and returns a table with
`request`, `notify`, `is_closing` and member functions `request`, `notify`,
`terminate`. See |vim.lsp.rpc.request()|, `is_closing` and `terminate`. See
|vim.lsp.rpc.notify()|. For TCP there is a |vim.lsp.rpc.request()|,
builtin RPC client factory: |vim.lsp.rpc.notify()|. For TCP there is a
|vim.lsp.rpc.connect()| builtin RPC client factory:
• {cmd_cwd}? (`string`, default: cwd) Directory to launch the |vim.lsp.rpc.connect()|
`cmd` process. Not related to `root_dir`. • {cmd_cwd}? (`string`, default: cwd) Directory to launch
• {cmd_env}? (`table`) Environment flags to pass to the LSP the `cmd` process. Not related to `root_dir`.
on spawn. Must be specified using a table. • {cmd_env}? (`table`) Environment flags to pass to the LSP
Non-string values are coerced to string. on spawn. Must be specified using a table.
Example: >lua Non-string values are coerced to string.
{ PORT = 8080; HOST = "0.0.0.0"; } Example: >lua
{ PORT = 8080; HOST = "0.0.0.0"; }
< <
• {detached}? (`boolean`, default: true) Daemonize the server • {detached}? (`boolean`, default: true) Daemonize the server
process so that it runs in a separate process process so that it runs in a separate process
group from Nvim. Nvim will shutdown the process group from Nvim. Nvim will shutdown the process
on exit, but if Nvim fails to exit cleanly this on exit, but if Nvim fails to exit cleanly this
could leave behind orphaned server processes. could leave behind orphaned server processes.
• {workspace_folders}? (`lsp.WorkspaceFolder[]`) List of workspace • {workspace_folders}? (`lsp.WorkspaceFolder[]`) List of workspace
folders passed to the language server. For folders passed to the language server. For
backwards compatibility rootUri and rootPath backwards compatibility rootUri and rootPath
will be derived from the first workspace folder will be derived from the first workspace folder
in this list. See `workspaceFolders` in the LSP in this list. See `workspaceFolders` in the LSP
spec. spec.
• {capabilities}? (`lsp.ClientCapabilities`) Map overriding the • {workspace_required}? (`boolean`) (default false) Server requires a
default capabilities defined by workspace (no "single file" support).
|vim.lsp.protocol.make_client_capabilities()|, • {capabilities}? (`lsp.ClientCapabilities`) Map overriding the
passed to the language server on initialization. default capabilities defined by
Hint: use make_client_capabilities() and modify |vim.lsp.protocol.make_client_capabilities()|,
its result. passed to the language server on
• Note: To send an empty dictionary use initialization. Hint: use
|vim.empty_dict()|, else it will be encoded as make_client_capabilities() and modify its
an array. result.
• {handlers}? (`table<string,function>`) Map of language • Note: To send an empty dictionary use
server method names to |lsp-handler| |vim.empty_dict()|, else it will be encoded
• {settings}? (`lsp.LSPObject`) Map with language server as an array.
specific settings. See the {settings} in • {handlers}? (`table<string,function>`) Map of language
|vim.lsp.Client|. server method names to |lsp-handler|
• {commands}? (`table<string,fun(command: lsp.Command, ctx: table)>`) • {settings}? (`lsp.LSPObject`) Map with language server
Table that maps string of clientside commands to specific settings. See the {settings} in
user-defined functions. Commands passed to |vim.lsp.Client|.
`start()` take precedence over the global • {commands}? (`table<string,fun(command: lsp.Command, ctx: table)>`)
command registry. Each key must be a unique Table that maps string of clientside commands
command name, and the value is a function which to user-defined functions. Commands passed to
is called if any LSP action (code action, code `start()` take precedence over the global
lenses, ...) triggers the command. command registry. Each key must be a unique
• {init_options}? (`lsp.LSPObject`) Values to pass in the command name, and the value is a function which
initialization request as is called if any LSP action (code action, code
`initializationOptions`. See `initialize` in the lenses, ...) triggers the command.
LSP spec. • {init_options}? (`lsp.LSPObject`) Values to pass in the
• {name}? (`string`, default: client-id) Name in log initialization request as
messages. `initializationOptions`. See `initialize` in
• {get_language_id}? (`fun(bufnr: integer, filetype: string): string`) the LSP spec.
Language ID as string. Defaults to the buffer • {name}? (`string`, default: client-id) Name in log
filetype. messages.
• {offset_encoding}? (`'utf-8'|'utf-16'|'utf-32'`) Called "position • {get_language_id}? (`fun(bufnr: integer, filetype: string): string`)
encoding" in LSP spec, the encoding that the LSP Language ID as string. Defaults to the buffer
server expects. Client does not verify this is filetype.
correct. • {offset_encoding}? (`'utf-8'|'utf-16'|'utf-32'`) Called "position
• {on_error}? (`fun(code: integer, err: string)`) Callback encoding" in LSP spec, the encoding that the
invoked when the client operation throws an LSP server expects. Client does not verify this
error. `code` is a number describing the error. is correct.
Other arguments may be passed depending on the • {on_error}? (`fun(code: integer, err: string)`) Callback
error kind. See `vim.lsp.rpc.client_errors` for invoked when the client operation throws an
possible errors. Use error. `code` is a number describing the error.
`vim.lsp.rpc.client_errors[code]` to get Other arguments may be passed depending on the
human-friendly name. error kind. See `vim.lsp.rpc.client_errors` for
• {before_init}? (`fun(params: lsp.InitializeParams, config: vim.lsp.ClientConfig)`) possible errors. Use
Callback invoked before the LSP "initialize" `vim.lsp.rpc.client_errors[code]` to get
phase, where `params` contains the parameters human-friendly name.
being sent to the server and `config` is the • {before_init}? (`fun(params: lsp.InitializeParams, config: vim.lsp.ClientConfig)`)
config that was passed to |vim.lsp.start()|. You Callback invoked before the LSP "initialize"
can use this to modify parameters before they phase, where `params` contains the parameters
are sent. being sent to the server and `config` is the
• {on_init}? (`elem_or_list<fun(client: vim.lsp.Client, init_result: lsp.InitializeResult)>`) config that was passed to |vim.lsp.start()|.
Callback invoked after LSP "initialize", where You can use this to modify parameters before
`result` is a table of `capabilities` and they are sent.
anything else the server may send. For example, • {on_init}? (`elem_or_list<fun(client: vim.lsp.Client, init_result: lsp.InitializeResult)>`)
clangd sends `init_result.offsetEncoding` if Callback invoked after LSP "initialize", where
`capabilities.offsetEncoding` was sent to it. `result` is a table of `capabilities` and
You can only modify the `client.offset_encoding` anything else the server may send. For example,
here before any notifications are sent. clangd sends `init_result.offsetEncoding` if
• {on_exit}? (`elem_or_list<fun(code: integer, signal: integer, client_id: integer)>`) `capabilities.offsetEncoding` was sent to it.
Callback invoked on client exit. You can only modify the
• code: exit code of the process `client.offset_encoding` here before any
• signal: number describing the signal used to notifications are sent.
terminate (if any) • {on_exit}? (`elem_or_list<fun(code: integer, signal: integer, client_id: integer)>`)
• client_id: client handle Callback invoked on client exit.
• {on_attach}? (`elem_or_list<fun(client: vim.lsp.Client, bufnr: integer)>`) • code: exit code of the process
Callback invoked when client attaches to a • signal: number describing the signal used to
buffer. terminate (if any)
• {trace}? (`'off'|'messages'|'verbose'`, default: "off") • client_id: client handle
Passed directly to the language server in the • {on_attach}? (`elem_or_list<fun(client: vim.lsp.Client, bufnr: integer)>`)
initialize request. Invalid/empty values will Callback invoked when client attaches to a
• {flags}? (`table`) A table with flags for the client. The buffer.
current (experimental) flags are: • {trace}? (`'off'|'messages'|'verbose'`, default: "off")
• {allow_incremental_sync}? (`boolean`, default: Passed directly to the language server in the
`true`) Allow using incremental sync for initialize request. Invalid/empty values will
buffer edits • {flags}? (`table`) A table with flags for the client.
• {debounce_text_changes} (`integer`, default: The current (experimental) flags are:
`150`) Debounce `didChange` notifications to • {allow_incremental_sync}? (`boolean`,
the server by the given number in default: `true`) Allow using incremental sync
milliseconds. No debounce occurs if `nil`. for buffer edits
• {exit_timeout} (`integer|false`, default: • {debounce_text_changes} (`integer`, default:
`false`) Milliseconds to wait for server to `150`) Debounce `didChange` notifications to
exit cleanly after sending the "shutdown" the server by the given number in
request before sending kill -15. If set to milliseconds. No debounce occurs if `nil`.
false, nvim exits immediately after sending • {exit_timeout} (`integer|false`, default:
the "shutdown" request to the server. `false`) Milliseconds to wait for server to
• {root_dir}? (`string`) Directory where the LSP server will exit cleanly after sending the "shutdown"
base its workspaceFolders, rootUri, and rootPath request before sending kill -15. If set to
on initialization. false, nvim exits immediately after sending
the "shutdown" request to the server.
• {root_dir}? (`string`) Directory where the LSP server will
base its workspaceFolders, rootUri, and
rootPath on initialization.
Client:cancel_request({id}) *Client:cancel_request()* Client:cancel_request({id}) *Client:cancel_request()*

View File

@ -276,6 +276,7 @@ LSP
`codeAction/resolve` request. `codeAction/resolve` request.
• The `textDocument/completion` request now includes the completion context in • The `textDocument/completion` request now includes the completion context in
its parameters. its parameters.
• |vim.lsp.Config| gained `workspace_required`.
LUA LUA

View File

@ -633,6 +633,17 @@ function lsp.start(config, opts)
config.root_dir = vim.fs.root(bufnr, opts._root_markers) config.root_dir = vim.fs.root(bufnr, opts._root_markers)
end end
if
not config.root_dir
and (not config.workspace_folders or #config.workspace_folders == 0)
and config.workspace_required
then
log.info(
('skipping config "%s": workspace_required=true, no workspace found'):format(config.name)
)
return
end
for _, client in pairs(all_clients) do for _, client in pairs(all_clients) do
if reuse_client(client, config) then if reuse_client(client, config) then
if opts.attach == false then if opts.attach == false then

View File

@ -63,6 +63,9 @@ local validate = vim.validate
--- folder in this list. See `workspaceFolders` in the LSP spec. --- folder in this list. See `workspaceFolders` in the LSP spec.
--- @field workspace_folders? lsp.WorkspaceFolder[] --- @field workspace_folders? lsp.WorkspaceFolder[]
--- ---
--- (default false) Server requires a workspace (no "single file" support).
--- @field workspace_required? boolean
---
--- Map overriding the default capabilities defined by |vim.lsp.protocol.make_client_capabilities()|, --- Map overriding the default capabilities defined by |vim.lsp.protocol.make_client_capabilities()|,
--- passed to the language server on initialization. Hint: use make_client_capabilities() and modify --- passed to the language server on initialization. Hint: use make_client_capabilities() and modify
--- its result. --- its result.

View File

@ -6456,5 +6456,40 @@ describe('LSP', function()
vim.lsp.config('*', {}) vim.lsp.config('*', {})
end) end)
end) end)
it('does not start without workspace if workspace_required=true', function()
exec_lua(create_server_definition)
local tmp1 = t.tmpname(true)
eq(
{ workspace_required = false },
exec_lua(function()
local server = _G._create_server({
handlers = {
initialize = function(_, _, callback)
callback(nil, { capabilities = {} })
end,
},
})
local ws_required = { cmd = server.cmd, workspace_required = true, filetypes = { 'foo' } }
local ws_not_required = vim.deepcopy(ws_required)
ws_not_required.workspace_required = false
vim.lsp.config('ws_required', ws_required)
vim.lsp.config('ws_not_required', ws_not_required)
vim.lsp.enable('ws_required')
vim.lsp.enable('ws_not_required')
vim.cmd.edit(assert(tmp1))
vim.bo.filetype = 'foo'
local clients = vim.lsp.get_clients({ bufnr = vim.api.nvim_get_current_buf() })
assert(1 == #clients)
return { workspace_required = clients[1].config.workspace_required }
end)
)
end)
end) end)
end) end)