diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 1812c672aa..f2b6693c21 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -2377,6 +2377,33 @@ set_level({level}) *vim.lsp.log.set_level()* • {level} (`string|integer`) One of |vim.log.levels| +============================================================================== +Lua module: vim.lsp.on_type_formatting *lsp-on_type_formatting* + +enable({enable}, {filter}) *vim.lsp.on_type_formatting.enable()* + Enables/disables on-type formatting globally or for the {filter}ed scope. + The following are some practical usage examples: >lua + -- Enable for all clients + vim.lsp.on_type_formatting.enable() + + -- Enable for a specific client + vim.api.nvim_create_autocmd('LspAttach', { + callback = function(args) + local client_id = args.data.client_id + local client = assert(vim.lsp.get_client_by_id(client_id)) + if client.name == 'rust-analyzer' then + vim.lsp.on_type_formatting.enable(true, { client_id = client_id }) + end + end, + }) +< + + Parameters: ~ + • {enable} (`boolean?`) true/nil to enable, false to disable. + • {filter} (`table?`) Optional filters |kwargs|: + • {client_id} (`integer?`) Client ID, or `nil` for all. + + ============================================================================== Lua module: vim.lsp.rpc *lsp-rpc* diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index eaf7eb3e56..6fe29b2cab 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -242,6 +242,8 @@ LSP • |vim.lsp.buf.signature_help()| supports "noActiveParameterSupport". • Support for `textDocument/inlineCompletion` |lsp-inline_completion| https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_inlineCompletion +• Support for `textDocument/onTypeFormatting`: |lsp-on_type_formatting| + https://microsoft.github.io/language-server-protocol/specification/#textDocument_onTypeFormatting LUA diff --git a/runtime/lua/vim/lsp.lua b/runtime/lua/vim/lsp.lua index 8b6d912701..dc09cdde5c 100644 --- a/runtime/lua/vim/lsp.lua +++ b/runtime/lua/vim/lsp.lua @@ -19,6 +19,7 @@ local lsp = vim._defer_require('vim.lsp', { inline_completion = ..., --- @module 'vim.lsp.inline_completion' linked_editing_range = ..., --- @module 'vim.lsp.linked_editing_range' log = ..., --- @module 'vim.lsp.log' + on_type_formatting = ..., --- @module 'vim.lsp.on_type_formatting' protocol = ..., --- @module 'vim.lsp.protocol' rpc = ..., --- @module 'vim.lsp.rpc' semantic_tokens = ..., --- @module 'vim.lsp.semantic_tokens' diff --git a/runtime/lua/vim/lsp/client.lua b/runtime/lua/vim/lsp/client.lua index c77077e3fc..7605d8fd44 100644 --- a/runtime/lua/vim/lsp/client.lua +++ b/runtime/lua/vim/lsp/client.lua @@ -211,6 +211,9 @@ local all_clients = {} --- --- @field _enabled_capabilities table --- +--- Whether on-type formatting is enabled for this client. +--- @field _otf_enabled boolean? +--- --- Track this so that we can escalate automatically if we've already tried a --- graceful shutdown --- @field private _graceful_shutdown_failed true? diff --git a/runtime/lua/vim/lsp/on_type_formatting.lua b/runtime/lua/vim/lsp/on_type_formatting.lua new file mode 100644 index 0000000000..217c4ea8ef --- /dev/null +++ b/runtime/lua/vim/lsp/on_type_formatting.lua @@ -0,0 +1,261 @@ +local api = vim.api +local lsp = vim.lsp +local util = lsp.util +local method = lsp.protocol.Methods.textDocument_onTypeFormatting + +local schedule = vim.schedule +local current_buf = api.nvim_get_current_buf +local get_mode = api.nvim_get_mode + +local ns = api.nvim_create_namespace('nvim.lsp.on_type_formatting') +local augroup = api.nvim_create_augroup('nvim.lsp.on_type_formatting', {}) + +local M = {} + +--- @alias vim.lsp.on_type_formatting.BufTriggers table> + +--- A map from bufnr -> trigger character -> client ID -> client +--- @type table +local buf_handles = {} + +--- |lsp-handler| for the `textDocument/onTypeFormatting` method. +--- +--- @param err? lsp.ResponseError +--- @param result? lsp.TextEdit[] +--- @param ctx lsp.HandlerContext +local function on_type_formatting(err, result, ctx) + if err then + lsp.log.error('on_type_formatting', err) + return + end + + local bufnr = assert(ctx.bufnr) + + -- A `null` result is equivalent to an empty `TextEdit[]` result; no work should be done. + if not result or not api.nvim_buf_is_loaded(bufnr) or util.buf_versions[bufnr] ~= ctx.version then + return + end + + local client = assert(vim.lsp.get_client_by_id(ctx.client_id)) + + util.apply_text_edits(result, ctx.bufnr, client.offset_encoding) +end + +---@param bufnr integer +---@param typed string +---@param triggered_clients vim.lsp.Client[] +---@param idx integer? +---@param client vim.lsp.Client? +local function format_iter(bufnr, typed, triggered_clients, idx, client) + if not idx or not client then + return + end + ---@type lsp.DocumentOnTypeFormattingParams + local params = vim.tbl_extend( + 'keep', + util.make_formatting_params(), + util.make_position_params(0, client.offset_encoding), + { ch = typed } + ) + client:request(method, params, function(...) + on_type_formatting(...) + format_iter(bufnr, typed, triggered_clients, next(triggered_clients, idx)) + end, bufnr) +end + +---@param typed string +local function on_key(_, typed) + local mode = get_mode() + if mode.blocking or mode.mode ~= 'i' then + return + end + + local bufnr = current_buf() + + local buf_handle = buf_handles[bufnr] + if not buf_handle then + return + end + + -- LSP expects '\n' for formatting on newline + if typed == '\r' then + typed = '\n' + end + + local triggered_clients = buf_handle[typed] + if not triggered_clients then + return + end + + -- Schedule the formatting to occur *after* the LSP is aware of the inserted character + schedule(function() + format_iter(bufnr, typed, triggered_clients, next(triggered_clients)) + end) +end + +--- @param client vim.lsp.Client +--- @param bufnr integer +local function detach(client, bufnr) + local buf_handle = buf_handles[bufnr] + if not buf_handle then + return + end + + local client_id = client.id + + -- Remove this client from its associated trigger characters + for trigger_char, attached_clients in pairs(buf_handle) do + attached_clients[client_id] = nil + + -- Remove the trigger character if we detached its last client. + if not next(attached_clients) then + buf_handle[trigger_char] = nil + end + end + + -- Remove the buf handle and its autocmds if we removed its last client. + if not next(buf_handle) then + buf_handles[bufnr] = nil + api.nvim_clear_autocmds({ group = augroup, buffer = bufnr }) + + -- Remove the on_key callback if we removed the last buf handle. + if not next(buf_handles) then + vim.on_key(nil, ns) + end + end +end + +--- @param client vim.lsp.Client +--- @param bufnr integer +local function attach(client, bufnr) + if not client:supports_method(method, bufnr) then + return + end + + local client_id = client.id + ---@type lsp.DocumentOnTypeFormattingOptions + local otf_capabilities = + assert(vim.tbl_get(client.server_capabilities, 'documentOnTypeFormattingProvider')) + + -- Set on_key callback, clearing first in case it was already registered. + vim.on_key(nil, ns) + vim.on_key(on_key, ns) + + -- Populate the buf handle data. We cannot use defaulttable here because then an empty table will + -- be created for each unique keystroke + local buf_handle = buf_handles[bufnr] or {} + buf_handles[bufnr] = buf_handle + + local trigger = buf_handle[otf_capabilities.firstTriggerCharacter] or {} + buf_handle[otf_capabilities.firstTriggerCharacter] = trigger + trigger[client_id] = client + + for _, char in ipairs(otf_capabilities.moreTriggerCharacter or {}) do + trigger = buf_handle[char] or {} + buf_handle[char] = trigger + trigger[client_id] = client + end + + api.nvim_clear_autocmds({ group = augroup, buffer = bufnr }) + api.nvim_create_autocmd('LspDetach', { + buffer = bufnr, + desc = 'Detach on-type formatting module when the client detaches', + group = augroup, + callback = function(args) + local detached_client = assert(lsp.get_client_by_id(args.data.client_id)) + detach(detached_client, bufnr) + end, + }) +end + +api.nvim_create_autocmd('LspAttach', { + desc = 'Enable on-type formatting for all buffers with individually-enabled clients.', + callback = function(ev) + local buf = ev.buf + local client = assert(lsp.get_client_by_id(ev.data.client_id)) + if client._otf_enabled then + attach(client, buf) + end + end, +}) + +---@param enable boolean +---@param client vim.lsp.Client +local function toggle_for_client(enable, client) + local handler = enable and attach or detach + + -- Toggle for buffers already attached. + for bufnr, _ in pairs(client.attached_buffers) do + handler(client, bufnr) + end + + client._otf_enabled = enable +end + +---@param enable boolean +local function toggle_globally(enable) + -- Toggle for clients that have already attached. + local clients = lsp.get_clients({ method = method }) + for _, client in ipairs(clients) do + toggle_for_client(enable, client) + end + + -- If disabling, only clear the attachment autocmd. If enabling, create it as well. + local group = api.nvim_create_augroup('nvim.lsp.on_type_formatting', { clear = true }) + if enable then + api.nvim_create_autocmd('LspAttach', { + group = group, + desc = 'Enable on-type formatting for ALL clients by default.', + callback = function(ev) + local client = assert(lsp.get_client_by_id(ev.data.client_id)) + if client._otf_enabled ~= false then + attach(client, ev.buf) + end + end, + }) + end +end + +--- Optional filters |kwargs|: +--- @inlinedoc +--- @class vim.lsp.on_type_formatting.enable.Filter +--- @field client_id integer? Client ID, or `nil` for all. + +--- Enables/disables on-type formatting globally or for the {filter}ed scope. The following are some +--- practical usage examples: +--- +--- ```lua +--- -- Enable for all clients +--- vim.lsp.on_type_formatting.enable() +--- +--- -- Enable for a specific client +--- vim.api.nvim_create_autocmd('LspAttach', { +--- callback = function(args) +--- local client_id = args.data.client_id +--- local client = assert(vim.lsp.get_client_by_id(client_id)) +--- if client.name == 'rust-analyzer' then +--- vim.lsp.on_type_formatting.enable(true, { client_id = client_id }) +--- end +--- end, +--- }) +--- ``` +--- +--- @param enable? boolean true/nil to enable, false to disable. +--- @param filter vim.lsp.on_type_formatting.enable.Filter? +function M.enable(enable, filter) + vim.validate('enable', enable, 'boolean', true) + vim.validate('filter', filter, 'table', true) + + enable = enable ~= false + filter = filter or {} + + if filter.client_id then + local client = + assert(lsp.get_client_by_id(filter.client_id), 'Client not found for id ' .. filter.client_id) + toggle_for_client(enable, client) + else + toggle_globally(enable) + end +end + +return M diff --git a/runtime/lua/vim/lsp/protocol.lua b/runtime/lua/vim/lsp/protocol.lua index f724d6fc66..d8e721d732 100644 --- a/runtime/lua/vim/lsp/protocol.lua +++ b/runtime/lua/vim/lsp/protocol.lua @@ -572,6 +572,9 @@ function protocol.make_client_capabilities() linkedEditingRange = { dynamicRegistration = false, }, + onTypeFormatting = { + dynamicRegistration = false, + }, }, workspace = { symbol = { diff --git a/src/gen/gen_vimdoc.lua b/src/gen/gen_vimdoc.lua index f084584102..ba96008cef 100755 --- a/src/gen/gen_vimdoc.lua +++ b/src/gen/gen_vimdoc.lua @@ -286,6 +286,7 @@ local config = { 'inline_completion.lua', 'linked_editing_range.lua', 'log.lua', + 'on_type_formatting.lua', 'rpc.lua', 'semantic_tokens.lua', 'tagfunc.lua', diff --git a/test/functional/plugin/lsp/on_type_formatting_spec.lua b/test/functional/plugin/lsp/on_type_formatting_spec.lua new file mode 100644 index 0000000000..211350335e --- /dev/null +++ b/test/functional/plugin/lsp/on_type_formatting_spec.lua @@ -0,0 +1,174 @@ +local t = require('test.testutil') +local n = require('test.functional.testnvim')() +local t_lsp = require('test.functional.plugin.lsp.testutil') +local retry = t.retry + +local eq = t.eq +local dedent = t.dedent +local exec_lua = n.exec_lua +local insert = n.insert +local feed = n.feed + +local clear_notrace = t_lsp.clear_notrace +local create_server_definition = t_lsp.create_server_definition + +describe('vim.lsp.on_type_formatting', function() + local text = dedent([[ + int main() { + int hi + }]]) + + before_each(function() + clear_notrace() + + exec_lua(create_server_definition) + exec_lua(function() + _G.server = _G._create_server({ + capabilities = { + documentOnTypeFormattingProvider = { + firstTriggerCharacter = '=', + }, + }, + handlers = { + ---@param params lsp.DocumentOnTypeFormattingParams + ---@param callback fun(err?: lsp.ResponseError, result?: lsp.TextEdit[]) + ['textDocument/onTypeFormatting'] = function(_, params, callback) + callback(nil, { + { + newText = ';', + range = { + start = params.position, + ['end'] = params.position, + }, + }, + }) + end, + }, + }) + + _G.server_id = vim.lsp.start({ + name = 'dummy', + cmd = _G.server.cmd, + }) + vim.lsp.on_type_formatting.enable(true, { client_id = _G.server_id }) + end) + + insert(text) + end) + + it('enables formatting on type', function() + exec_lua(function() + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_cursor(win, { 2, 0 }) + end) + feed('A = 5') + retry(nil, 100, function() + eq( + { + 'int main() {', + ' int hi = 5;', + '}', + }, + exec_lua(function() + return vim.api.nvim_buf_get_lines(0, 0, -1, false) + end) + ) + end) + end) + + it('works with multiple clients', function() + exec_lua(function() + vim.lsp.on_type_formatting.enable(true) + _G.server2 = _G._create_server({ + capabilities = { + documentOnTypeFormattingProvider = { + firstTriggerCharacter = '.', + moreTriggerCharacter = { '=' }, + }, + }, + handlers = { + ---@param params lsp.DocumentOnTypeFormattingParams + ---@param callback fun(err?: lsp.ResponseError, result?: lsp.TextEdit[]) + ['textDocument/onTypeFormatting'] = function(_, params, callback) + callback(nil, { + { + newText = ';', + range = { + start = params.position, + ['end'] = params.position, + }, + }, + }) + end, + }, + }) + + vim.lsp.start({ + name = 'dummy2', + cmd = _G.server2.cmd, + }) + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_cursor(win, { 2, 0 }) + end) + feed('A =') + retry(nil, 100, function() + eq( + { + 'int main() {', + ' int hi =;;', + '}', + }, + exec_lua(function() + return vim.api.nvim_buf_get_lines(0, 0, -1, false) + end) + ) + end) + end) + + it('can be disabled', function() + exec_lua(function() + vim.lsp.on_type_formatting.enable(false, { client_id = _G.server_id }) + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_cursor(win, { 2, 0 }) + end) + feed('A = 5') + eq( + { + 'int main() {', + ' int hi = 5', + '}', + }, + exec_lua(function() + return vim.api.nvim_buf_get_lines(0, 0, -1, false) + end) + ) + end) + + it('attaches to new buffers', function() + exec_lua(function() + local buf = vim.api.nvim_create_buf(true, false) + vim.api.nvim_set_current_buf(buf) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, { + 'int main() {', + ' int hi', + '}', + }) + local win = vim.api.nvim_get_current_win() + vim.api.nvim_win_set_cursor(win, { 2, 0 }) + vim.lsp.buf_attach_client(buf, _G.server_id) + end) + feed('A = 5') + retry(nil, 100, function() + eq( + { + 'int main() {', + ' int hi = 5;', + '}', + }, + exec_lua(function() + return vim.api.nvim_buf_get_lines(0, 0, -1, false) + end) + ) + end) + end) +end)