diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 795ccc55de..b6839ec692 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -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()`. It considers both the dynamic capabilities and static `server_capabilities`. • |vim.iter()| provides a generic iterator interface for tables and Lua iterators |luaref-in|. diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 2e6ca7a0ac..5337abea25 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -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) diff --git a/runtime/lua/vim/lsp/_dynamic.lua b/runtime/lua/vim/lsp/_dynamic.lua new file mode 100644 index 0000000000..04040e8e28 --- /dev/null +++ b/runtime/lua/vim/lsp/_dynamic.lua @@ -0,0 +1,109 @@ +local wf = require('vim.lsp._watchfiles') + +--- @class lsp.DynamicCapabilities +--- @field capabilities table +--- @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 diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index a307dea673..b2f202c4ba 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -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) diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua index 8e926c4644..5346160871 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -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) diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index a7919f12f5..a28ff407b7 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -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 = { diff --git a/runtime/lua/vim/lsp/types.lua b/runtime/lua/vim/lsp/types.lua index 779f313aa7..e77e1fb63a 100644 --- a/runtime/lua/vim/lsp/types.lua +++ b/runtime/lua/vim/lsp/types.lua @@ -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[]} diff --git a/test/functional/plugin/lsp_spec.lua b/test/functional/plugin/lsp_spec.lua index b906ae265f..1a7a656d1d 100644 --- a/test/functional/plugin/lsp_spec.lua +++ b/test/functional/plugin/lsp_spec.lua @@ -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()