commit 77e3efecee4e9948044ec02caeaf6fec496c6c02
parent f311c96973a561ba5e664f46e758a97fd10acdcb
Author: Riley Bruins <ribru17@hotmail.com>
Date: Sun, 31 Aug 2025 14:09:12 -0700
feat(lsp): support `textDocument/onTypeFormatting` (#34637)
Implements [on-type
formatting](https://microsoft.github.io/language-server-protocol/specifications/lsp/3.18/specification/#textDocument_onTypeFormatting)
using a `vim.on_key()` approach to listen to typed keys. It will listen
to keys on the *left hand side* of mappings. The `on_key` callback is
cleared when detaching the last on-type formatting client. This feature
is disabled by default.
Co-authored-by: Maria José Solano <majosolano99@gmail.com>
Diffstat:
8 files changed, 472 insertions(+), 0 deletions(-)
diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt
@@ -2378,6 +2378,33 @@ set_level({level}) *vim.lsp.log.set_level()*
==============================================================================
+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*
*vim.lsp.rpc.PublicClient*
diff --git 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
@@ -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
@@ -211,6 +211,9 @@ local all_clients = {}
---
--- @field _enabled_capabilities table<vim.lsp.capability.Name, boolean?>
---
+--- 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
@@ -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<string, table<integer, vim.lsp.Client>>
+
+--- A map from bufnr -> trigger character -> client ID -> client
+--- @type table<integer, vim.lsp.on_type_formatting.BufTriggers>
+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
@@ -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
@@ -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
@@ -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)