feat(lsp): add vim.lsp.config and vim.lsp.enable

Design goals/requirements:
- Default configuration of a server can be distributed across multiple sources.
  - And via RTP discovery.
- Default configuration can be specified for all servers.
- Configuration _can_ be project specific.

Solution:

- Two new API's:
  - `vim.lsp.config(name, cfg)`:
    - Used to define default configurations for servers of name.
    - Can be used like a table or called as a function.
    - Use `vim.lsp.confg('*', cfg)` to specify default config for all
      servers.
  - `vim.lsp.enable(name)`
    - Used to enable servers of name. Uses configuration defined
    via `vim.lsp.config()`.
This commit is contained in:
Lewis Russell
2024-11-01 16:31:51 +00:00
committed by Lewis Russell
parent ca760e645b
commit 3f1d09bc94
11 changed files with 600 additions and 75 deletions

View File

@@ -5010,6 +5010,7 @@ vim.go.ruf = vim.go.rulerformat
--- indent/ indent scripts `indent-expression`
--- keymap/ key mapping files `mbyte-keymap`
--- lang/ menu translations `:menutrans`
--- lsp/ LSP client configurations `lsp-config`
--- lua/ `Lua` plugins
--- menu.vim GUI menus `menu.vim`
--- pack/ packages `:packadd`

View File

@@ -316,6 +316,240 @@ local function create_and_initialize_client(config)
return client.id, nil
end
--- @class vim.lsp.Config : vim.lsp.ClientConfig
---
--- See `cmd` in [vim.lsp.ClientConfig].
--- @field cmd? string[]|fun(dispatchers: vim.lsp.rpc.Dispatchers): vim.lsp.rpc.PublicClient
---
--- Filetypes the client will attach to, if activated by `vim.lsp.enable()`.
--- If not provided, then the client will attach to all filetypes.
--- @field filetypes? string[]
---
--- Directory markers (.e.g. '.git/') where the LSP server will base its workspaceFolders,
--- rootUri, and rootPath on initialization. Unused if `root_dir` is provided.
--- @field root_markers? string[]
---
--- Predicate used to decide if a client should be re-used. Used on all
--- running clients. The default implementation re-uses a client if name and
--- root_dir matches.
--- @field reuse_client? fun(client: vim.lsp.Client, config: vim.lsp.ClientConfig): boolean
--- Update the configuration for an LSP client.
---
--- Use name '*' to set default configuration for all clients.
---
--- Can also be table-assigned to redefine the configuration for a client.
---
--- Examples:
---
--- - Add a root marker for all clients:
--- ```lua
--- vim.lsp.config('*', {
--- root_markers = { '.git' },
--- })
--- ```
--- - Add additional capabilities to all clients:
--- ```lua
--- vim.lsp.config('*', {
--- capabilities = {
--- textDocument = {
--- semanticTokens = {
--- multilineTokenSupport = true,
--- }
--- }
--- }
--- })
--- ```
--- - (Re-)define the configuration for clangd:
--- ```lua
--- vim.lsp.config.clangd = {
--- cmd = {
--- 'clangd',
--- '--clang-tidy',
--- '--background-index',
--- '--offset-encoding=utf-8',
--- },
--- root_markers = { '.clangd', 'compile_commands.json' },
--- filetypes = { 'c', 'cpp' },
--- }
--- ```
--- - Get configuration for luals:
--- ```lua
--- local cfg = vim.lsp.config.luals
--- ```
---
--- @param name string
--- @param cfg vim.lsp.Config
--- @diagnostic disable-next-line:assign-type-mismatch
function lsp.config(name, cfg)
local _, _ = name, cfg -- ignore unused
-- dummy proto for docs
end
lsp._enabled_configs = {} --- @type table<string,{resolved_config:vim.lsp.Config?}>
--- If a config in vim.lsp.config() is accessed then the resolved config becomes invalid.
--- @param name string
local function invalidate_enabled_config(name)
if name == '*' then
for _, v in pairs(lsp._enabled_configs) do
v.resolved_config = nil
end
elseif lsp._enabled_configs[name] then
lsp._enabled_configs[name].resolved_config = nil
end
end
--- @nodoc
--- @class vim.lsp.config
--- @field [string] vim.lsp.Config
--- @field package _configs table<string,vim.lsp.Config>
lsp.config = setmetatable({ _configs = {} }, {
--- @param self vim.lsp.config
--- @param name string
--- @return vim.lsp.Config
__index = function(self, name)
validate('name', name, 'string')
invalidate_enabled_config(name)
self._configs[name] = self._configs[name] or {}
return self._configs[name]
end,
--- @param self vim.lsp.config
--- @param name string
--- @param cfg vim.lsp.Config
__newindex = function(self, name, cfg)
validate('name', name, 'string')
validate('cfg', cfg, 'table')
invalidate_enabled_config(name)
self._configs[name] = cfg
end,
--- @param self vim.lsp.config
--- @param name string
--- @param cfg vim.lsp.Config
__call = function(self, name, cfg)
validate('name', name, 'string')
validate('cfg', cfg, 'table')
invalidate_enabled_config(name)
self[name] = vim.tbl_deep_extend('force', self._configs[name] or {}, cfg)
end,
})
--- @private
--- @param name string
--- @return vim.lsp.Config
function lsp._resolve_config(name)
local econfig = lsp._enabled_configs[name] or {}
if not econfig.resolved_config then
-- Resolve configs from lsp/*.lua
-- Calls to vim.lsp.config in lsp/* have a lower precedence than calls from other sites.
local orig_configs = lsp.config._configs
lsp.config._configs = {}
pcall(vim.cmd.runtime, { ('lsp/%s.lua'):format(name), bang = true })
local rtp_configs = lsp.config._configs
lsp.config._configs = orig_configs
local config = vim.tbl_deep_extend(
'force',
lsp.config._configs['*'] or {},
rtp_configs[name] or {},
lsp.config._configs[name] or {}
)
config.name = name
validate('cmd', config.cmd, { 'function', 'table' })
validate('cmd', config.reuse_client, 'function', true)
-- All other fields are validated in client.create
econfig.resolved_config = config
end
return assert(econfig.resolved_config)
end
local lsp_enable_autocmd_id --- @type integer?
--- @param bufnr integer
local function lsp_enable_callback(bufnr)
-- Only ever attach to buffers that represent an actual file.
if vim.bo[bufnr].buftype ~= '' then
return
end
--- @param config vim.lsp.Config
local function can_start(config)
if config.filetypes and not vim.tbl_contains(config.filetypes, vim.bo[bufnr].filetype) then
return false
elseif type(config.cmd) == 'table' and vim.fn.executable(config.cmd[1]) == 0 then
return false
end
return true
end
for name in vim.spairs(lsp._enabled_configs) do
local config = lsp._resolve_config(name)
if can_start(config) then
-- Deepcopy config so changes done in the client
-- do not propagate back to the enabled configs.
config = vim.deepcopy(config)
vim.lsp.start(config, {
bufnr = bufnr,
reuse_client = config.reuse_client,
_root_markers = config.root_markers,
})
end
end
end
--- Enable an LSP server to automatically start when opening a buffer.
---
--- Uses configuration defined with `vim.lsp.config`.
---
--- Examples:
---
--- ```lua
--- vim.lsp.enable('clangd')
---
--- vim.lsp.enable({'luals', 'pyright'})
--- ```
---
--- @param name string|string[] Name(s) of client(s) to enable.
--- @param enable? boolean `true|nil` to enable, `false` to disable.
function lsp.enable(name, enable)
validate('name', name, { 'string', 'table' })
local names = vim._ensure_list(name) --[[@as string[] ]]
for _, nm in ipairs(names) do
if nm == '*' then
error('Invalid name')
end
lsp._enabled_configs[nm] = enable == false and nil or {}
end
if not next(lsp._enabled_configs) then
if lsp_enable_autocmd_id then
api.nvim_del_autocmd(lsp_enable_autocmd_id)
lsp_enable_autocmd_id = nil
end
return
end
-- Only ever create autocmd once to reuse computation of config merging.
lsp_enable_autocmd_id = lsp_enable_autocmd_id
or api.nvim_create_autocmd('FileType', {
group = api.nvim_create_augroup('nvim.lsp.enable', {}),
callback = function(args)
lsp_enable_callback(args.buf)
end,
})
end
--- @class vim.lsp.start.Opts
--- @inlinedoc
---
@@ -334,6 +568,8 @@ end
---
--- Suppress error reporting if the LSP server fails to start (default false).
--- @field silent? boolean
---
--- @field package _root_markers? string[]
--- Create a new LSP client and start a language server or reuses an already
--- running client if one is found matching `name` and `root_dir`.
@@ -379,6 +615,11 @@ function lsp.start(config, opts)
local reuse_client = opts.reuse_client or reuse_client_default
local bufnr = vim._resolve_bufnr(opts.bufnr)
if not config.root_dir and opts._root_markers then
config = vim.deepcopy(config)
config.root_dir = vim.fs.root(bufnr, opts._root_markers)
end
for _, client in pairs(all_clients) do
if reuse_client(client, config) then
if opts.attach == false then
@@ -387,9 +628,8 @@ function lsp.start(config, opts)
if lsp.buf_attach_client(bufnr, client.id) then
return client.id
else
return nil
end
return
end
end
@@ -398,7 +638,7 @@ function lsp.start(config, opts)
if not opts.silent then
vim.notify(err, vim.log.levels.WARN)
end
return nil
return
end
if opts.attach == false then
@@ -408,8 +648,6 @@ function lsp.start(config, opts)
if client_id and lsp.buf_attach_client(bufnr, client_id) then
return client_id
end
return nil
end
--- Consumes the latest progress messages from all clients and formats them as a string.
@@ -1275,7 +1513,7 @@ end
--- and the value is a function which is called if any LSP action
--- (code action, code lenses, ...) triggers the command.
---
--- If a LSP response contains a command for which no matching entry is
--- If an LSP response contains a command for which no matching entry is
--- available in this registry, the command will be executed via the LSP server
--- using `workspace/executeCommand`.
---

View File

@@ -359,16 +359,6 @@ local function get_name(id, config)
return tostring(id)
end
--- @generic T
--- @param x elem_or_list<T>?
--- @return T[]
local function ensure_list(x)
if type(x) == 'table' then
return x
end
return { x }
end
--- @nodoc
--- @param config vim.lsp.ClientConfig
--- @return vim.lsp.Client?
@@ -395,13 +385,13 @@ function Client.create(config)
settings = config.settings or {},
flags = config.flags or {},
get_language_id = config.get_language_id or default_get_language_id,
capabilities = config.capabilities or lsp.protocol.make_client_capabilities(),
capabilities = config.capabilities,
workspace_folders = lsp._get_workspace_folders(config.workspace_folders or config.root_dir),
root_dir = config.root_dir,
_before_init_cb = config.before_init,
_on_init_cbs = ensure_list(config.on_init),
_on_exit_cbs = ensure_list(config.on_exit),
_on_attach_cbs = ensure_list(config.on_attach),
_on_init_cbs = vim._ensure_list(config.on_init),
_on_exit_cbs = vim._ensure_list(config.on_exit),
_on_attach_cbs = vim._ensure_list(config.on_attach),
_on_error_cb = config.on_error,
_trace = get_trace(config.trace),
@@ -417,6 +407,9 @@ function Client.create(config)
messages = { name = name, messages = {}, progress = {}, status = {} },
}
self.capabilities =
vim.tbl_deep_extend('force', lsp.protocol.make_client_capabilities(), self.capabilities or {})
--- @class lsp.DynamicCapabilities
--- @nodoc
self.dynamic_capabilities = {

View File

@@ -28,42 +28,45 @@ local function check_log()
report_fn(string.format('Log size: %d KB', log_size / 1000))
end
--- @param f function
--- @return string
local function func_tostring(f)
local info = debug.getinfo(f, 'S')
return ('<function %s:%s>'):format(info.source, info.linedefined)
end
local function check_active_clients()
vim.health.start('vim.lsp: Active Clients')
local clients = vim.lsp.get_clients()
if next(clients) then
for _, client in pairs(clients) do
local cmd ---@type string
if type(client.config.cmd) == 'table' then
cmd = table.concat(client.config.cmd --[[@as table]], ' ')
elseif type(client.config.cmd) == 'function' then
cmd = tostring(client.config.cmd)
local ccmd = client.config.cmd
if type(ccmd) == 'table' then
cmd = vim.inspect(ccmd)
elseif type(ccmd) == 'function' then
cmd = func_tostring(ccmd)
end
local dirs_info ---@type string
if client.workspace_folders and #client.workspace_folders > 1 then
dirs_info = string.format(
' Workspace folders:\n %s',
vim
.iter(client.workspace_folders)
---@param folder lsp.WorkspaceFolder
:map(function(folder)
return folder.name
end)
:join('\n ')
)
local wfolders = {} --- @type string[]
for _, dir in ipairs(client.workspace_folders) do
wfolders[#wfolders + 1] = dir.name
end
dirs_info = ('- Workspace folders:\n %s'):format(table.concat(wfolders, '\n '))
else
dirs_info = string.format(
' Root directory: %s',
'- Root directory: %s',
client.root_dir and vim.fn.fnamemodify(client.root_dir, ':~')
) or nil
end
report_info(table.concat({
string.format('%s (id: %d)', client.name, client.id),
dirs_info,
string.format(' Command: %s', cmd),
string.format(' Settings: %s', vim.inspect(client.settings, { newline = '\n ' })),
string.format('- Command: %s', cmd),
string.format('- Settings: %s', vim.inspect(client.settings, { newline = '\n ' })),
string.format(
' Attached buffers: %s',
'- Attached buffers: %s',
vim.iter(pairs(client.attached_buffers)):map(tostring):join(', ')
),
}, '\n'))
@@ -174,10 +177,45 @@ local function check_position_encodings()
end
end
local function check_enabled_configs()
vim.health.start('vim.lsp: Enabled Configurations')
for name in vim.spairs(vim.lsp._enabled_configs) do
local config = vim.lsp._resolve_config(name)
local text = {} --- @type string[]
text[#text + 1] = ('%s:'):format(name)
for k, v in
vim.spairs(config --[[@as table<string,any>]])
do
local v_str --- @type string?
if k == 'name' then
v_str = nil
elseif k == 'filetypes' or k == 'root_markers' then
v_str = table.concat(v, ', ')
elseif type(v) == 'function' then
v_str = func_tostring(v)
else
v_str = vim.inspect(v, { newline = '\n ' })
end
if k == 'cmd' and type(v) == 'table' and vim.fn.executable(v[1]) == 0 then
report_warn(("'%s' is not executable. Configuration will not be used."):format(v[1]))
end
if v_str then
text[#text + 1] = ('- %s: %s'):format(k, v_str)
end
end
text[#text + 1] = ''
report_info(table.concat(text, '\n'))
end
end
--- Performs a healthcheck for LSP
function M.check()
check_log()
check_active_clients()
check_enabled_configs()
check_watcher()
check_position_encodings()
end

View File

@@ -1409,4 +1409,14 @@ function vim._resolve_bufnr(bufnr)
return bufnr
end
--- @generic T
--- @param x elem_or_list<T>?
--- @return T[]
function vim._ensure_list(x)
if type(x) == 'table' then
return x
end
return { x }
end
return vim