Compare commits

...

4 commits

Author SHA1 Message Date
zeertzjq
5e874f749a
Merge 1b608c449f into 77e3efecee 2025-09-01 07:45:30 +07:00
Riley Bruins
77e3efecee
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>
2025-08-31 14:09:12 -07:00
Michael Henry
f311c96973
fix(health): update advice for Python #35564
Problem: `:checkhealth` advice for Python is out-of-date.

Solution: Update the advice to point to `:help provider-python`.
2025-08-31 11:17:21 -07:00
zeertzjq
1b608c449f vim-patch:9.1.1646: MS-Windows: completion cannot handle implicit drive letters
Problem:  MS-Windows: completion cannot handle implicit drive letters
Solution: Consider paths like \folder and /folder as absolute
          (Miguel Barro).

closes: vim/vim#17829

a2f13bf782

Co-authored-by: Miguel Barro <miguel.barro@live.com>
2025-08-18 11:02:11 +08:00
14 changed files with 560 additions and 20 deletions

View file

@ -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*

View file

@ -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
@ -352,6 +354,8 @@ These existing features changed their behavior.
• 'scrollback' maximum value increased from 100000 to 1000000
• |matchfuzzy()| and |matchfuzzypos()| use an improved fuzzy matching algorithm
(same as fzy).
- MS-Winodws: Paths like "\Windows" and "/Windows" are now considered to be
absolute paths (to the current drive) and no longer relative.
==============================================================================
REMOVED FEATURES *news-removed*

View file

@ -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'

View file

@ -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?

View file

@ -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

View file

@ -572,6 +572,9 @@ function protocol.make_client_capabilities()
linkedEditingRange = {
dynamicRegistration = false,
},
onTypeFormatting = {
dynamicRegistration = false,
},
},
workspace = {
symbol = {

View file

@ -732,10 +732,9 @@ local function python()
local message = 'Detected pip upgrade failure: Python executable can import "pynvim" but not "neovim": '
.. pynvim_exe
local advice = {
'Use that Python version to reinstall "pynvim" and optionally "neovim".',
'Use that Python version to uninstall any "pynvim" or "neovim", e.g.:',
pynvim_exe .. ' -m pip uninstall pynvim neovim',
pynvim_exe .. ' -m pip install pynvim',
pynvim_exe .. ' -m pip install neovim # only if needed by third-party software',
'Then see :help provider-python for "pynvim" installation steps.',
}
health.error(message, advice)
end
@ -761,7 +760,7 @@ local function python()
if is_bad_response(current) then
health.error(
'pynvim is not installed.\nError: ' .. current,
'Run in shell: ' .. python_exe .. ' -m pip install pynvim'
'See :help provider-python for "pynvim" installation steps.'
)
end

View file

@ -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',

View file

@ -8121,7 +8121,11 @@ repeat:
}
// FullName_save() is slow, don't use it when not needed.
if (*p != NUL || !vim_isAbsName(*fnamep)) {
if (*p != NUL || !vim_isAbsName(*fnamep)
#ifdef MSWIN // enforce drive letter on windows paths
|| **fnamep == '/' || **fnamep == '\\'
#endif
) {
*fnamep = FullName_save(*fnamep, *p != NUL);
xfree(*bufp); // free any allocated file name
*bufp = *fnamep;

View file

@ -330,17 +330,6 @@ void *vim_findfile_init(char *path, char *filename, size_t filenamelen, char *st
ff_expand_buffer.size = strlen(ff_expand_buffer.data);
search_ctx->ffsc_start_dir = copy_string(ff_expand_buffer, NULL);
#ifdef BACKSLASH_IN_FILENAME
// A path that starts with "/dir" is relative to the drive, not to the
// directory (but not for "//machine/dir"). Only use the drive name.
if ((*path == '/' || *path == '\\')
&& path[1] != path[0]
&& search_ctx->ffsc_start_dir.data[1] == ':') {
search_ctx->ffsc_start_dir.data[2] = NUL;
search_ctx->ffsc_start_dir.size = 2;
}
#endif
}
// If stopdirs are given, split them into an array of pointers.

View file

@ -5,6 +5,9 @@
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#ifdef MSWIN
# include <direct.h>
#endif
#include "auto/config.h"
#include "nvim/ascii_defs.h"
@ -377,6 +380,38 @@ int path_fnamencmp(const char *const fname1, const char *const fname2, size_t le
const char *p1 = fname1;
const char *p2 = fname2;
# ifdef MSWIN
// To allow proper comparison of absolute paths:
// - one with explicit drive letter C:\xxx
// - another with implicit drive letter \xxx
// advance the pointer, of the explicit one, to skip the drive
for (int swap = 0, drive = NUL; swap < 2; swap++) {
// Handle absolute paths with implicit drive letter
c1 = utf_ptr2char(p1);
c2 = utf_ptr2char(p2);
if ((c1 == '/' || c1 == '\\') && ASCII_ISALPHA(c2)) {
drive = mb_toupper(c2) - 'A' + 1;
// Check for the colon
p2 += utfc_ptr2len(p2);
c2 = utf_ptr2char(p2);
if (c2 == ':' && drive == _getdrive()) { // skip the drive for comparison
p2 += utfc_ptr2len(p2);
break;
} else { // ignore
p2 -= utfc_ptr2len(p2);
}
}
// swap pointers
const char *tmp = p1;
p1 = p2;
p2 = tmp;
}
# endif
while (len > 0) {
c1 = utf_ptr2char(p1);
c2 = utf_ptr2char(p2);
@ -1840,7 +1875,7 @@ int vim_FullName(const char *fname, char *buf, size_t len, bool force)
/// the root may have relative paths (like dir/../subdir) or symlinks
/// embedded, or even extra separators (//). This function addresses
/// those possibilities, returning a resolved absolute path.
/// For MS-Windows, this also expands names like "longna~1".
/// For MS-Windows, this also provides deive letter for all absolute paths.
///
/// @param fname is the filename to expand
/// @return [allocated] Full path (NULL for failure).
@ -1854,6 +1889,10 @@ char *fix_fname(const char *fname)
|| strstr(fname, "//") != NULL
# ifdef BACKSLASH_IN_FILENAME
|| strstr(fname, "\\\\") != NULL
# endif
# ifdef MSWIN
|| fname[0] == '/'
|| fname[0] == '\\'
# endif
) {
return FullName_save(fname, false);
@ -2376,8 +2415,9 @@ bool path_is_absolute(const char *fname)
return false;
}
// A name like "d:/foo" and "//server/share" is absolute
return ((isalpha((uint8_t)fname[0]) && fname[1] == ':' && vim_ispathsep_nocolon(fname[2]))
|| (vim_ispathsep_nocolon(fname[0]) && fname[0] == fname[1]));
// /foo and \foo are absolute too because windows keeps a current drive.
return ((ASCII_ISALPHA(fname[0]) && fname[1] == ':' && vim_ispathsep_nocolon(fname[2]))
|| vim_ispathsep_nocolon(fname[0]));
#else
// UNIX: This just checks if the file name starts with '/' or '~'.
return *fname == '/' || *fname == '~';

View file

@ -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)

View file

@ -224,6 +224,39 @@ func Test_cd_completion()
call assert_equal('"' .. cmd .. ' XComplDir1/ XComplDir2/ XComplDir3/', @:)
endfor
set cdpath&
if has('win32')
" Test windows absolute path completion
" Retrieve a suitable dir in the current drive
let dir = readdir('/', 'isdirectory("/" .. v:val) && len(v:val) > 2')[-1]
" Get partial path
let partial = dir[0:-2]
" Get the current drive letter
let old = chdir('/' . dir)
let full = getcwd()
let drive = full[0]
call chdir(old)
for cmd in ['cd', 'chdir', 'lcd', 'lchdir', 'tcd', 'tchdir']
for sep in [ '/', '\']
" Explicit drive letter
call feedkeys(':' .. cmd .. ' ' .. drive .. ':' .. sep ..
\ partial .. "\<C-A>\<C-B>\"\<CR>", 'tx')
call assert_match(full, @:)
" Implicit drive letter
call feedkeys(':' .. cmd .. ' ' .. sep .. partial .. "\<C-A>\<C-B>\"\<CR>", 'tx')
call assert_match('/' .. dir .. '/', @:)
" UNC path
call feedkeys(':' .. cmd .. ' ' .. sep .. sep .. $COMPUTERNAME .. sep ..
\ drive .. '$' .. sep .. partial .."\<C-A>\<C-B>\"\<CR>", 'tx')
call assert_match('//' .. $COMPUTERNAME .. '/' .. drive .. '$/' .. dir .. '/' , @:)
endfor
endfor
endif
endfunc
func Test_cd_unknown_dir()

View file

@ -3727,7 +3727,8 @@ func Test_isabsolutepath()
call assert_true(isabsolutepath('A:\Foo'))
call assert_true(isabsolutepath('A:/Foo'))
call assert_false(isabsolutepath('A:Foo'))
call assert_false(isabsolutepath('\Windows'))
call assert_true(isabsolutepath('\Windows'))
call assert_true(isabsolutepath('/Windows'))
call assert_true(isabsolutepath('\\Server2\Share\Test\Foo.txt'))
else
call assert_true(isabsolutepath('/'))