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.
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:
- "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()|
@ -89,6 +89,8 @@ These GLOBAL keymaps are created unconditionally when Nvim starts:
- "gri" is mapped in Normal mode to |vim.lsp.buf.implementation()|
- "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()|
- "an" and "in" are mapped in Visual mode to outer and inner incremental
selections, respectively, using |vim.lsp.buf.selection_range()|
BUFFER-LOCAL DEFAULTS
- '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.
• {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()*
Displays signature information about the symbol under the cursor in a
floating window.

View File

@ -167,6 +167,8 @@ LSP
"resolving" it).
• Support for `workspace/diagnostic`: |vim.lsp.buf.workspace_diagnostics()|
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

View File

@ -209,6 +209,14 @@ do
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.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)
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

View File

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