feat(lsp): initial support for dynamic capabilities (#23681)

- `client.dynamic_capabilities` is an object that tracks client register/unregister
- `client.supports_method` will additionally check if a dynamic capability supports the method, taking document filters into account. But only if the client enabled `dynamicRegistration` for the capability
- updated the default client capabilities to include dynamicRegistration for:
    - formatting
    - rangeFormatting
    - hover
    - codeAction
    - hover
    - rename
This commit is contained in:
Folke Lemaitre 2023-05-28 07:51:28 +02:00 committed by GitHub
parent e41b2e34b4
commit ddd92a70d2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 327 additions and 69 deletions

View File

@ -36,6 +36,7 @@ ADDED FEATURES *news-added*
The following new APIs or features were added.
• Dynamic registration of LSP capabilities. An implication of this change is that checking a client's `server_capabilities` is no longer a sufficient indicator to see if a server supports a feature. Instead use `client.supports_method(<method>)`. It considers both the dynamic capabilities and static `server_capabilities`.
• |vim.iter()| provides a generic iterator interface for tables and Lua
iterators |luaref-in|.

View File

@ -50,6 +50,7 @@ lsp._request_name_to_capability = {
['textDocument/codeAction'] = { 'codeActionProvider' },
['textDocument/codeLens'] = { 'codeLensProvider' },
['codeLens/resolve'] = { 'codeLensProvider', 'resolveProvider' },
['codeAction/resolve'] = { 'codeActionProvider', 'resolveProvider' },
['workspace/executeCommand'] = { 'executeCommandProvider' },
['workspace/symbol'] = { 'workspaceSymbolProvider' },
['textDocument/references'] = { 'referencesProvider' },
@ -886,6 +887,47 @@ function lsp.start(config, opts)
return client_id
end
---@private
-- Determines whether the given option can be set by `set_defaults`.
local function is_empty_or_default(bufnr, option)
if vim.bo[bufnr][option] == '' then
return true
end
local info = vim.api.nvim_get_option_info2(option, { buf = bufnr })
local scriptinfo = vim.tbl_filter(function(e)
return e.sid == info.last_set_sid
end, vim.fn.getscriptinfo())
if #scriptinfo ~= 1 then
return false
end
return vim.startswith(scriptinfo[1].name, vim.fn.expand('$VIMRUNTIME'))
end
---@private
---@param client lsp.Client
function lsp._set_defaults(client, bufnr)
if
client.supports_method('textDocument/definition') and is_empty_or_default(bufnr, 'tagfunc')
then
vim.bo[bufnr].tagfunc = 'v:lua.vim.lsp.tagfunc'
end
if
client.supports_method('textDocument/completion') and is_empty_or_default(bufnr, 'omnifunc')
then
vim.bo[bufnr].omnifunc = 'v:lua.vim.lsp.omnifunc'
end
if
client.supports_method('textDocument/rangeFormatting')
and is_empty_or_default(bufnr, 'formatprg')
and is_empty_or_default(bufnr, 'formatexpr')
then
vim.bo[bufnr].formatexpr = 'v:lua.vim.lsp.formatexpr()'
end
end
-- FIXME: DOC: Currently all methods on the `vim.lsp.client` object are
-- documented twice: Here, and on the methods themselves (e.g.
-- `client.request()`). This is a workaround for the vimdoc generator script
@ -1090,43 +1132,6 @@ function lsp.start_client(config)
end
end
---@private
-- Determines whether the given option can be set by `set_defaults`.
local function is_empty_or_default(bufnr, option)
if vim.bo[bufnr][option] == '' then
return true
end
local info = vim.api.nvim_get_option_info2(option, { buf = bufnr })
local scriptinfo = vim.tbl_filter(function(e)
return e.sid == info.last_set_sid
end, vim.fn.getscriptinfo())
if #scriptinfo ~= 1 then
return false
end
return vim.startswith(scriptinfo[1].name, vim.fn.expand('$VIMRUNTIME'))
end
---@private
local function set_defaults(client, bufnr)
local capabilities = client.server_capabilities
if capabilities.definitionProvider and is_empty_or_default(bufnr, 'tagfunc') then
vim.bo[bufnr].tagfunc = 'v:lua.vim.lsp.tagfunc'
end
if capabilities.completionProvider and is_empty_or_default(bufnr, 'omnifunc') then
vim.bo[bufnr].omnifunc = 'v:lua.vim.lsp.omnifunc'
end
if
capabilities.documentRangeFormattingProvider
and is_empty_or_default(bufnr, 'formatprg')
and is_empty_or_default(bufnr, 'formatexpr')
then
vim.bo[bufnr].formatexpr = 'v:lua.vim.lsp.formatexpr()'
end
end
---@private
--- Reset defaults set by `set_defaults`.
--- Must only be called if the last client attached to a buffer exits.
@ -1228,7 +1233,9 @@ function lsp.start_client(config)
requests = {},
-- for $/progress report
messages = { name = name, messages = {}, progress = {}, status = {} },
dynamic_capabilities = require('vim.lsp._dynamic').new(client_id),
}
client.config.capabilities = config.capabilities or protocol.make_client_capabilities()
-- Store the uninitialized_clients for cleanup in case we exit before initialize finishes.
uninitialized_clients[client_id] = client
@ -1291,7 +1298,7 @@ function lsp.start_client(config)
-- User provided initialization options.
initializationOptions = config.init_options,
-- The capabilities provided by the client (editor or tool)
capabilities = config.capabilities or protocol.make_client_capabilities(),
capabilities = config.capabilities,
-- The initial trace setting. If omitted trace is disabled ("off").
-- trace = "off" | "messages" | "verbose";
trace = valid_traces[config.trace] or 'off',
@ -1300,6 +1307,26 @@ function lsp.start_client(config)
-- TODO(ashkan) handle errors here.
pcall(config.before_init, initialize_params, config)
end
--- @param method string
--- @param opts? {bufnr?: number}
client.supports_method = function(method, opts)
opts = opts or {}
local required_capability = lsp._request_name_to_capability[method]
-- if we don't know about the method, assume that the client supports it.
if not required_capability then
return true
end
if vim.tbl_get(client.server_capabilities or {}, unpack(required_capability)) then
return true
else
if client.dynamic_capabilities:supports_registration(method) then
return client.dynamic_capabilities:supports(method, opts)
end
return false
end
end
local _ = log.trace() and log.trace(log_prefix, 'initialize_params', initialize_params)
rpc.request('initialize', initialize_params, function(init_err, result)
assert(not init_err, tostring(init_err))
@ -1314,18 +1341,6 @@ function lsp.start_client(config)
client.server_capabilities =
assert(result.capabilities, "initialize result doesn't contain capabilities")
client.server_capabilities = protocol.resolve_capabilities(client.server_capabilities)
client.supports_method = function(method)
local required_capability = lsp._request_name_to_capability[method]
-- if we don't know about the method, assume that the client supports it.
if not required_capability then
return true
end
if vim.tbl_get(client.server_capabilities, unpack(required_capability)) then
return true
else
return false
end
end
if next(config.settings) then
client.notify('workspace/didChangeConfiguration', { settings = config.settings })
@ -1522,7 +1537,7 @@ function lsp.start_client(config)
function client._on_attach(bufnr)
text_document_did_open_handler(bufnr, client)
set_defaults(client, bufnr)
lsp._set_defaults(client, bufnr)
nvim_exec_autocmds('LspAttach', {
buffer = bufnr,
@ -1946,7 +1961,7 @@ function lsp.buf_request(bufnr, method, params, handler)
local supported_clients = {}
local method_supported = false
for_each_buffer_client(bufnr, function(client, client_id)
if client.supports_method(method) then
if client.supports_method(method, { bufnr = bufnr }) then
method_supported = true
table.insert(supported_clients, client_id)
end
@ -2002,7 +2017,7 @@ function lsp.buf_request_all(bufnr, method, params, callback)
local set_expected_result_count = once(function()
for_each_buffer_client(bufnr, function(client)
if client.supports_method(method) then
if client.supports_method(method, { bufnr = bufnr }) then
expected_result_count = expected_result_count + 1
end
end)

View File

@ -0,0 +1,109 @@
local wf = require('vim.lsp._watchfiles')
--- @class lsp.DynamicCapabilities
--- @field capabilities table<string, lsp.Registration[]>
--- @field client_id number
local M = {}
--- @param client_id number
function M.new(client_id)
return setmetatable({
capabilities = {},
client_id = client_id,
}, { __index = M })
end
function M:supports_registration(method)
local client = vim.lsp.get_client_by_id(self.client_id)
if not client then
return false
end
local capability = vim.tbl_get(client.config.capabilities, unpack(vim.split(method, '/')))
return type(capability) == 'table' and capability.dynamicRegistration
end
--- @param registrations lsp.Registration[]
--- @private
function M:register(registrations)
-- remove duplicates
self:unregister(registrations)
for _, reg in ipairs(registrations) do
local method = reg.method
if not self.capabilities[method] then
self.capabilities[method] = {}
end
table.insert(self.capabilities[method], reg)
end
end
--- @param unregisterations lsp.Unregistration[]
--- @private
function M:unregister(unregisterations)
for _, unreg in ipairs(unregisterations) do
local method = unreg.method
if not self.capabilities[method] then
return
end
local id = unreg.id
for i, reg in ipairs(self.capabilities[method]) do
if reg.id == id then
table.remove(self.capabilities[method], i)
break
end
end
end
end
--- @param method string
--- @param opts? {bufnr?: number}
--- @return lsp.Registration? (table|nil) the registration if found
--- @private
function M:get(method, opts)
opts = opts or {}
opts.bufnr = opts.bufnr or vim.api.nvim_get_current_buf()
for _, reg in ipairs(self.capabilities[method] or {}) do
if not reg.registerOptions then
return reg
end
local documentSelector = reg.registerOptions.documentSelector
if not documentSelector then
return reg
end
if M.match(opts.bufnr, documentSelector) then
return reg
end
end
end
--- @param method string
--- @param opts? {bufnr?: number}
--- @private
function M:supports(method, opts)
return self:get(method, opts) ~= nil
end
--- @param bufnr number
--- @param documentSelector lsp.DocumentSelector
--- @private
function M.match(bufnr, documentSelector)
local ft = vim.bo[bufnr].filetype
local uri = vim.uri_from_bufnr(bufnr)
local fname = vim.uri_to_fname(uri)
for _, filter in ipairs(documentSelector) do
local matches = true
if filter.language and ft ~= filter.language then
matches = false
end
if matches and filter.scheme and not vim.startswith(uri, filter.scheme .. ':') then
matches = false
end
if matches and filter.pattern and not wf._match(filter.pattern, fname) then
matches = false
end
if matches then
return true
end
end
end
return M

View File

@ -683,11 +683,7 @@ local function on_code_action_results(results, ctx, options)
--
local client = vim.lsp.get_client_by_id(action_tuple[1])
local action = action_tuple[2]
if
not action.edit
and client
and vim.tbl_get(client.server_capabilities, 'codeActionProvider', 'resolveProvider')
then
if not action.edit and client and client.supports_method('codeAction/resolve') then
client.request('codeAction/resolve', action, function(err, resolved_action)
if err then
vim.notify(err.code .. ': ' .. err.message, vim.log.levels.ERROR)

View File

@ -118,22 +118,30 @@ end
--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#client_registerCapability
M['client/registerCapability'] = function(_, result, ctx)
local log_unsupported = false
local client_id = ctx.client_id
---@type lsp.Client
local client = vim.lsp.get_client_by_id(client_id)
client.dynamic_capabilities:register(result.registrations)
for bufnr, _ in ipairs(client.attached_buffers) do
vim.lsp._set_defaults(client, bufnr)
end
---@type string[]
local unsupported = {}
for _, reg in ipairs(result.registrations) do
if reg.method == 'workspace/didChangeWatchedFiles' then
require('vim.lsp._watchfiles').register(reg, ctx)
else
log_unsupported = true
elseif not client.dynamic_capabilities:supports_registration(reg.method) then
unsupported[#unsupported + 1] = reg.method
end
end
if log_unsupported then
local client_id = ctx.client_id
if #unsupported > 0 then
local warning_tpl = 'The language server %s triggers a registerCapability '
.. 'handler despite dynamicRegistration set to false. '
.. 'handler for %s despite dynamicRegistration set to false. '
.. 'Report upstream, this warning is harmless'
local client = vim.lsp.get_client_by_id(client_id)
local client_name = client and client.name or string.format('id=%d', client_id)
local warning = string.format(warning_tpl, client_name)
local warning = string.format(warning_tpl, client_name, table.concat(unsupported, ', '))
log.warn(warning)
end
return vim.NIL
@ -141,6 +149,10 @@ end
--see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#client_unregisterCapability
M['client/unregisterCapability'] = function(_, result, ctx)
local client_id = ctx.client_id
local client = vim.lsp.get_client_by_id(client_id)
client.dynamic_capabilities:unregister(result.unregisterations)
for _, unreg in ipairs(result.unregisterations) do
if unreg.method == 'workspace/didChangeWatchedFiles' then
require('vim.lsp._watchfiles').unregister(unreg, ctx)

View File

@ -697,7 +697,7 @@ function protocol.make_client_capabilities()
didSave = true,
},
codeAction = {
dynamicRegistration = false,
dynamicRegistration = true,
codeActionLiteralSupport = {
codeActionKind = {
@ -714,6 +714,12 @@ function protocol.make_client_capabilities()
properties = { 'edit' },
},
},
formatting = {
dynamicRegistration = true,
},
rangeFormatting = {
dynamicRegistration = true,
},
completion = {
dynamicRegistration = false,
completionItem = {
@ -747,6 +753,7 @@ function protocol.make_client_capabilities()
},
definition = {
linkSupport = true,
dynamicRegistration = true,
},
implementation = {
linkSupport = true,
@ -755,7 +762,7 @@ function protocol.make_client_capabilities()
linkSupport = true,
},
hover = {
dynamicRegistration = false,
dynamicRegistration = true,
contentFormat = { protocol.MarkupKind.Markdown, protocol.MarkupKind.PlainText },
},
signatureHelp = {
@ -790,7 +797,7 @@ function protocol.make_client_capabilities()
hierarchicalDocumentSymbolSupport = true,
},
rename = {
dynamicRegistration = false,
dynamicRegistration = true,
prepareSupport = true,
},
publishDiagnostics = {

View File

@ -35,3 +35,31 @@
---@field source string
---@field tags? lsp.DiagnosticTag[]
---@field relatedInformation DiagnosticRelatedInformation[]
--- @class lsp.DocumentFilter
--- @field language? string
--- @field scheme? string
--- @field pattern? string
--- @alias lsp.DocumentSelector lsp.DocumentFilter[]
--- @alias lsp.RegisterOptions any | lsp.StaticRegistrationOptions | lsp.TextDocumentRegistrationOptions
--- @class lsp.Registration
--- @field id string
--- @field method string
--- @field registerOptions? lsp.RegisterOptions
--- @alias lsp.RegistrationParams {registrations: lsp.Registration[]}
--- @class lsp.StaticRegistrationOptions
--- @field id? string
--- @class lsp.TextDocumentRegistrationOptions
--- @field documentSelector? lsp.DocumentSelector
--- @class lsp.Unregistration
--- @field id string
--- @field method string
--- @alias lsp.UnregistrationParams {unregisterations: lsp.Unregistration[]}

View File

@ -3765,6 +3765,96 @@ describe('LSP', function()
end)
end)
describe('#dynamic vim.lsp._dynamic', function()
it('supports dynamic registration', function()
local root_dir = helpers.tmpname()
os.remove(root_dir)
mkdir(root_dir)
local tmpfile = root_dir .. '/dynamic.foo'
local file = io.open(tmpfile, 'w')
file:close()
exec_lua(create_server_definition)
local result = exec_lua([[
local root_dir, tmpfile = ...
local server = _create_server()
local client_id = vim.lsp.start({
name = 'dynamic-test',
cmd = server.cmd,
root_dir = root_dir,
capabilities = {
textDocument = {
formatting = {
dynamicRegistration = true,
},
rangeFormatting = {
dynamicRegistration = true,
},
},
},
})
local expected_messages = 2 -- initialize, initialized
vim.lsp.handlers['client/registerCapability'](nil, {
registrations = {
{
id = 'formatting',
method = 'textDocument/formatting',
registerOptions = {
documentSelector = {{
pattern = root_dir .. '/*.foo',
}},
},
},
},
}, { client_id = client_id })
vim.lsp.handlers['client/registerCapability'](nil, {
registrations = {
{
id = 'range-formatting',
method = 'textDocument/rangeFormatting',
},
},
}, { client_id = client_id })
vim.lsp.handlers['client/registerCapability'](nil, {
registrations = {
{
id = 'completion',
method = 'textDocument/completion',
},
},
}, { client_id = client_id })
local result = {}
local function check(method, fname)
local bufnr = fname and vim.fn.bufadd(fname) or nil
local client = vim.lsp.get_client_by_id(client_id)
result[#result + 1] = {method = method, fname = fname, supported = client.supports_method(method, {bufnr = bufnr})}
end
check("textDocument/formatting")
check("textDocument/formatting", tmpfile)
check("textDocument/rangeFormatting")
check("textDocument/rangeFormatting", tmpfile)
check("textDocument/completion")
return result
]], root_dir, tmpfile)
eq(5, #result)
eq({method = 'textDocument/formatting', supported = false}, result[1])
eq({method = 'textDocument/formatting', supported = true, fname = tmpfile}, result[2])
eq({method = 'textDocument/rangeFormatting', supported = true}, result[3])
eq({method = 'textDocument/rangeFormatting', supported = true, fname = tmpfile}, result[4])
eq({method = 'textDocument/completion', supported = false}, result[5])
end)
end)
describe('vim.lsp._watchfiles', function()
it('sends notifications when files change', function()
local root_dir = helpers.tmpname()