diff --git a/runtime/lua/vscode/util.lua b/runtime/lua/vscode/util.lua index 3d4576e..79ae8f9 100644 --- a/runtime/lua/vscode/util.lua +++ b/runtime/lua/vscode/util.lua @@ -63,6 +63,17 @@ function M.utf16_col(text, byte_col) return byte_col == 0 and 0 or str_utf16index(text, byte_col) end +---@param text string the line the column belongs to +---@param byte_col integer zero-indexed byte column, clamped to `text` +---@return integer zero-indexed byte column just past the character byte_col points into +local function byte_after_char(text, byte_col) + byte_col = math.max(0, math.min(byte_col, #text)) + if byte_col == #text then + return byte_col + end + return byte_col + vim.str_utf_end(text, byte_col + 1) + 1 +end + ---@param buf integer ---@param line integer one-indexed line ---@param byte_col integer zero-indexed byte column @@ -84,10 +95,11 @@ end ---@return lsp.Range function M.lsp_range(buf, start_line, start_col, end_line, end_col) local end_text = M.get_line(buf, end_line - 1) or "" - local end_char = M.utf16_col(end_text, end_col) if vim.o.selection ~= "exclusive" then - end_char = math.min(end_char + 1, M.utf16_col(end_text, #end_text)) + -- step over the whole character: adding one UTF-16 unit splits a surrogate pair + end_col = byte_after_char(end_text, end_col) end + local end_char = M.utf16_col(end_text, end_col) return { start = M.lsp_position(buf, start_line, start_col), ["end"] = { line = end_line - 1, character = end_char }, diff --git a/src/test/integ/multi-byte-chars.test.ts b/src/test/integ/multi-byte-chars.test.ts index 680bfaf..7db3dae 100644 --- a/src/test/integ/multi-byte-chars.test.ts +++ b/src/test/integ/multi-byte-chars.test.ts @@ -1,4 +1,5 @@ import { NeovimClient } from "neovim"; +import vscode from "vscode"; import { attachTestNvimClient, @@ -286,4 +287,54 @@ describe("Multi-width characters", () => { client, ); }); + + it("Works - visual mode over chars outside the BMP", async () => { + // ๐•œ is U+1D55C: one Nvim character, but two UTF-16 units for VSCode + await openTextDocument({ content: ["a๐•œb"].join("\n") }); + + await assertContent({ content: ["a๐•œb"], cursor: [0, 0] }, client); + + await sendVSCodeKeys("v"); + await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 1)] }, client); + + await sendVSCodeKeys("l"); + await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 3)] }, client); + + await sendVSCodeKeys("l"); + await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 4)] }, client); + + await sendEscapeKey(); + }); + + it("Works - visual mode over consecutive non-BMP chars", async () => { + await openTextDocument({ content: ["a๐•œ๐•b"].join("\n") }); + + await sendVSCodeKeys("v"); + await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 1)] }, client); + + await sendVSCodeKeys("ll"); + await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 5)] }, client); + + await sendVSCodeKeys("l"); + await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 6)] }, client); + + await sendEscapeKey(); + }); + + it("Works - visual mode across mixed BMP and non-BMP chars", async () => { + await openTextDocument({ content: ["aแตฉ๐•œb"].join("\n") }); + + await sendVSCodeKeys("v"); + await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 1)] }, client); + + // the cursor sits on the astral char, whose two UTF-16 units both belong to + // the selection: a, the modifier letter and the pair take offsets 0 to 4 + await sendVSCodeKeys("ll"); + await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 4)] }, client); + + await sendVSCodeKeys("l"); + await assertContent({ vsCodeSelections: [new vscode.Selection(0, 0, 0, 5)] }, client); + + await sendEscapeKey(); + }); });