feat(lsp): fallback to textDocument/formatting from vim.lsp.formatexpr #40079

Problem:
With the addition of the `:help al` text object, you can now easily
format the whole buffer with `gqal`. However, `vim.lsp.formatexpr` only
uses `textDocument/rangeFormatting` which some language servers (like
gopls) don't support.

Solution:
- Fall back to `textDocument/formatting` if the whole buffer is being formatted
  and the server doesn't support `textDocument/rangeFormatting`.
  - In theory, these two methods should return the same response if the whole
    buffer is being formatted, but I preserved the existing behaviour of
    prioritising `textDocument/rangeFormatting` in case that does not hold (i.e.
    LS bug).
- Also: `vim.lsp.formatexpr` had no tests at all, so actually add tests for it.
This commit is contained in:
Marcus Caisey authored and GitHub committed 2026-08-18 05:16:16 -04:00
1 parent 27d40762bc
commit d9b4fb1273
8 files changed
+361 -16

No files matched your search

+3
View File
@@ -352,6 +352,9 @@ LSP
`commitCharacters` had no effect.
• The `reuse_client` predicate now passes the target buffer. See |vim.lsp.Config| and
|vim.lsp.start()|
• |vim.lsp.formatexpr()| now falls back to `textDocument/formatting` if the whole
buffer is being formatted and the language server doesn't support
`textDocument/rangeFormatting`.
LUA
+15 -5
View File
@@ -854,7 +854,10 @@ function lsp._set_defaults(client, bufnr)
vim.bo[bufnr].omnifunc = lsp.omnifunc
end
if
client:supports_method('textDocument/rangeFormatting')
(
client:supports_method('textDocument/rangeFormatting')
or client:supports_method('textDocument/formatting')
)
and is_empty_or_default(bufnr, 'formatprg')
and is_empty_or_default(bufnr, 'formatexpr')
then
@@ -1387,7 +1390,7 @@ function lsp.formatexpr(opts)
opts = opts or {}
local timeout_ms = opts.timeout_ms or 500
if vim.list_contains({ 'i', 'R', 'ic', 'ix' }, vim.fn.mode()) then
if vim.list_contains({ 'i', 'R' }, vim.fn.mode()) then
-- `formatexpr` is also called when exceeding `textwidth` in insert mode
-- fall back to internal formatting
return 1
@@ -1400,9 +1403,11 @@ function lsp.formatexpr(opts)
return 0
end
local bufnr = api.nvim_get_current_buf()
local range_is_whole_buffer = start_lnum == 1 and end_lnum == api.nvim_buf_line_count(bufnr)
for _, client in pairs(lsp.get_clients({ bufnr = bufnr })) do
local params = util.make_formatting_params()
local method ---@type vim.lsp.protocol.Method.ClientToServer.Request?
if client:supports_method('textDocument/rangeFormatting') then
local params = util.make_formatting_params()
local end_line = vim.fn.getline(end_lnum) --[[@as string]]
local end_col = vim.str_utfindex(end_line, client.offset_encoding)
--- @cast params +lsp.DocumentRangeFormattingParams
@@ -1416,8 +1421,13 @@ function lsp.formatexpr(opts)
character = end_col,
},
}
local response =
client:request_sync('textDocument/rangeFormatting', params, timeout_ms, bufnr)
method = 'textDocument/rangeFormatting'
elseif client:supports_method('textDocument/formatting') and range_is_whole_buffer then
method = 'textDocument/formatting'
end
if method then
local response = client:request_sync(method, params, timeout_ms, bufnr)
if response and response.result then
util.apply_text_edits(response.result, bufnr, client.offset_encoding)
return 0
+9 -2
View File
@@ -1099,7 +1099,10 @@ describe('vim.lsp.buf', function()
})
end,
['codeAction/resolve'] = function(_, _, callback)
callback('resolve failed', nil)
callback(
{ code = vim.lsp.protocol.ErrorCodes.InternalError, message = 'resolve failed' },
nil
)
end,
},
})
@@ -1753,7 +1756,11 @@ describe('vim.lsp.buf', function()
return exec_lua(function()
_G.server = _G._create_server({
capabilities = {
diagnosticProvider = { workspaceDiagnostics = true },
diagnosticProvider = {
documentSelector = vim.NIL,
interFileDependencies = false,
workspaceDiagnostics = true,
},
},
handlers = {
['workspace/diagnostic'] = function(_, _, callback)
@@ -1212,7 +1212,7 @@ describe('vim.lsp.completion: protocol', function()
local params = exec_lua(function()
local params
local server = _G._create_server({
capabilities = { completionProvider = true },
capabilities = { completionProvider = {} },
handlers = {
['textDocument/completion'] = function(_, params0, callback)
params = params0
+15 -3
View File
@@ -326,7 +326,11 @@ describe('vim.lsp.diagnostic', function()
_G.requests = 0
_G.server = _G._create_server({
capabilities = {
diagnosticProvider = {},
diagnosticProvider = {
documentSelector = vim.NIL,
interFileDependencies = false,
workspaceDiagnostics = false,
},
},
handlers = {
['textDocument/diagnostic'] = function(_, params)
@@ -819,7 +823,11 @@ describe('vim.lsp.diagnostic', function()
_G.requests = 0
_G.server = _G._create_server({
capabilities = {
diagnosticProvider = {},
diagnosticProvider = {
documentSelector = vim.NIL,
interFileDependencies = false,
workspaceDiagnostics = false,
},
},
handlers = {
['textDocument/diagnostic'] = function(_, _, callback)
@@ -877,7 +885,11 @@ describe('vim.lsp.diagnostic', function()
_G.doc_requests = 0
_G.server = _G._create_server({
capabilities = {
diagnosticProvider = { workspaceDiagnostics = true },
diagnosticProvider = {
documentSelector = vim.NIL,
interFileDependencies = false,
workspaceDiagnostics = true,
},
},
handlers = {
['textDocument/diagnostic'] = function(_, _, callback)
@@ -954,15 +954,14 @@ describe('semantic token highlighting', function()
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
semanticTokensProvider = {
legend = vim.fn.json_decode(legend),
full = { delta = false },
},
},
handlers = {
--- @param callback function
['textDocument/semanticTokens/full'] = function(_, _, callback)
callback(nil, nil)
end,
--- @param callback function
['textDocument/semanticTokens/full/delta'] = function(_, _, callback)
callback(nil, nil)
end,
@@ -1006,6 +1005,7 @@ describe('semantic token highlighting', function()
capabilities = {
textDocumentSync = vim.lsp.protocol.TextDocumentSyncKind.Full,
semanticTokensProvider = {
legend = vim.fn.json_decode(legend),
full = { delta = false },
},
},
+14
View File
@@ -65,6 +65,20 @@ M.create_tcp_echo_server = function()
end
M.create_server_definition = function()
---@class test.functional.plugin.lsp.testutil._create_server.Opts
---@field capabilities lsp.ServerCapabilities?
---@field handlers table<vim.lsp.protocol.Method.ClientToServer.Request, fun(method: vim.lsp.protocol.Method.ClientToServer.Request, params: table?, callback: fun(err?: lsp.ResponseError, result: any))>?
---@class test.functional.plugin.lsp.testutil._create_server.Message
---@field method vim.lsp.protocol.Method.ClientToServer
---@field params table?
---@class test.functional.plugin.lsp.testutil._create_server.Server
---@field messages test.functional.plugin.lsp.testutil._create_server.Message[]
---@field cmd fun(dispatchers: vim.lsp.rpc.Dispatchers, config: vim.lsp.ClientConfig): vim.lsp.rpc.Client
---@param opts test.functional.plugin.lsp.testutil._create_server.Opts?
---@return test.functional.plugin.lsp.testutil._create_server.Server
function _G._create_server(opts)
opts = opts or {}
local server = {}
+302 -3
View File
@@ -9,6 +9,7 @@ local buf_lines = n.buf_lines
local command = n.command
local dedent = t.dedent
local exec_lua = n.exec_lua
local feed = n.feed
local eq = t.eq
local eval = n.eval
local matches = t.matches
@@ -2846,6 +2847,303 @@ describe('LSP', function()
end)
end)
describe('vim.lsp.formatexpr', function()
---@return string[]
local function nvim_buf_get_all_lines()
return exec_lua(function()
return vim.api.nvim_buf_get_lines(0, 0, -1, true)
end)
end
before_each(function()
exec_lua(create_server_definition)
exec_lua(function()
vim.bo.formatexpr = 'v:lua.vim.lsp.formatexpr()'
---@class test.functional.plugin.lsp_spec.formatexpr.start_server.Opts
---@field range_formatting_handler? fun(params: lsp.DocumentRangeFormattingParams): lsp.TextEdit[]?
---@field formatting_handler? fun(params: lsp.DocumentFormattingParams): lsp.TextEdit[]?
local server_number = 1
---Starts a server which can support textDocument/rangeFormatting and
---textDocument/formatting. Providing a handler for either method enables the corresponding
---capability.
---@param opts test.functional.plugin.lsp_spec.formatexpr.start_server.Opts?
function _G.start_server(opts)
opts = opts or {}
local server = _G._create_server({
capabilities = {
documentRangeFormattingProvider = opts.range_formatting_handler ~= nil,
documentFormattingProvider = opts.formatting_handler ~= nil,
},
handlers = {
---@param params lsp.DocumentRangeFormattingParams
['textDocument/rangeFormatting'] = function(_, params, callback)
local text_edits = opts.range_formatting_handler(params)
callback(nil, text_edits)
end,
---@param params lsp.DocumentFormattingParams
['textDocument/formatting'] = function(_, params, callback)
local text_edits = opts.formatting_handler(params)
callback(nil, text_edits)
end,
},
})
vim.lsp.start({ name = string.format('server %d', server_number), cmd = server.cmd })
server_number = server_number + 1
end
end)
end)
it('formats range of lines with textDocument/rangeFormatting', function()
local lines = { 'line 1', 'line 2', 'line 3', 'line 4' }
exec_lua(function()
vim.api.nvim_buf_set_lines(0, 0, -1, true, lines)
-- This server formats a range starting on line n and ending on line m by replacing the
-- whole range with the lines "formatted line $i" where $i goes from n to m. This enables us
-- to verify that the correct range is requested for formatting (starts at first character
-- of first line and ends on last character of last line).
start_server({
range_formatting_handler = function(params)
local lines = {} ---@type string[]
for i = params.range.start.line + 1, params.range['end'].line + 1 do
table.insert(lines, string.format('formatted line %d', i))
end
return { { range = params.range, newText = table.concat(lines, '\n') } }
end,
})
end)
feed('2G') -- Move to line 2
feed('gqj') -- Format lines 2 and 3
local expected_lines = vim.deepcopy(lines)
expected_lines[2] = 'formatted line 2'
expected_lines[3] = 'formatted line 3'
eq(expected_lines, nvim_buf_get_all_lines())
end)
it(
'formats whole buffer with textDocument/rangeFormatting when client supports textDocument/rangeFormatting and textDocument/formatting',
function()
local range_formatting_new_text = 'range formatting result'
local formatting_new_text = 'formatting result'
exec_lua(function()
-- This server formats a range and a whole document by inserting "range formatting result"
-- and "formatting result" respectively at the start of the buffer.
local first_char = { line = 0, character = 0 }
start_server({
range_formatting_handler = function()
return {
{
range = { start = first_char, ['end'] = first_char },
newText = range_formatting_new_text,
},
}
end,
formatting_handler = function()
return {
{
range = { start = first_char, ['end'] = first_char },
newText = formatting_new_text,
},
}
end,
})
end)
feed('gqal') -- Format whole buffer
eq({ range_formatting_new_text }, nvim_buf_get_all_lines())
end
)
it(
'formats whole buffer with textDocument/formatting when client supports textDocument/formatting but not textDocument/rangeFormatting',
function()
local new_text = 'formatting result'
exec_lua(function()
-- This server formats a whole document by inserting "formatting result" at the start of
-- the buffer.
start_server({
formatting_handler = function()
local first_char = { line = 0, character = 0 }
return {
{
range = { start = first_char, ['end'] = first_char },
newText = new_text,
},
}
end,
})
end)
feed('gqal') -- Format whole buffer
eq({ new_text }, nvim_buf_get_all_lines())
end
)
it(
'does nothing when range is not whole buffer and client supports textDocument/formatting but not textDocument/rangeFormatting',
function()
local lines = { 'a b', 'c d' }
local new_text = 'formatting result'
exec_lua(function()
vim.api.nvim_buf_set_lines(0, 0, -1, true, lines)
-- Set small textwidth to verify that gq doesn't perform internal formatting (wrap
-- lines) on the buffer.
vim.bo.textwidth = 1
-- This server formats a whole document by inserting "formatting result" at the start of
-- the buffer.
start_server({
formatting_handler = function()
local first_char = { line = 0, character = 0 }
return {
{
range = { start = first_char, ['end'] = first_char },
newText = new_text,
},
}
end,
})
end)
feed('2G') -- Move to line 2
feed('Vgq') -- Format line 2
eq(lines, nvim_buf_get_all_lines())
end
)
it('applies only one formatting result when multiple clients support formatting', function()
local new_text = 'formatting result'
exec_lua(function()
-- These servers format a range by inserting "formatting result" at the start of the buffer.
-- This enables us to verify that only one client was used, since if both were used then
-- both results would have been inserted at the start of the buffer.
for _ = 1, 2 do
start_server({
range_formatting_handler = function()
local first_char = { line = 0, character = 0 }
return {
{
range = { start = first_char, ['end'] = first_char },
newText = new_text,
},
}
end,
})
end
end)
feed('gqal') -- Format whole buffer
eq({ new_text }, nvim_buf_get_all_lines())
end)
it(
'applies first non-null formatting result when multiple clients support formatting',
function()
local new_text = 'formatting result'
exec_lua(function()
-- These servers format a range by returning null if neither of them have been called
-- before and inserting "formatting result" otherwise. This decouples the test from the
-- (undefined) order that clients are called in.
for _ = 1, 2 do
start_server({
range_formatting_handler = function()
if not range_formatting_called then
_G.range_formatting_called = true
return nil
else
local first_char = { line = 0, character = 0 }
return {
{
range = { start = first_char, ['end'] = first_char },
newText = new_text,
},
}
end
end,
})
end
end)
feed('gqal') -- Format whole buffer
eq({ new_text }, nvim_buf_get_all_lines())
end
)
describe('does nothing when', function()
---@type table<string, function>
local test_case_setups = {
['no clients are attached'] = function() end,
['all servers return null formatting result'] = function()
start_server({
range_formatting_handler = function()
return nil
end,
})
end,
['no servers support textDocument/rangeFormatting or textDocument/formatting'] = function()
start_server()
end,
}
for test_case, setup in pairs(test_case_setups) do
it(test_case, function()
local lines = { 'a b' }
exec_lua(function()
vim.api.nvim_buf_set_lines(0, 0, -1, true, lines)
-- Set small textwidth to verify that gq doesn't perform internal formatting (wrap
-- lines) on the buffer.
vim.bo.textwidth = 1
start_server({
range_formatting_handler = function()
return nil
end,
})
end)
exec_lua(setup)
feed('gqal') -- Format whole buffer
eq(lines, nvim_buf_get_all_lines())
end)
end
end)
describe('falls back to internal formatting', function()
for _, mode in ipairs({ 'i', 'R' }) do
it(string.format('in %q mode', mode), function()
exec_lua(function()
vim.bo.textwidth = 1
-- This server formats a range by inserting "formatting result" at the start of the
-- buffer.
start_server({
range_formatting_handler = function()
local first_char = { line = 0, character = 0 }
return {
{
range = { start = first_char, ['end'] = first_char },
newText = 'formatting result',
},
}
end,
})
end)
feed(mode)
feed('a b')
eq({ 'a', 'b' }, nvim_buf_get_all_lines())
end)
end
end)
end)
describe('vim.lsp.tagfunc', function()
before_each(function()
---@type lsp.Location[]
@@ -2869,17 +3167,14 @@ describe('LSP', function()
exec_lua(function()
_G.mock_locations = mock_locations
_G.server = _G._create_server({
---@type lsp.ServerCapabilities
capabilities = {
definitionProvider = true,
workspaceSymbolProvider = true,
},
handlers = {
---@return lsp.Location[]
['textDocument/definition'] = function(_, _, callback)
callback(nil, { _G.mock_locations[1] })
end,
---@return lsp.WorkspaceSymbol[]
['workspace/symbol'] = function(_, request, callback)
assert(request.query == 'foobar')
callback(nil, {
@@ -3648,6 +3943,8 @@ describe('LSP', function()
capabilities = {
colorProvider = { id = 'color-registration' },
diagnosticProvider = {
documentSelector = vim.NIL,
interFileDependencies = false,
id = 'diag-registration',
identifier = 'diag-ident-static',
workspaceDiagnostics = true,
@@ -3697,6 +3994,8 @@ describe('LSP', function()
id = 'diag-registration',
method = 'textDocument/diagnostic',
registerOptions = {
documentSelector = vim.NIL,
interFileDependencies = false,
id = 'diag-registration',
identifier = 'diag-ident-static',
workspaceDiagnostics = true,