From c0394e311cbdae38314faccf18cd66025989a3f8 Mon Sep 17 00:00:00 2001 From: upstream Date: Fri, 11 Sep 2026 23:53:04 +0200 Subject: [PATCH] Upstream vscode-neovim/vscode-neovim state of the touched files before 6a6303a21d --- runtime/lua/vscode/util.lua | 168 ++++++++++++++ src/test/integ/multi-byte-chars.test.ts | 289 ++++++++++++++++++++++++ 2 files changed, 457 insertions(+) create mode 100644 runtime/lua/vscode/util.lua create mode 100644 src/test/integ/multi-byte-chars.test.ts diff --git a/runtime/lua/vscode/util.lua b/runtime/lua/vscode/util.lua new file mode 100644 index 0000000..6a8fd76 --- /dev/null +++ b/runtime/lua/vscode/util.lua @@ -0,0 +1,168 @@ +local M = {} + +local fn, api = vim.fn, vim.api + +function M.is_visual_mode() + local mode = api.nvim_get_mode().mode + return mode == "v" or mode == "V" or mode == "\x16" +end + +function M.get_char_at(line, byte_col, buf) + if not buf or buf == 0 then + buf = api.nvim_get_current_buf() + end + local line_str = fn.getbufoneline(buf, line) + local char_idx = fn.charidx(line_str, (byte_col - 1)) + local char_nr = fn.strgetchar(line_str, char_idx) + if char_nr ~= -1 then + return fn.nr2char(char_nr) + else + return nil + end +end + +--- Gets the zero-indexed lines from the given buffer. +--- +---@param bufnr integer bufnr to get the lines from +---@param rows integer[] zero-indexed line numbers +---@return table a table mapping rows to lines +function M.get_lines(bufnr, rows) + rows = type(rows) == "table" and rows or { rows } + + local lines = {} + for _, row in ipairs(rows) do + lines[row] = (api.nvim_buf_get_lines(bufnr, row, row + 1, false) or { "" })[1] + end + return lines +end + +--- Gets the zero-indexed line from the given buffer. +--- +---@param bufnr integer +---@param row integer zero-indexed line number +---@return string the line at row in filename +function M.get_line(bufnr, row) + return M.get_lines(bufnr, { row })[row] +end + +local str_utf16index = fn.has("nvim-0.11") == 1 + and function(text, byte_col) + return vim.str_utfindex(text, "utf-16", byte_col) + end + or function(text, byte_col) + return select(2, vim.str_utfindex(text, byte_col)) + end + +--- VSCode counts a position in UTF-16 code units, Nvim counts bytes. +--- +---@param text string the line the column belongs to +---@param byte_col integer zero-indexed byte column, clamped to `text` +---@return integer zero-indexed UTF-16 column +function M.utf16_col(text, byte_col) + byte_col = math.max(0, math.min(byte_col, #text)) + return byte_col == 0 and 0 or str_utf16index(text, byte_col) +end + +---@param buf integer +---@param line integer one-indexed line +---@param byte_col integer zero-indexed byte column +---@return lsp.Position +function M.lsp_position(buf, line, byte_col) + return { line = line - 1, character = M.utf16_col(M.get_line(buf, line - 1) or "", byte_col) } +end + +--- Converts an inclusive Nvim byte range to an exclusive LSP range. +--- +--- The end stops at the line end, so that a linewise range - which passes the whole +--- line length as its end column - stays a valid position instead of running past it. +--- +---@param buf integer +---@param start_line integer one-indexed +---@param start_col integer zero-indexed byte column +---@param end_line integer one-indexed +---@param end_col integer zero-indexed byte column, inclusive +---@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)) + end + return { + start = M.lsp_position(buf, start_line, start_col), + ["end"] = { line = end_line - 1, character = end_char }, + } +end + +--- Compare two positions +---@param a lsp.Position +---@param b lsp.Position +---@return -1|0|1 -1 if a < b, 0 if a == b, 1 if a > b +function M.compare_position(a, b) + if a.line > b.line then + return 1 + end + if a.line == b.line and a.character > b.character then + return 1 + end + if a.line == b.line and a.character == b.character then + return 0 + end + return -1 +end + +---Debounce a function. +---@param func function function to debounce +---@param time number trialing time in ms +---@return function +function M.debounce(func, time) + local timer + return function(...) + local args = { ... } + if timer and timer:is_active() then + timer:close() + end + timer = vim.defer_fn(function() + func(unpack(args)) + end, time) + end +end + +-- Since Nvim 0.10.0, `virtcol2col` changed from returning the last byte of a +-- multi-byte character to returning the first byte. However, we need the column +-- of the last byte, which is consistent with the selected region in Nvim. +-- See https://github.com/neovim/neovim/issues/29786 +if fn.has("nvim-0.10.0") == 0 then + M.virtcol2col = fn.virtcol2col +else + ---@diagnostic disable-next-line: duplicate-set-field + M.virtcol2col = function(winid, lnum, virtcol) + local byte_idx = fn.virtcol2col(winid, lnum, virtcol) - 1 + local buf = api.nvim_win_get_buf(winid) + local line = M.get_line(buf, lnum - 1) + local char_idx = fn.charidx(line, byte_idx) + local prefix = fn.strcharpart(line, 0, char_idx + 1) + return #prefix + end +end + +-- Wrapper for nvim_set_option_value that sets the option only if the value differs +function M.set_option_value(name, value, opts) + opts = opts or {} + local current = api.nvim_get_option_value(name, opts) + if current ~= value then + api.nvim_set_option_value(name, value, opts) + end +end + +-- Wrapper for set_option_value to set a buffer option +function M.buf_set_option(buf, name, value) + return M.set_option_value(name, value, { buf = buf }) +end + +-- Wrapper for set_option_value to set a window option +function M.win_set_option(win, name, value) + return M.set_option_value(name, value, { win = win }) +end + +return M diff --git a/src/test/integ/multi-byte-chars.test.ts b/src/test/integ/multi-byte-chars.test.ts new file mode 100644 index 0000000..680bfaf --- /dev/null +++ b/src/test/integ/multi-byte-chars.test.ts @@ -0,0 +1,289 @@ +import { NeovimClient } from "neovim"; + +import { + attachTestNvimClient, + sendVSCodeKeys, + assertContent, + closeAllActiveEditors, + closeNvimClient, + setCursor, + sendEscapeKey, + openTextDocument, + sendInsertKey, +} from "./integrationUtils"; + +describe("Multi-width characters", () => { + let client: NeovimClient; + before(async () => { + client = await attachTestNvimClient(); + }); + after(async () => { + await closeNvimClient(client); + await closeAllActiveEditors(); + }); + + it("Works - 2col width chars", async () => { + await openTextDocument({ content: ["测试微服务", "", "没办法跳转到最后一个"].join("\n") }); + + await assertContent( + { + content: ["测试微服务", "", "没办法跳转到最后一个"], + cursor: [0, 0], + }, + client, + ); + + await sendVSCodeKeys("ll"); + await assertContent( + { + vsCodeCursor: [0, 2], + }, + client, + ); + + await sendVSCodeKeys("x", 500); + await assertContent( + { + content: ["测试服务", "", "没办法跳转到最后一个"], + vsCodeCursor: [0, 2], + }, + client, + ); + + // await sendVSCodeKeys("vll"); + // await assertContent( + // { + // vsCodeSelections: [new vscode.Selection(0, 2, 0, 4)], + // }, + // client, + // ); + + await setCursor(2, 5); + await assertContent( + { + vsCodeCursor: [2, 5], + }, + client, + ); + }); + + it("Works - 1col-2byte width chars", async () => { + await openTextDocument({ content: ["żżżżżżżż',", "ńńńńńńńń',"].join("\n") }); + + await assertContent( + { + content: ["żżżżżżżż',", "ńńńńńńńń',"], + cursor: [0, 0], + }, + client, + ); + await sendVSCodeKeys("lll"); + await assertContent({ vsCodeCursor: [0, 3] }, client); + + await sendVSCodeKeys("x"); + await assertContent( + { + content: ["żżżżżżż',", "ńńńńńńńń',"], + vsCodeCursor: [0, 3], + }, + client, + ); + }); + + it("Works - 1col-3byte width chars", async () => { + await openTextDocument({ content: ["1ᵩᵩ123"].join("\n") }); + + await assertContent( + { + content: ["1ᵩᵩ123"], + cursor: [0, 0], + }, + client, + ); + await sendVSCodeKeys("ll"); + await assertContent({ vsCodeCursor: [0, 2] }, client); + await sendVSCodeKeys("ll"); + await assertContent({ vsCodeCursor: [0, 4] }, client); + + await sendVSCodeKeys("x"); + await assertContent( + { + content: ["1ᵩᵩ13"], + vsCodeCursor: [0, 4], + }, + client, + ); + }); + + it("Cursor is ok after exiting insert mode - 2 col chars", async () => { + await openTextDocument({ content: ["测试微服务", "", "没办法跳转到最后一个"].join("\n") }); + + await sendVSCodeKeys("lll"); + await assertContent( + { + vsCodeCursor: [0, 3], + }, + client, + ); + await sendInsertKey(); + + await sendEscapeKey(); + await assertContent( + { + vsCodeCursor: [0, 2], + }, + client, + ); + }); + + it("Cursor is ok after exiting insert mode - 1col-2byte chars", async () => { + await openTextDocument({ content: ["żżżżżżżż',", "ńńńńńńńń',"].join("\n") }); + + await sendVSCodeKeys("lll"); + await assertContent( + { + vsCodeCursor: [0, 3], + }, + client, + ); + await sendInsertKey(); + + await sendEscapeKey(); + await assertContent( + { + vsCodeCursor: [0, 2], + }, + client, + ); + }); + + it("Cursor is ok after exiting insert mode - 1col-3byte width chars", async () => { + await openTextDocument({ content: ["1ᵩᵩ123"].join("\n") }); + + await assertContent( + { + content: ["1ᵩᵩ123"], + cursor: [0, 0], + }, + client, + ); + await sendVSCodeKeys("ll"); + await assertContent({ vsCodeCursor: [0, 2] }, client); + await sendInsertKey("a"); + + await sendEscapeKey(); + await assertContent({ vsCodeCursor: [0, 2] }, client); + + await sendVSCodeKeys("ll"); + await sendInsertKey("a"); + await sendEscapeKey(); + await assertContent({ vsCodeCursor: [0, 4] }, client); + }); + + it("Cursor is ok after exiting insert mode at end of the line - 1col-3byte width chars", async () => { + await openTextDocument({ content: "ᵩ123" }); + + await sendInsertKey("A"); + await sendEscapeKey(); + await assertContent({ vsCodeCursor: [0, 3] }, client); + }); + + it("Multi byte with tabs", async () => { + await openTextDocument({ content: "\t\t测试\t微服务" }); + + await sendVSCodeKeys("gg0"); + await sendVSCodeKeys("l"); + await assertContent( + { + vsCodeCursor: [0, 1], + }, + client, + ); + + await sendVSCodeKeys("l"); + await assertContent( + { + vsCodeCursor: [0, 2], + }, + client, + ); + + await sendVSCodeKeys("x"); + await assertContent( + { + content: ["\t\t试\t微服务"], + vsCodeCursor: [0, 2], + }, + client, + ); + + await sendVSCodeKeys("ll"); + await assertContent( + { + vsCodeCursor: [0, 4], + }, + client, + ); + + await sendVSCodeKeys("x"); + await assertContent( + { + content: ["\t\t试\t服务"], + vsCodeCursor: [0, 4], + }, + client, + ); + }); + + it("Issue #503", async () => { + await openTextDocument({ content: "ŷaŷbŷcŷd = functionŷ(par1)" }); + + await sendVSCodeKeys("f(l"); + await assertContent( + { + vsCodeCursor: [0, 26], + }, + client, + ); + + await sendVSCodeKeys("ci("); + await assertContent({ vsCodeCursor: [0, 26], content: ["ŷaŷbŷcŷd = functionŷ()"] }, client); + }); + + it("Works - Emoji chars", async () => { + await openTextDocument({ content: ["🚀🕵️💡🤣", "", "🕵️🕵️🕵️🕵️"].join("\n") }); + + await assertContent( + { + content: ["🚀🕵️💡🤣", "", "🕵️🕵️🕵️🕵️"], + cursor: [0, 0], + }, + client, + ); + + await sendVSCodeKeys("ll"); + await assertContent( + { + vsCodeCursor: [0, 5], + }, + client, + ); + + await sendVSCodeKeys("x"); + await assertContent( + { + content: ["🚀🕵️🤣", "", "🕵️🕵️🕵️🕵️"], + vsCodeCursor: [0, 5], + }, + client, + ); + await sendVSCodeKeys("jjdw"); + await assertContent( + { + content: ["🚀🕵️🤣", "", "🕵️🕵️"], + vsCodeCursor: [2, 3], + }, + client, + ); + }); +});