diff options
Diffstat (limited to 'runtime/lua/vim/lsp')
-rw-r--r-- | runtime/lua/vim/lsp/buf.lua | 42 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/codelens.lua | 6 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/diagnostic.lua | 24 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/handlers.lua | 5 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/rpc.lua | 29 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/sync.lua | 381 | ||||
-rw-r--r-- | runtime/lua/vim/lsp/util.lua | 247 |
7 files changed, 512 insertions, 222 deletions
diff --git a/runtime/lua/vim/lsp/buf.lua b/runtime/lua/vim/lsp/buf.lua index 245f29943e..747d761730 100644 --- a/runtime/lua/vim/lsp/buf.lua +++ b/runtime/lua/vim/lsp/buf.lua @@ -247,22 +247,35 @@ end --- Renames all references to the symbol under the cursor. --- ---@param new_name (string) If not provided, the user will be prompted for a new ----name using |input()|. +---name using |vim.ui.input()|. function M.rename(new_name) - local params = util.make_position_params() + local opts = { + prompt = "New Name: " + } + + ---@private + local function on_confirm(input) + if not (input and #input > 0) then return end + local params = util.make_position_params() + params.newName = input + request('textDocument/rename', params) + end + local function prepare_rename(err, result) if err == nil and result == nil then vim.notify('nothing to rename', vim.log.levels.INFO) return end if result and result.placeholder then - new_name = new_name or npcall(vfn.input, "New Name: ", result.placeholder) + opts.default = result.placeholder + if not new_name then npcall(vim.ui.input, opts, on_confirm) end elseif result and result.start and result['end'] and result.start.line == result['end'].line then local line = vfn.getline(result.start.line+1) local start_char = result.start.character+1 local end_char = result['end'].character - new_name = new_name or npcall(vfn.input, "New Name: ", string.sub(line, start_char, end_char)) + opts.default = string.sub(line, start_char, end_char) + if not new_name then npcall(vim.ui.input, opts, on_confirm) end else -- fallback to guessing symbol using <cword> -- @@ -270,13 +283,12 @@ function M.rename(new_name) -- returns an unexpected response, or requests for "default behavior" -- -- see https://microsoft.github.io/language-server-protocol/specification#textDocument_prepareRename - new_name = new_name or npcall(vfn.input, "New Name: ", vfn.expand('<cword>')) + opts.default = vfn.expand('<cword>') + if not new_name then npcall(vim.ui.input, opts, on_confirm) end end - if not (new_name and #new_name > 0) then return end - params.newName = new_name - request('textDocument/rename', params) + if new_name then on_confirm(new_name) end end - request('textDocument/prepareRename', params, prepare_rename) + request('textDocument/prepareRename', util.make_position_params(), prepare_rename) end --- Lists all the references to the symbol under the cursor in the quickfix window. @@ -357,7 +369,7 @@ end function M.list_workspace_folders() local workspace_folders = {} for _, client in pairs(vim.lsp.buf_get_clients()) do - for _, folder in pairs(client.workspaceFolders) do + for _, folder in pairs(client.workspaceFolders or {}) do table.insert(workspace_folders, folder.name) end end @@ -377,7 +389,7 @@ function M.add_workspace_folder(workspace_folder) local params = util.make_workspace_params({{uri = vim.uri_from_fname(workspace_folder); name = workspace_folder}}, {{}}) for _, client in pairs(vim.lsp.buf_get_clients()) do local found = false - for _, folder in pairs(client.workspaceFolders) do + for _, folder in pairs(client.workspaceFolders or {}) do if folder.name == workspace_folder then found = true print(workspace_folder, "is already part of this workspace") @@ -386,6 +398,9 @@ function M.add_workspace_folder(workspace_folder) end if not found then vim.lsp.buf_notify(0, 'workspace/didChangeWorkspaceFolders', params) + if not client.workspaceFolders then + client.workspaceFolders = {} + end table.insert(client.workspaceFolders, params.event.added[1]) end end @@ -480,11 +495,11 @@ local function on_code_action_results(results, ctx) end if action.command then local command = type(action.command) == 'table' and action.command or action - local fn = vim.lsp.commands[command.command] + local fn = client.commands[command.command] or vim.lsp.commands[command.command] if fn then local enriched_ctx = vim.deepcopy(ctx) enriched_ctx.client_id = client.id - fn(command, ctx) + fn(command, enriched_ctx) else M.execute_command(command) end @@ -529,6 +544,7 @@ local function on_code_action_results(results, ctx) vim.ui.select(action_tuples, { prompt = 'Code actions:', + kind = 'codeaction', format_item = function(action_tuple) local title = action_tuple[2].title:gsub('\r\n', '\\r\\n') return title:gsub('\n', '\\n') diff --git a/runtime/lua/vim/lsp/codelens.lua b/runtime/lua/vim/lsp/codelens.lua index 63fcbe430b..9eb64c9a2e 100644 --- a/runtime/lua/vim/lsp/codelens.lua +++ b/runtime/lua/vim/lsp/codelens.lua @@ -31,15 +31,15 @@ local function execute_lens(lens, bufnr, client_id) local line = lens.range.start.line api.nvim_buf_clear_namespace(bufnr, namespaces[client_id], line, line + 1) + local client = vim.lsp.get_client_by_id(client_id) + assert(client, 'Client is required to execute lens, client_id=' .. client_id) local command = lens.command - local fn = vim.lsp.commands[command.command] + local fn = client.commands[command.command] or vim.lsp.commands[command.command] if fn then fn(command, { bufnr = bufnr, client_id = client_id }) return end -- Need to use the client that returned the lens → must not use buf_request - local client = vim.lsp.get_client_by_id(client_id) - assert(client, 'Client is required to execute lens, client_id=' .. client_id) local command_provider = client.server_capabilities.executeCommandProvider local commands = type(command_provider) == 'table' and command_provider.commands or {} if not vim.tbl_contains(commands, command.command) then diff --git a/runtime/lua/vim/lsp/diagnostic.lua b/runtime/lua/vim/lsp/diagnostic.lua index 0e63c0dd29..1e6f83c1ba 100644 --- a/runtime/lua/vim/lsp/diagnostic.lua +++ b/runtime/lua/vim/lsp/diagnostic.lua @@ -146,7 +146,8 @@ local _client_namespaces = {} function M.get_namespace(client_id) vim.validate { client_id = { client_id, 'n' } } if not _client_namespaces[client_id] then - local name = string.format("vim.lsp.client-%d", client_id) + local client = vim.lsp.get_client_by_id(client_id) + local name = string.format("vim.lsp.%s.%d", client and client.name or "unknown", client_id) _client_namespaces[client_id] = vim.api.nvim_create_namespace(name) end return _client_namespaces[client_id] @@ -551,14 +552,15 @@ end ---@param position table|nil The (0,0)-indexed position ---@return table {popup_bufnr, win_id} function M.show_position_diagnostics(opts, buf_nr, position) - if opts then - if opts.severity then - opts.severity = severity_lsp_to_vim(opts.severity) - elseif opts.severity_limit then - opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} - end + opts = opts or {} + opts.scope = "cursor" + opts.pos = position + if opts.severity then + opts.severity = severity_lsp_to_vim(opts.severity) + elseif opts.severity_limit then + opts.severity = {min=severity_lsp_to_vim(opts.severity_limit)} end - return vim.diagnostic.show_position_diagnostics(opts, buf_nr, position) + return vim.diagnostic.open_float(buf_nr, opts) end --- Open a floating window with the diagnostics from {line_nr} @@ -573,11 +575,13 @@ end ---@param client_id number|nil the client id ---@return table {popup_bufnr, win_id} function M.show_line_diagnostics(opts, buf_nr, line_nr, client_id) + opts = opts or {} + opts.scope = "line" + opts.pos = line_nr if client_id then - opts = opts or {} opts.namespace = M.get_namespace(client_id) end - return vim.diagnostic.show_line_diagnostics(opts, buf_nr, line_nr) + return vim.diagnostic.open_float(buf_nr, opts) end --- Redraw diagnostics for the given buffer and client diff --git a/runtime/lua/vim/lsp/handlers.lua b/runtime/lua/vim/lsp/handlers.lua index eff27807be..a561630c2b 100644 --- a/runtime/lua/vim/lsp/handlers.lua +++ b/runtime/lua/vim/lsp/handlers.lua @@ -28,6 +28,7 @@ local function progress_handler(_, result, ctx, _) local client_name = client and client.name or string.format("id=%d", client_id) if not client then err_message("LSP[", client_name, "] client has shut down after sending the message") + return end local val = result.value -- unspecified yet local token = result.token -- string or number @@ -185,7 +186,7 @@ local function response_to_list(map_result, entity) title = 'Language Server'; items = map_result(result, ctx.bufnr); }) - api.nvim_command("copen") + api.nvim_command("botright copen") end end end @@ -349,7 +350,7 @@ M['textDocument/signatureHelp'] = M.signature_help --see: https://microsoft.github.io/language-server-protocol/specifications/specification-current/#textDocument_documentHighlight M['textDocument/documentHighlight'] = function(_, result, ctx, _) if not result then return end - util.buf_highlight_references(ctx.bufnr, result) + util.buf_highlight_references(ctx.bufnr, result, ctx.client_id) end ---@private diff --git a/runtime/lua/vim/lsp/rpc.lua b/runtime/lua/vim/lsp/rpc.lua index d9a684a738..bce1e9f35d 100644 --- a/runtime/lua/vim/lsp/rpc.lua +++ b/runtime/lua/vim/lsp/rpc.lua @@ -297,6 +297,7 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) local message_index = 0 local message_callbacks = {} + local notify_reply_callbacks = {} local handle, pid do @@ -309,8 +310,9 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) stdout:close() stderr:close() handle:close() - -- Make sure that message_callbacks can be gc'd. + -- Make sure that message_callbacks/notify_reply_callbacks can be gc'd. message_callbacks = nil + notify_reply_callbacks = nil dispatchers.on_exit(code, signal) end local spawn_params = { @@ -375,10 +377,12 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) ---@param method (string) The invoked LSP method ---@param params (table) Parameters for the invoked LSP method ---@param callback (function) Callback to invoke + ---@param notify_reply_callback (function) Callback to invoke as soon as a request is no longer pending ---@returns (bool, number) `(true, message_id)` if request could be sent, `false` if not - local function request(method, params, callback) + local function request(method, params, callback, notify_reply_callback) validate { callback = { callback, 'f' }; + notify_reply_callback = { notify_reply_callback, 'f', true }; } message_index = message_index + 1 local message_id = message_index @@ -388,8 +392,15 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) method = method; params = params; } - if result and message_callbacks then - message_callbacks[message_id] = schedule_wrap(callback) + if result then + if message_callbacks then + message_callbacks[message_id] = schedule_wrap(callback) + else + return false + end + if notify_reply_callback and notify_reply_callbacks then + notify_reply_callbacks[message_id] = schedule_wrap(notify_reply_callback) + end return result, message_id else return false @@ -466,6 +477,16 @@ local function start(cmd, cmd_args, dispatchers, extra_spawn_params) -- We sent a number, so we expect a number. local result_id = tonumber(decoded.id) + -- Notify the user that a response was received for the request + local notify_reply_callback = notify_reply_callbacks and notify_reply_callbacks[result_id] + if notify_reply_callback then + validate { + notify_reply_callback = { notify_reply_callback, 'f' }; + } + notify_reply_callback(result_id) + notify_reply_callbacks[result_id] = nil + end + -- Do not surface RequestCancelled to users, it is RPC-internal. if decoded.error then local mute_error = false diff --git a/runtime/lua/vim/lsp/sync.lua b/runtime/lua/vim/lsp/sync.lua new file mode 100644 index 0000000000..37247c61b9 --- /dev/null +++ b/runtime/lua/vim/lsp/sync.lua @@ -0,0 +1,381 @@ +-- Notes on incremental sync: +-- Per the protocol, the text range should be: +-- +-- A position inside a document (see Position definition below) is expressed as +-- a zero-based line and character offset. The offsets are based on a UTF-16 +-- string representation. So a string of the form a𐐀b the character offset +-- of the character a is 0, the character offset of 𐐀 is 1 and the character +-- offset of b is 3 since 𐐀 is represented using two code units in UTF-16. +-- +-- To ensure that both client and server split the string into the same line +-- representation the protocol specifies the following end-of-line sequences: ‘\n’, ‘\r\n’ and ‘\r’. +-- +-- Positions are line end character agnostic. So you can not specify a position that +-- denotes \r|\n or \n| where | represents the character offset. This means *no* defining +-- a range than ends on the same line after a terminating character +-- +-- Generic warnings about byte level changes in neovim. Many apparently "single" +-- operations in on_lines callbacks are actually multiple operations. +-- +-- Join operation (2 operations): +-- * extends line 1 with the contents of line 2 +-- * deletes line 2 +-- +-- test 1 test 1 test 2 test 1 test 2 +-- test 2 -> test 2 -> test 3 +-- test 3 test 3 +-- +-- Deleting (and undoing) two middle lines (1 operation): +-- +-- test 1 test 1 +-- test 2 -> test 4 +-- test 3 +-- test 4 +-- +-- Deleting partial lines (5 operations) deleting between asterisks below: +-- +-- test *1 test * test * test * test *4 test *4* +-- test 2 -> test 2 -> test *4 -> *4 -> *4 -> +-- test 3 test 3 +-- test *4 test 4 + +local M = {} + +-- local string.byte, unclear if this is necessary for JIT compilation +local str_byte = string.byte +local min = math.min +local str_utfindex = vim.str_utfindex +local str_utf_start = vim.str_utf_start +local str_utf_end = vim.str_utf_end + +---@private +-- Given a line, byte idx, and offset_encoding convert to the +-- utf-8, utf-16, or utf-32 index. +---@param line string the line to index into +---@param byte integer the byte idx +---@param offset_encoding string utf-8|utf-16|utf-32|nil (default: utf-8) +--@returns integer the utf idx for the given encoding +local function byte_to_utf(line, byte, offset_encoding) + -- convert to 0 based indexing for str_utfindex + byte = byte - 1 + + local utf_idx + local _ + -- Convert the byte range to utf-{8,16,32} and convert 1-based (lua) indexing to 0-based + if offset_encoding == 'utf-16' then + _, utf_idx = str_utfindex(line, byte) + elseif offset_encoding == 'utf-32' then + utf_idx, _ = str_utfindex(line, byte) + else + utf_idx = byte + end + + -- convert to 1 based indexing + return utf_idx + 1 +end + +---@private +-- Given a line, byte idx, alignment, and offset_encoding convert to the aligned +-- utf-8 index and either the utf-16, or utf-32 index. +---@param line string the line to index into +---@param byte integer the byte idx +---@param align string when dealing with multibyte characters, +-- to choose the start of the current character or the beginning of the next. +-- Used for incremental sync for start/end range respectively +---@param offset_encoding string utf-8|utf-16|utf-32|nil (default: utf-8) +---@returns table<string, int> byte_idx and char_idx of first change position +local function align_position(line, byte, align, offset_encoding) + local char + -- If on the first byte, or an empty string: the trivial case + if byte == 1 or #line == 0 then + char = byte + -- Called in the case of extending an empty line "" -> "a" + elseif byte == #line + 1 then + byte = byte + str_utf_end(line, #line) + char = byte_to_utf(line, byte, offset_encoding) + else + -- Modifying line, find the nearest utf codepoint + if align == 'start' then + byte = byte + str_utf_start(line, byte) + char = byte_to_utf(line, byte, offset_encoding) + elseif align == 'end' then + local offset = str_utf_end(line, byte) + -- If the byte does not fall on the start of the character, then + -- align to the start of the next character. + if offset > 0 then + char = byte_to_utf(line, byte, offset_encoding) + 1 + byte = byte + offset + else + char = byte_to_utf(line, byte, offset_encoding) + byte = byte + offset + end + else + error('`align` must be start or end.') + end + -- Extending line, find the nearest utf codepoint for the last valid character + end + return byte, char +end + +---@private +--- Finds the first line, byte, and char index of the difference between the previous and current lines buffer normalized to the previous codepoint. +---@param prev_lines table list of lines from previous buffer +---@param curr_lines table list of lines from current buffer +---@param firstline integer firstline from on_lines, adjusted to 1-index +---@param lastline integer lastline from on_lines, adjusted to 1-index +---@param new_lastline integer new_lastline from on_lines, adjusted to 1-index +---@param offset_encoding string utf-8|utf-16|utf-32|nil (fallback to utf-8) +---@returns table<int, int> line_idx, byte_idx, and char_idx of first change position +local function compute_start_range(prev_lines, curr_lines, firstline, lastline, new_lastline, offset_encoding) + -- If firstline == lastline, no existing text is changed. All edit operations + -- occur on a new line pointed to by lastline. This occurs during insertion of + -- new lines(O), the new newline is inserted at the line indicated by + -- new_lastline. + -- If firstline == new_lastline, the first change occured on a line that was deleted. + -- In this case, the first byte change is also at the first byte of firstline + if firstline == new_lastline or firstline == lastline then + return { line_idx = firstline, byte_idx = 1, char_idx = 1 } + end + + local prev_line = prev_lines[firstline] + local curr_line = curr_lines[firstline] + + -- Iterate across previous and current line containing first change + -- to find the first different byte. + -- Note: *about -> a*about will register the second a as the first + -- difference, regardless of edit since we do not receive the first + -- column of the edit from on_lines. + local start_byte_idx = 1 + for idx = 1, #prev_line + 1 do + start_byte_idx = idx + if str_byte(prev_line, idx) ~= str_byte(curr_line, idx) then + break + end + end + + -- Convert byte to codepoint if applicable + local byte_idx, char_idx = align_position(prev_line, start_byte_idx, 'start', offset_encoding) + + -- Return the start difference (shared for new and prev lines) + return { line_idx = firstline, byte_idx = byte_idx, char_idx = char_idx } +end + +---@private +--- Finds the last line and byte index of the differences between prev and current buffer. +--- Normalized to the next codepoint. +--- prev_end_range is the text range sent to the server representing the changed region. +--- curr_end_range is the text that should be collected and sent to the server. +-- +---@param prev_lines table list of lines +---@param curr_lines table list of lines +---@param start_range table +---@param lastline integer +---@param new_lastline integer +---@param offset_encoding string +---@returns (int, int) end_line_idx and end_col_idx of range +local function compute_end_range(prev_lines, curr_lines, start_range, firstline, lastline, new_lastline, offset_encoding) + -- If firstline == new_lastline, the first change occured on a line that was deleted. + -- In this case, the last_byte... + if firstline == new_lastline then + return { line_idx = (lastline - new_lastline + firstline), byte_idx = 1, char_idx = 1 }, { line_idx = firstline, byte_idx = 1, char_idx = 1 } + end + if firstline == lastline then + return { line_idx = firstline, byte_idx = 1, char_idx = 1 }, { line_idx = new_lastline - lastline + firstline, byte_idx = 1, char_idx = 1 } + end + -- Compare on last line, at minimum will be the start range + local start_line_idx = start_range.line_idx + + -- lastline and new_lastline were last lines that were *not* replaced, compare previous lines + local prev_line_idx = lastline - 1 + local curr_line_idx = new_lastline - 1 + + local prev_line = prev_lines[lastline - 1] + local curr_line = curr_lines[new_lastline - 1] + + local prev_line_length = #prev_line + local curr_line_length = #curr_line + + local byte_offset = 0 + + -- Editing the same line + -- If the byte offset is zero, that means there is a difference on the last byte (not newline) + if prev_line_idx == curr_line_idx then + local max_length + if start_line_idx == prev_line_idx then + -- Search until beginning of difference + max_length = min(prev_line_length - start_range.byte_idx, curr_line_length - start_range.byte_idx) + 1 + else + max_length = min(prev_line_length, curr_line_length) + 1 + end + for idx = 0, max_length do + byte_offset = idx + if + str_byte(prev_line, prev_line_length - byte_offset) ~= str_byte(curr_line, curr_line_length - byte_offset) + then + break + end + end + end + + -- Iterate from end to beginning of shortest line + local prev_end_byte_idx = prev_line_length - byte_offset + 1 + local prev_byte_idx, prev_char_idx = align_position(prev_line, prev_end_byte_idx, 'start', offset_encoding) + local prev_end_range = { line_idx = prev_line_idx, byte_idx = prev_byte_idx, char_idx = prev_char_idx } + + local curr_end_range + -- Deletion event, new_range cannot be before start + if curr_line_idx < start_line_idx then + curr_end_range = { line_idx = start_line_idx, byte_idx = 1, char_idx = 1 } + else + local curr_end_byte_idx = curr_line_length - byte_offset + 1 + local curr_byte_idx, curr_char_idx = align_position(curr_line, curr_end_byte_idx, 'start', offset_encoding) + curr_end_range = { line_idx = curr_line_idx, byte_idx = curr_byte_idx, char_idx = curr_char_idx } + end + + return prev_end_range, curr_end_range +end + +---@private +--- Get the text of the range defined by start and end line/column +---@param lines table list of lines +---@param start_range table table returned by first_difference +---@param end_range table new_end_range returned by last_difference +---@returns string text extracted from defined region +local function extract_text(lines, start_range, end_range, line_ending) + if not lines[start_range.line_idx] then + return "" + end + -- Trivial case: start and end range are the same line, directly grab changed text + if start_range.line_idx == end_range.line_idx then + -- string.sub is inclusive, end_range is not + return string.sub(lines[start_range.line_idx], start_range.byte_idx, end_range.byte_idx - 1) + + else + -- Handle deletion case + -- Collect the changed portion of the first changed line + local result = { string.sub(lines[start_range.line_idx], start_range.byte_idx) } + + -- Collect the full line for intermediate lines + for idx = start_range.line_idx + 1, end_range.line_idx - 1 do + table.insert(result, lines[idx]) + end + + if lines[end_range.line_idx] then + -- Collect the changed portion of the last changed line. + table.insert(result, string.sub(lines[end_range.line_idx], 1, end_range.byte_idx - 1)) + else + table.insert(result, "") + end + + -- Add line ending between all lines + return table.concat(result, line_ending) + end +end + +local function compute_line_length(line, offset_encoding) + local length + local _ + if offset_encoding == 'utf-16' then + _, length = str_utfindex(line) + elseif offset_encoding == 'utf-32' then + length, _ = str_utfindex(line) + else + length = #line + end + return length +end +---@private +-- rangelength depends on the offset encoding +-- bytes for utf-8 (clangd with extenion) +-- codepoints for utf-16 +-- codeunits for utf-32 +-- Line endings count here as 2 chars for \r\n (dos), 1 char for \n (unix), and 1 char for \r (mac) +-- These correspond to Windows, Linux/macOS (OSX and newer), and macOS (version 9 and prior) +local function compute_range_length(lines, start_range, end_range, offset_encoding, line_ending) + local line_ending_length = #line_ending + -- Single line case + if start_range.line_idx == end_range.line_idx then + return end_range.char_idx - start_range.char_idx + end + + local start_line = lines[start_range.line_idx] + local range_length + if start_line and #start_line > 0 then + range_length = compute_line_length(start_line, offset_encoding) - start_range.char_idx + 1 + line_ending_length + else + -- Length of newline character + range_length = line_ending_length + end + + -- The first and last range of the line idx may be partial lines + for idx = start_range.line_idx + 1, end_range.line_idx - 1 do + -- Length full line plus newline character + if #lines[idx] > 0 then + range_length = range_length + compute_line_length(lines[idx], offset_encoding) + #line_ending + else + range_length = range_length + line_ending_length + end + end + + local end_line = lines[end_range.line_idx] + if end_line and #end_line > 0 then + range_length = range_length + end_range.char_idx - 1 + end + + return range_length +end + +--- Returns the range table for the difference between prev and curr lines +---@param prev_lines table list of lines +---@param curr_lines table list of lines +---@param firstline number line to begin search for first difference +---@param lastline number line to begin search in old_lines for last difference +---@param new_lastline number line to begin search in new_lines for last difference +---@param offset_encoding string encoding requested by language server +---@returns table TextDocumentContentChangeEvent see https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#textDocumentContentChangeEvent +function M.compute_diff(prev_lines, curr_lines, firstline, lastline, new_lastline, offset_encoding, line_ending) + -- Find the start of changes between the previous and current buffer. Common between both. + -- Sent to the server as the start of the changed range. + -- Used to grab the changed text from the latest buffer. + local start_range = compute_start_range( + prev_lines, + curr_lines, + firstline + 1, + lastline + 1, + new_lastline + 1, + offset_encoding + ) + -- Find the last position changed in the previous and current buffer. + -- prev_end_range is sent to the server as as the end of the changed range. + -- curr_end_range is used to grab the changed text from the latest buffer. + local prev_end_range, curr_end_range = compute_end_range( + prev_lines, + curr_lines, + start_range, + firstline + 1, + lastline + 1, + new_lastline + 1, + offset_encoding + ) + + -- Grab the changed text of from start_range to curr_end_range in the current buffer. + -- The text range is "" if entire range is deleted. + local text = extract_text(curr_lines, start_range, curr_end_range, line_ending) + + -- Compute the range of the replaced text. Deprecated but still required for certain language servers + local range_length = compute_range_length(prev_lines, start_range, prev_end_range, offset_encoding, line_ending) + + -- convert to 0 based indexing + local result = { + range = { + ['start'] = { line = start_range.line_idx - 1, character = start_range.char_idx - 1 }, + ['end'] = { line = prev_end_range.line_idx - 1, character = prev_end_range.char_idx - 1 }, + }, + text = text, + rangeLength = range_length, + } + + return result +end + +return M diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index 952926b67e..a4b7b9922b 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -151,7 +151,7 @@ end --- Position is a https://microsoft.github.io/language-server-protocol/specifications/specification-current/#position --- Returns a zero-indexed column, since set_lines() does the conversion to --- 1-indexed -local function get_line_byte_from_position(bufnr, position) +local function get_line_byte_from_position(bufnr, position, offset_encoding) -- LSP's line and characters are 0-indexed -- Vim's line and columns are 1-indexed local col = position.character @@ -165,7 +165,13 @@ local function get_line_byte_from_position(bufnr, position) local line = position.line local lines = api.nvim_buf_get_lines(bufnr, line, line + 1, false) if #lines > 0 then - local ok, result = pcall(vim.str_byteindex, lines[1], col) + local ok, result + + if offset_encoding == "utf-16" or not offset_encoding then + ok, result = pcall(vim.str_byteindex, lines[1], col, true) + elseif offset_encoding == "utf-32" then + ok, result = pcall(vim.str_byteindex, lines[1], col, false) + end if ok then return result @@ -226,9 +232,10 @@ function M.get_progress_messages() table.remove(client.messages, item.idx) end - for _, item in ipairs(progress_remove) do - client.messages.progress[item.token] = nil - end + end + + for _, item in ipairs(progress_remove) do + item.client.messages.progress[item.token] = nil end return new_messages @@ -275,7 +282,8 @@ function M.apply_text_edits(text_edits, bufnr) -- Some LSP servers may return +1 range of the buffer content but nvim_buf_set_text can't accept it so we should fix it here. local has_eol_text_edit = false local max = vim.api.nvim_buf_line_count(bufnr) - local len = vim.str_utfindex(vim.api.nvim_buf_get_lines(bufnr, -2, -1, false)[1] or '') + -- TODO handle offset_encoding + local _, len = vim.str_utfindex(vim.api.nvim_buf_get_lines(bufnr, -2, -1, false)[1] or '') text_edits = vim.tbl_map(function(text_edit) if max <= text_edit.range.start.line then text_edit.range.start.line = max - 1 @@ -359,177 +367,6 @@ end -- function M.glob_to_regex(glob) -- end ----@private ---- Finds the first line and column of the difference between old and new lines ----@param old_lines table list of lines ----@param new_lines table list of lines ----@returns (int, int) start_line_idx and start_col_idx of range -local function first_difference(old_lines, new_lines, start_line_idx) - local line_count = math.min(#old_lines, #new_lines) - if line_count == 0 then return 1, 1 end - if not start_line_idx then - for i = 1, line_count do - start_line_idx = i - if old_lines[start_line_idx] ~= new_lines[start_line_idx] then - break - end - end - end - local old_line = old_lines[start_line_idx] - local new_line = new_lines[start_line_idx] - local length = math.min(#old_line, #new_line) - local start_col_idx = 1 - while start_col_idx <= length do - if string.sub(old_line, start_col_idx, start_col_idx) ~= string.sub(new_line, start_col_idx, start_col_idx) then - break - end - start_col_idx = start_col_idx + 1 - end - return start_line_idx, start_col_idx -end - - ----@private ---- Finds the last line and column of the differences between old and new lines ----@param old_lines table list of lines ----@param new_lines table list of lines ----@param start_char integer First different character idx of range ----@returns (int, int) end_line_idx and end_col_idx of range -local function last_difference(old_lines, new_lines, start_char, end_line_idx) - local line_count = math.min(#old_lines, #new_lines) - if line_count == 0 then return 0,0 end - if not end_line_idx then - end_line_idx = -1 - end - for i = end_line_idx, -line_count, -1 do - if old_lines[#old_lines + i + 1] ~= new_lines[#new_lines + i + 1] then - end_line_idx = i - break - end - end - local old_line - local new_line - if end_line_idx <= -line_count then - end_line_idx = -line_count - old_line = string.sub(old_lines[#old_lines + end_line_idx + 1], start_char) - new_line = string.sub(new_lines[#new_lines + end_line_idx + 1], start_char) - else - old_line = old_lines[#old_lines + end_line_idx + 1] - new_line = new_lines[#new_lines + end_line_idx + 1] - end - local old_line_length = #old_line - local new_line_length = #new_line - local length = math.min(old_line_length, new_line_length) - local end_col_idx = -1 - while end_col_idx >= -length do - local old_char = string.sub(old_line, old_line_length + end_col_idx + 1, old_line_length + end_col_idx + 1) - local new_char = string.sub(new_line, new_line_length + end_col_idx + 1, new_line_length + end_col_idx + 1) - if old_char ~= new_char then - break - end - end_col_idx = end_col_idx - 1 - end - return end_line_idx, end_col_idx - -end - ----@private ---- Get the text of the range defined by start and end line/column ----@param lines table list of lines ----@param start_char integer First different character idx of range ----@param end_char integer Last different character idx of range ----@param start_line integer First different line idx of range ----@param end_line integer Last different line idx of range ----@returns string text extracted from defined region -local function extract_text(lines, start_line, start_char, end_line, end_char) - if start_line == #lines + end_line + 1 then - if end_line == 0 then return '' end - local line = lines[start_line] - local length = #line + end_char - start_char - return string.sub(line, start_char, start_char + length + 1) - end - local result = string.sub(lines[start_line], start_char) .. '\n' - for line_idx = start_line + 1, #lines + end_line do - result = result .. lines[line_idx] .. '\n' - end - if end_line ~= 0 then - local line = lines[#lines + end_line + 1] - local length = #line + end_char + 1 - result = result .. string.sub(line, 1, length) - end - return result -end - ----@private ---- Compute the length of the substituted range ----@param lines table list of lines ----@param start_char integer First different character idx of range ----@param end_char integer Last different character idx of range ----@param start_line integer First different line idx of range ----@param end_line integer Last different line idx of range ----@returns (int, int) end_line_idx and end_col_idx of range -local function compute_length(lines, start_line, start_char, end_line, end_char) - local adj_end_line = #lines + end_line + 1 - local adj_end_char - if adj_end_line > #lines then - adj_end_char = end_char - 1 - else - adj_end_char = #lines[adj_end_line] + end_char - end - if start_line == adj_end_line then - return adj_end_char - start_char + 1 - end - local result = #lines[start_line] - start_char + 1 - for line = start_line + 1, adj_end_line -1 do - result = result + #lines[line] + 1 - end - result = result + adj_end_char + 1 - return result -end - ---- Returns the range table for the difference between old and new lines ----@param old_lines table list of lines ----@param new_lines table list of lines ----@param start_line_idx int line to begin search for first difference ----@param end_line_idx int line to begin search for last difference ----@param offset_encoding string encoding requested by language server ----@returns table start_line_idx and start_col_idx of range -function M.compute_diff(old_lines, new_lines, start_line_idx, end_line_idx, offset_encoding) - local start_line, start_char = first_difference(old_lines, new_lines, start_line_idx) - local end_line, end_char = last_difference(vim.list_slice(old_lines, start_line, #old_lines), - vim.list_slice(new_lines, start_line, #new_lines), start_char, end_line_idx) - local text = extract_text(new_lines, start_line, start_char, end_line, end_char) - local length = compute_length(old_lines, start_line, start_char, end_line, end_char) - - local adj_end_line = #old_lines + end_line - local adj_end_char - if end_line == 0 then - adj_end_char = 0 - else - adj_end_char = #old_lines[#old_lines + end_line + 1] + end_char + 1 - end - - local _ - if offset_encoding == "utf-16" then - _, start_char = vim.str_utfindex(old_lines[start_line], start_char - 1) - _, end_char = vim.str_utfindex(old_lines[#old_lines + end_line + 1], adj_end_char) - else - start_char = start_char - 1 - end_char = adj_end_char - end - - local result = { - range = { - start = { line = start_line - 1, character = start_char}, - ["end"] = { line = adj_end_line, character = end_char} - }, - text = text, - rangeLength = length + 1, - } - - return result -end - --- Can be used to extract the completion items from a --- `textDocument/completion` request, which may return one of --- `CompletionItem[]`, `CompletionList` or null. @@ -712,18 +549,29 @@ end -- ignoreIfExists? bool function M.rename(old_fname, new_fname, opts) opts = opts or {} - local bufnr = vim.fn.bufadd(old_fname) - vim.fn.bufload(bufnr) local target_exists = vim.loop.fs_stat(new_fname) ~= nil if target_exists and not opts.overwrite or opts.ignoreIfExists then vim.notify('Rename target already exists. Skipping rename.') return end + local oldbuf = vim.fn.bufadd(old_fname) + vim.fn.bufload(oldbuf) + + -- The there may be pending changes in the buffer + api.nvim_buf_call(oldbuf, function() + vim.cmd('w!') + end) + local ok, err = os.rename(old_fname, new_fname) assert(ok, err) - api.nvim_buf_call(bufnr, function() - vim.cmd('saveas! ' .. vim.fn.fnameescape(new_fname)) - end) + + local newbuf = vim.fn.bufadd(new_fname) + for _, win in pairs(api.nvim_list_wins()) do + if api.nvim_win_get_buf(win) == oldbuf then + api.nvim_win_set_buf(win, newbuf) + end + end + api.nvim_buf_delete(oldbuf, { force = true }) end @@ -1494,18 +1342,30 @@ do --[[ References ]] ---@param bufnr buffer id ---@param references List of `DocumentHighlight` objects to highlight ---@see https://microsoft.github.io/language-server-protocol/specifications/specification-3-17/#documentHighlight - function M.buf_highlight_references(bufnr, references) + function M.buf_highlight_references(bufnr, references, client_id) validate { bufnr = {bufnr, 'n', true} } + local client = vim.lsp.get_client_by_id(client_id) + if not client then + return + end for _, reference in ipairs(references) do - local start_pos = {reference["range"]["start"]["line"], reference["range"]["start"]["character"]} - local end_pos = {reference["range"]["end"]["line"], reference["range"]["end"]["character"]} + local start_line, start_char = reference["range"]["start"]["line"], reference["range"]["start"]["character"] + local end_line, end_char = reference["range"]["end"]["line"], reference["range"]["end"]["character"] + + local start_idx = get_line_byte_from_position(bufnr, { line = start_line, character = start_char }, client.offset_encoding) + local end_idx = get_line_byte_from_position(bufnr, { line = start_line, character = end_char }, client.offset_encoding) + local document_highlight_kind = { [protocol.DocumentHighlightKind.Text] = "LspReferenceText"; [protocol.DocumentHighlightKind.Read] = "LspReferenceRead"; [protocol.DocumentHighlightKind.Write] = "LspReferenceWrite"; } local kind = reference["kind"] or protocol.DocumentHighlightKind.Text - highlight.range(bufnr, reference_ns, document_highlight_kind[kind], start_pos, end_pos) + highlight.range(bufnr, + reference_ns, + document_highlight_kind[kind], + { start_line, start_idx }, + { end_line, end_idx }) end end end @@ -1719,7 +1579,9 @@ function M.symbols_to_items(symbols, bufnr) }) if symbol.children then for _, v in ipairs(_symbols_to_items(symbol.children, _items, _bufnr)) do - vim.list_extend(_items, v) + for _, s in ipairs(v) do + table.insert(_items, s) + end end end end @@ -1787,7 +1649,9 @@ local function make_position_param() if not line then return { line = 0; character = 0; } end - col = str_utfindex(line, col) + -- TODO handle offset_encoding + local _ + _, col = str_utfindex(line, col) return { line = row; character = col; } end @@ -1837,11 +1701,14 @@ function M.make_given_range_params(start_pos, end_pos) A[1] = A[1] - 1 B[1] = B[1] - 1 -- account for encoding. + -- TODO handle offset_encoding if A[2] > 0 then - A = {A[1], M.character_offset(0, A[1], A[2])} + local _, char = M.character_offset(0, A[1], A[2]) + A = {A[1], char} end if B[2] > 0 then - B = {B[1], M.character_offset(0, B[1], B[2])} + local _, char = M.character_offset(0, B[1], B[2]) + B = {B[1], char} end -- we need to offset the end character position otherwise we loose the last -- character of the selection, as LSP end position is exclusive |