feat(lsp): incremental selection via "textDocument/selectionRange" #34011

Select outwards with "an" and inwards with "in" in Visual mode.
Ranges are reset when leaving Visual mode.
This commit is contained in:
Riley Bruins
2025-06-12 09:25:19 -07:00
committed by GitHub
parent a9b8a8dc6c
commit f99e3a8a2a
5 changed files with 141 additions and 1 deletions

View File

@@ -81,7 +81,7 @@ the options are empty or were set by the builtin runtime (ftplugin) files. The
options are not restored when the LSP client is stopped or detached. options are not restored when the LSP client is stopped or detached.
GLOBAL DEFAULTS GLOBAL DEFAULTS
*grr* *gra* *grn* *gri* *i_CTRL-S* *grr* *gra* *grn* *gri* *i_CTRL-S* *an* *in*
These GLOBAL keymaps are created unconditionally when Nvim starts: These GLOBAL keymaps are created unconditionally when Nvim starts:
- "grn" is mapped in Normal mode to |vim.lsp.buf.rename()| - "grn" is mapped in Normal mode to |vim.lsp.buf.rename()|
- "gra" is mapped in Normal and Visual mode to |vim.lsp.buf.code_action()| - "gra" is mapped in Normal and Visual mode to |vim.lsp.buf.code_action()|
@@ -89,6 +89,8 @@ These GLOBAL keymaps are created unconditionally when Nvim starts:
- "gri" is mapped in Normal mode to |vim.lsp.buf.implementation()| - "gri" is mapped in Normal mode to |vim.lsp.buf.implementation()|
- "gO" is mapped in Normal mode to |vim.lsp.buf.document_symbol()| - "gO" is mapped in Normal mode to |vim.lsp.buf.document_symbol()|
- CTRL-S is mapped in Insert mode to |vim.lsp.buf.signature_help()| - CTRL-S is mapped in Insert mode to |vim.lsp.buf.signature_help()|
- "an" and "in" are mapped in Visual mode to outer and inner incremental
selections, respectively, using |vim.lsp.buf.selection_range()|
BUFFER-LOCAL DEFAULTS BUFFER-LOCAL DEFAULTS
- 'omnifunc' is set to |vim.lsp.omnifunc()|, use |i_CTRL-X_CTRL-O| to trigger - 'omnifunc' is set to |vim.lsp.omnifunc()|, use |i_CTRL-X_CTRL-O| to trigger
@@ -1833,6 +1835,14 @@ rename({new_name}, {opts}) *vim.lsp.buf.rename()*
ones where client.name matches this field. ones where client.name matches this field.
• {bufnr}? (`integer`) (default: current buffer) • {bufnr}? (`integer`) (default: current buffer)
selection_range({direction}) *vim.lsp.buf.selection_range()*
Perform an incremental selection at the cursor position based on ranges
given by the LSP. The `direction` parameter specifies whether the
selection should head inward or outward.
Parameters: ~
• {direction} (`'inner'|'outer'`)
signature_help({config}) *vim.lsp.buf.signature_help()* signature_help({config}) *vim.lsp.buf.signature_help()*
Displays signature information about the symbol under the cursor in a Displays signature information about the symbol under the cursor in a
floating window. floating window.

View File

@@ -167,6 +167,8 @@ LSP
"resolving" it). "resolving" it).
• Support for `workspace/diagnostic`: |vim.lsp.buf.workspace_diagnostics()| • Support for `workspace/diagnostic`: |vim.lsp.buf.workspace_diagnostics()|
https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_dagnostics https://microsoft.github.io/language-server-protocol/specifications/specification-current/#workspace_dagnostics
• Incremental selection is now supported via `textDocument/selectionRange`.
`an` selects outwards and `in` selects inwards.
LUA LUA

View File

@@ -209,6 +209,14 @@ do
vim.lsp.buf.implementation() vim.lsp.buf.implementation()
end, { desc = 'vim.lsp.buf.implementation()' }) end, { desc = 'vim.lsp.buf.implementation()' })
vim.keymap.set('x', 'an', function()
vim.lsp.buf.selection_range('outer')
end, { desc = "vim.lsp.buf.selection_range('outer')" })
vim.keymap.set('x', 'in', function()
vim.lsp.buf.selection_range('inner')
end, { desc = "vim.lsp.buf.selection_range('inner')" })
vim.keymap.set('n', 'gO', function() vim.keymap.set('n', 'gO', function()
vim.lsp.buf.document_symbol() vim.lsp.buf.document_symbol()
end, { desc = 'vim.lsp.buf.document_symbol()' }) end, { desc = 'vim.lsp.buf.document_symbol()' })

View File

@@ -1327,4 +1327,121 @@ function M.execute_command(command_params)
lsp.buf_request(0, ms.workspace_executeCommand, command_params) lsp.buf_request(0, ms.workspace_executeCommand, command_params)
end end
---@type { index: integer, ranges: lsp.Range[] }?
local selection_ranges = nil
---@param range lsp.Range
local function select_range(range)
local start_line = range.start.line + 1
local end_line = range['end'].line + 1
local start_col = range.start.character
local end_col = range['end'].character
-- If the selection ends at column 0, adjust the position to the end of the previous line.
if end_col == 0 then
end_line = end_line - 1
local end_line_text = api.nvim_buf_get_lines(0, end_line - 1, end_line, true)[1]
end_col = #end_line_text
end
vim.fn.setpos("'<", { 0, start_line, start_col + 1, 0 })
vim.fn.setpos("'>", { 0, end_line, end_col, 0 })
vim.cmd.normal({ 'gv', bang = true })
end
---@param range lsp.Range
local function is_empty(range)
return range.start.line == range['end'].line and range.start.character == range['end'].character
end
--- Perform an incremental selection at the cursor position based on ranges given by the LSP. The
--- `direction` parameter specifies whether the selection should head inward or outward.
---
--- @param direction 'inner' | 'outer'
function M.selection_range(direction)
if selection_ranges then
local offset = direction == 'outer' and 1 or -1
local new_index = selection_ranges.index + offset
if new_index <= #selection_ranges.ranges and new_index >= 1 then
selection_ranges.index = new_index
end
select_range(selection_ranges.ranges[selection_ranges.index])
return
end
local method = ms.textDocument_selectionRange
local client = lsp.get_clients({ method = method, bufnr = 0 })[1]
if not client then
vim.notify(lsp._unsupported_method(method), vim.log.levels.WARN)
return
end
local position_params = util.make_position_params(0, client.offset_encoding)
---@type lsp.SelectionRangeParams
local params = {
textDocument = position_params.textDocument,
positions = { position_params.position },
}
lsp.buf_request(
0,
ms.textDocument_selectionRange,
params,
---@param response lsp.SelectionRange[]?
function(err, response)
if err then
lsp.log.error(err.code, err.message)
return
end
if not response then
return
end
-- We only requested one range, thus we get the first and only reponse here.
response = response[1]
local ranges = {} ---@type lsp.Range[]
local lines = api.nvim_buf_get_lines(0, 0, -1, false)
-- Populate the list of ranges from the given request.
while response do
local range = response.range
if not is_empty(range) then
local start_line = range.start.line
local end_line = range['end'].line
range.start.character = vim.str_byteindex(
lines[start_line + 1] or '',
client.offset_encoding,
range.start.character,
false
)
range['end'].character = vim.str_byteindex(
lines[end_line + 1] or '',
client.offset_encoding,
range['end'].character,
false
)
ranges[#ranges + 1] = range
end
response = response.parent
end
-- Clear selection ranges when leaving visual mode.
api.nvim_create_autocmd('ModeChanged', {
once = true,
pattern = 'v*:*',
callback = function()
selection_ranges = nil
end,
})
if #ranges > 0 then
selection_ranges = { index = 1, ranges = ranges }
select_range(ranges[1])
end
end
)
end
return M return M

View File

@@ -539,6 +539,9 @@ function protocol.make_client_capabilities()
colorProvider = { colorProvider = {
dynamicRegistration = true, dynamicRegistration = true,
}, },
selectionRange = {
dynamicRegistration = false,
},
}, },
workspace = { workspace = {
symbol = { symbol = {