summaryrefslogtreecommitdiff
path: root/lua/blink/cmp/lib/text_edits.lua
diff options
context:
space:
mode:
Diffstat (limited to 'lua/blink/cmp/lib/text_edits.lua')
-rw-r--r--lua/blink/cmp/lib/text_edits.lua193
1 files changed, 193 insertions, 0 deletions
diff --git a/lua/blink/cmp/lib/text_edits.lua b/lua/blink/cmp/lib/text_edits.lua
new file mode 100644
index 0000000..2ce76fe
--- /dev/null
+++ b/lua/blink/cmp/lib/text_edits.lua
@@ -0,0 +1,193 @@
+local config = require('blink.cmp.config')
+local context = require('blink.cmp.completion.trigger.context')
+
+local text_edits = {}
+
+--- Applies one or more text edits to the current buffer, assuming utf-8 encoding
+--- @param edits lsp.TextEdit[]
+function text_edits.apply(edits)
+ local mode = context.get_mode()
+ if mode == 'default' then return vim.lsp.util.apply_text_edits(edits, vim.api.nvim_get_current_buf(), 'utf-8') end
+
+ assert(mode == 'cmdline', 'Unsupported mode for text edits: ' .. mode)
+ assert(#edits == 1, 'Cmdline mode only supports one text edit. Contributions welcome!')
+
+ local edit = edits[1]
+ local line = context.get_line()
+ local edited_line = line:sub(1, edit.range.start.character)
+ .. edit.newText
+ .. line:sub(edit.range['end'].character + 1)
+ -- FIXME: for some reason, we have to set the cursor here, instead of later,
+ -- because this will override the cursor position set later
+ vim.fn.setcmdline(edited_line, edit.range.start.character + #edit.newText + 1)
+end
+
+------- Undo -------
+
+--- Gets the reverse of the text edit, must be called before applying
+--- @param text_edit lsp.TextEdit
+--- @return lsp.TextEdit
+function text_edits.get_undo_text_edit(text_edit)
+ return {
+ range = text_edits.get_undo_range(text_edit),
+ newText = text_edits.get_text_to_replace(text_edit),
+ }
+end
+
+--- Gets the range for undoing an applied text edit
+--- @param text_edit lsp.TextEdit
+function text_edits.get_undo_range(text_edit)
+ text_edit = vim.deepcopy(text_edit)
+ local lines = vim.split(text_edit.newText, '\n')
+ local last_line_len = lines[#lines] and #lines[#lines] or 0
+
+ local range = text_edit.range
+ range['end'].line = range.start.line + #lines - 1
+ range['end'].character = #lines > 1 and last_line_len or range.start.character + last_line_len
+
+ return range
+end
+
+--- Gets the text the text edit will replace
+--- @param text_edit lsp.TextEdit
+--- @return string
+function text_edits.get_text_to_replace(text_edit)
+ local lines = {}
+ for line = text_edit.range.start.line, text_edit.range['end'].line do
+ local line_text = context.get_line()
+ local is_start_line = line == text_edit.range.start.line
+ local is_end_line = line == text_edit.range['end'].line
+
+ if is_start_line and is_end_line then
+ table.insert(lines, line_text:sub(text_edit.range.start.character + 1, text_edit.range['end'].character))
+ elseif is_start_line then
+ table.insert(lines, line_text:sub(text_edit.range.start.character + 1))
+ elseif is_end_line then
+ table.insert(lines, line_text:sub(1, text_edit.range['end'].character))
+ else
+ table.insert(lines, line_text)
+ end
+ end
+ return table.concat(lines, '\n')
+end
+
+------- Get -------
+
+--- Grabbed from vim.lsp.utils. Converts an offset_encoding to byte offset
+--- @param position lsp.Position
+--- @param offset_encoding? 'utf-8'|'utf-16'|'utf-32'
+--- @return number
+local function get_line_byte_from_position(position, offset_encoding)
+ local bufnr = vim.api.nvim_get_current_buf()
+ local col = position.character
+
+ -- When on the first character, we can ignore the difference between byte and character
+ if col == 0 then return 0 end
+
+ local line = vim.api.nvim_buf_get_lines(bufnr, position.line, position.line + 1, false)[1] or ''
+ if vim.fn.has('nvim-0.11.0') == 1 then
+ col = vim.str_byteindex(line, offset_encoding or 'utf-16', col, false) or 0
+ else
+ col = vim.lsp.util._str_byteindex_enc(line, col, offset_encoding or 'utf-16')
+ end
+ return math.min(col, #line)
+end
+
+--- Gets the text edit from an item, handling insert/replace ranges and converts
+--- offset encodings (utf-16 | utf-32) to utf-8
+--- @param item blink.cmp.CompletionItem
+--- @return lsp.TextEdit
+function text_edits.get_from_item(item)
+ local text_edit = vim.deepcopy(item.textEdit)
+
+ -- Guess the text edit if the item doesn't define it
+ if text_edit == nil then return text_edits.guess(item) end
+
+ -- FIXME: temporarily convert insertReplaceEdit to regular textEdit
+ if text_edit.range == nil then
+ if config.completion.keyword.range == 'full' and text_edit.replace ~= nil then
+ text_edit.range = text_edit.replace
+ else
+ text_edit.range = text_edit.insert or text_edit.replace
+ end
+ end
+ text_edit.insert = nil
+ text_edit.replace = nil
+ --- @cast text_edit lsp.TextEdit
+
+ -- Adjust the position of the text edit to be the current cursor position
+ -- since the data might be outdated. We compare the cursor column position
+ -- from when the items were fetched versus the current.
+ -- HACK: is there a better way?
+ -- TODO: take into account the offset_encoding
+ local offset = context.get_cursor()[2] - item.cursor_column
+ text_edit.range['end'].character = text_edit.range['end'].character + offset
+
+ -- convert the offset encoding to utf-8
+ -- TODO: we have to do this last because it applies a max on the position based on the length of the line
+ -- so it would break the offset code when removing characters at the end of the line
+ local offset_encoding = text_edits.offset_encoding_from_item(item)
+ text_edit = text_edits.to_utf_8(text_edit, offset_encoding)
+
+ text_edit.range = text_edits.clamp_range_to_bounds(text_edit.range)
+
+ return text_edit
+end
+
+function text_edits.offset_encoding_from_item(item)
+ local client = vim.lsp.get_client_by_id(item.client_id)
+ return client ~= nil and client.offset_encoding or 'utf-8'
+end
+
+function text_edits.to_utf_8(text_edit, offset_encoding)
+ if offset_encoding == 'utf-8' then return text_edit end
+ text_edit = vim.deepcopy(text_edit)
+ text_edit.range.start.character = get_line_byte_from_position(text_edit.range.start, offset_encoding)
+ text_edit.range['end'].character = get_line_byte_from_position(text_edit.range['end'], offset_encoding)
+ return text_edit
+end
+
+--- Uses the keyword_regex to guess the text edit ranges
+--- @param item blink.cmp.CompletionItem
+--- TODO: doesnt work when the item contains characters not included in the context regex
+function text_edits.guess(item)
+ local word = item.insertText or item.label
+
+ local start_col, end_col = require('blink.cmp.fuzzy').guess_edit_range(
+ item,
+ context.get_line(),
+ context.get_cursor()[2],
+ config.completion.keyword.range
+ )
+ local current_line = context.get_cursor()[1]
+
+ -- convert to 0-index
+ return {
+ range = {
+ start = { line = current_line - 1, character = start_col },
+ ['end'] = { line = current_line - 1, character = end_col },
+ },
+ newText = word,
+ }
+end
+
+--- Clamps the range to the bounds of their respective lines
+--- @param range lsp.Range
+--- @return lsp.Range
+--- TODO: clamp start and end lines
+function text_edits.clamp_range_to_bounds(range)
+ range = vim.deepcopy(range)
+
+ local start_line = context.get_line(range.start.line)
+ range.start.character = math.min(math.max(range.start.character, 0), #start_line)
+
+ local end_line = context.get_line(range['end'].line)
+ range['end'].character = math.min(
+ math.max(range['end'].character, range.start.line == range['end'].line and range.start.character or 0),
+ #end_line
+ )
+
+ return range
+end
+
+return text_edits