From f2b031136ff607866a9ab7933408aa86eb8b99a7 Mon Sep 17 00:00:00 2001 From: Yi Ming Date: Mon, 18 May 2026 17:08:19 +0800 Subject: [PATCH 1/3] feat(pos,range): pos:to_mark(), pos.mark(), range:to_mark(), range.mark() Problem: Ranges represented by marks are usually end-inclusive, but the range utilities we provided are end-exclusive. Solution: Add pos:to_mark(), pos.mark(), range:to_mark(), and range.mark(). --- runtime/doc/lua.txt | 57 +++++++++++++++++++++++++++++ runtime/doc/news.txt | 1 + runtime/lua/vim/pos.lua | 19 ++++++++++ runtime/lua/vim/range.lua | 76 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 153 insertions(+) diff --git a/runtime/doc/lua.txt b/runtime/doc/lua.txt index 2f935f0882..53636d6205 100644 --- a/runtime/doc/lua.txt +++ b/runtime/doc/lua.txt @@ -4539,6 +4539,14 @@ lsp({buf}, {pos}, {position_encoding}) *vim.pos.lsp()* • {pos} (`lsp.Position`) • {position_encoding} (`lsp.PositionEncodingKind`) +mark({buf}, {row}, {col}) *vim.pos.mark()* + Creates a new |vim.Pos| from mark position (see |api-indexing|). + + Parameters: ~ + • {buf} (`integer`) + • {row} (`integer`) + • {col} (`integer`) + offset({buf}, {offset}) *vim.pos.offset()* Creates a new |vim.Pos| from buffer offset. @@ -4583,6 +4591,16 @@ to_lsp({pos}, {position_encoding}) *vim.pos.to_lsp()* • {pos} (`vim.Pos`) See |vim.Pos|. • {position_encoding} (`lsp.PositionEncodingKind`) +to_mark({pos}) *vim.pos.to_mark()* + Converts |vim.Pos| to mark position (see |api-indexing|). + + Parameters: ~ + • {pos} (`vim.Pos`) See |vim.Pos|. + + Return (multiple): ~ + (`integer`) + (`integer`) + to_offset({pos}) *vim.pos.to_offset()* Converts |vim.Pos| to buffer offset. @@ -4719,6 +4737,26 @@ lsp({buf}, {range}, {position_encoding}) *vim.range.lsp()* • {range} (`lsp.Range`) • {position_encoding} (`lsp.PositionEncodingKind`) + *vim.range.mark()* +mark({buf}, {start_row}, {start_col}, {end_row}, {end_col}) + Creates a new |vim.Range| from "mark-indexed" range (see |api-indexing|). + + Example: >lua + -- A range represented by marks may be end-inclusive (decided by 'selection' option). + local start_row, start_col = unpack(api.nvim_buf_get_mark(bufnr, '<')) + local end_row, end_col = unpack(api.nvim_buf_get_mark(bufnr, '>')) + + -- Create an end-exclusive range. + local range = vim.range.mark(0, start_row, start_col, end_row, end_col) +< + + Parameters: ~ + • {buf} (`integer`) + • {start_row} (`integer`) + • {start_col} (`integer`) + • {end_row} (`integer`) + • {end_col} (`integer`) + to_cursor({range}) *vim.range.to_cursor()* Converts |vim.Range| to mark-like range (see |api-indexing|). @@ -4762,6 +4800,25 @@ to_lsp({range}, {position_encoding}) *vim.range.to_lsp()* Return: ~ (`lsp.Range`) +to_mark({range}) *vim.range.to_mark()* + Converts |vim.Range| to extmark range (see |api-indexing|). + + Example: >lua + local range = vim.range(0, 3, 5, 4, 0) + + -- Convert to mark range, you can call it in a method style. + local start_row, start_col, end_row, end_col = range:to_mark() +< + + Parameters: ~ + • {range} (`vim.Range`) See |vim.Range|. + + Return (multiple): ~ + (`integer`) + (`integer`) + (`integer`) + (`integer`) + ============================================================================== Lua module: vim.re *vim.re* diff --git a/runtime/doc/news.txt b/runtime/doc/news.txt index 1d7dc96b79..0fc47843cb 100644 --- a/runtime/doc/news.txt +++ b/runtime/doc/news.txt @@ -203,6 +203,7 @@ LUA • |vim.log| for easily creating loggers. • |vim.pack.get()| output includes revision of a pending update. • |vim.pack.get()| can fetch new updates before computing the output. +• |vim.pos| and |vim.range| can now convert between mark positions. OPTIONS diff --git a/runtime/lua/vim/pos.lua b/runtime/lua/vim/pos.lua index e5d0d4c84e..a37353f736 100644 --- a/runtime/lua/vim/pos.lua +++ b/runtime/lua/vim/pos.lua @@ -291,6 +291,25 @@ function M.cursor(buf, pos) return M.new(buf, pos[1] - 1, pos[2]) end +--- Converts |vim.Pos| to mark position (see |api-indexing|). +---@param pos vim.Pos +---@return integer, integer +function M.to_mark(pos) + return pos[1] + 1, pos[2] +end + +--- Creates a new |vim.Pos| from mark position (see |api-indexing|). +---@param buf integer +---@param row integer +---@param col integer +function M.mark(buf, row, col) + if buf == 0 then + buf = api.nvim_get_current_buf() + end + + return M.new(buf, row - 1, col) +end + --- Converts |vim.Pos| to extmark position (see |api-indexing|). ---@param pos vim.Pos ---@return integer, integer diff --git a/runtime/lua/vim/range.lua b/runtime/lua/vim/range.lua index f50aff60b4..dd22d1151e 100644 --- a/runtime/lua/vim/range.lua +++ b/runtime/lua/vim/range.lua @@ -160,6 +160,21 @@ local function to_inclusive_pos(buf, row, col) return row, col end +---@param row integer +---@param col integer +---@param buf integer +---@return integer, integer +local function to_exclusive_pos(buf, row, col) + if col >= #get_line(buf, row) then + row = row + 1 + col = 0 + else + col = col + 1 + end + + return row, col +end + ---@private ---@param r1 vim.Range ---@param r2 vim.Range @@ -314,6 +329,67 @@ function M.lsp(buf, range, position_encoding) return M.new(start, end_) end +--- Converts |vim.Range| to extmark range (see |api-indexing|). +--- +--- Example: +--- ```lua +--- local range = vim.range(0, 3, 5, 4, 0) +--- +--- -- Convert to mark range, you can call it in a method style. +--- local start_row, start_col, end_row, end_col = range:to_mark() +--- ``` +---@param range vim.Range +---@return integer, integer, integer, integer +function M.to_mark(range) + validate('range', range, 'table') + + local buf = range.buf + local start_row, start_col, end_row, end_col = range[1], range[2], range[3], range[4] + if vim.o.selection ~= 'exclusive' then + end_row, end_col = to_inclusive_pos(buf, end_row, end_col) + end + + start_row, start_col = vim.pos(buf, start_row, start_col):to_mark() + end_row, end_col = vim.pos(buf, end_row, end_col):to_mark() + return start_row, start_col, end_row, end_col +end + +--- Creates a new |vim.Range| from "mark-indexed" range (see |api-indexing|). +--- +--- Example: +--- ```lua +--- -- A range represented by marks may be end-inclusive (decided by 'selection' option). +--- local start_row, start_col = unpack(api.nvim_buf_get_mark(bufnr, '<')) +--- local end_row, end_col = unpack(api.nvim_buf_get_mark(bufnr, '>')) +--- +--- -- Create an end-exclusive range. +--- local range = vim.range.mark(0, start_row, start_col, end_row, end_col) +--- ``` +---@param buf integer +---@param start_row integer +---@param start_col integer +---@param end_row integer +---@param end_col integer +function M.mark(buf, start_row, start_col, end_row, end_col) + validate('buf', buf, 'number') + validate('start_row', start_row, 'number') + validate('start_col', start_col, 'number') + validate('end_row', end_row, 'number') + validate('end_col', end_col, 'number') + + if buf == 0 then + buf = api.nvim_get_current_buf() + end + + local start = vim.pos.mark(buf, start_row, start_col) + local end_ = vim.pos.mark(buf, end_row, end_col) + + if vim.o.selection ~= 'exclusive' then + end_[1], end_[2] = to_exclusive_pos(buf, end_[1], end_[2]) + end + return M.new(start, end_) +end + --- Converts |vim.Range| to extmark range (see |api-indexing|). --- --- Example: From 856bc6d284f6c9f31f21d01e69b44457502a773f Mon Sep 17 00:00:00 2001 From: Yi Ming Date: Sun, 17 May 2026 16:39:25 +0800 Subject: [PATCH 2/3] fix(range): handle inclusive/exclusive positions on multibyte characters --- runtime/lua/vim/range.lua | 14 ++++++++------ test/functional/lua/range_spec.lua | 12 ++++++++++++ 2 files changed, 20 insertions(+), 6 deletions(-) diff --git a/runtime/lua/vim/range.lua b/runtime/lua/vim/range.lua index dd22d1151e..e069df4a3a 100644 --- a/runtime/lua/vim/range.lua +++ b/runtime/lua/vim/range.lua @@ -150,11 +150,12 @@ end ---@param buf integer ---@return integer, integer local function to_inclusive_pos(buf, row, col) + local line = get_line(buf, row) if col > 0 then - col = col - 1 + col = col + vim.str_utf_start(line, col) - 1 elseif col == 0 and row > 0 then row = row - 1 - col = #get_line(buf, row) + col = #line > 0 and #line + vim.str_utf_start(line, #line) - 1 or 0 end return row, col @@ -165,11 +166,12 @@ end ---@param buf integer ---@return integer, integer local function to_exclusive_pos(buf, row, col) - if col >= #get_line(buf, row) then + local line = get_line(buf, row) + if col >= #line then row = row + 1 col = 0 else - col = col + 1 + col = col + vim.str_utf_end(line, col + 1) + 1 end return row, col @@ -179,7 +181,7 @@ end ---@param r1 vim.Range ---@param r2 vim.Range function M.__lt(r1, r2) - if r1:is_empty() then + if r1:is_empty() or r2:is_empty() then return cmp_pos(r1[3], r1[4], r2[1], r2[2]) ~= 1 end @@ -191,7 +193,7 @@ end ---@param r1 vim.Range ---@param r2 vim.Range function M.__le(r1, r2) - if r1:is_empty() then + if r1:is_empty() or r2:is_empty() then return cmp_pos(r1[3], r1[4], r2[1], r2[2]) ~= 1 end diff --git a/test/functional/lua/range_spec.lua b/test/functional/lua/range_spec.lua index bb2235d6c6..d165080d81 100644 --- a/test/functional/lua/range_spec.lua +++ b/test/functional/lua/range_spec.lua @@ -80,6 +80,18 @@ describe('vim.range', function() }, range) end) + it('converts between inclusive mark ranges ending on multibyte characters', function() + insert('🙂') + + local range, mark_range = exec_lua(function() + vim.o.selection = 'inclusive' + local range = vim.range.mark(0, 1, 0, 1, 0) + return { range[1], range[2], range[3], range[4] }, { range:to_mark() } + end) + eq({ 0, 0, 0, 4 }, range) + eq({ 1, 0, 1, 0 }, mark_range) + end) + it('checks whether a range contains a position', function() eq( true, From ff43f1950e1fd3b5ae37924bc8f9c6c3e9d54908 Mon Sep 17 00:00:00 2001 From: Yi Ming Date: Sun, 17 May 2026 16:17:22 +0800 Subject: [PATCH 3/3] feat(lsp)!: deprecate `vim.lsp.util.character_offset()` --- runtime/doc/deprecated.txt | 4 ++++ runtime/doc/lsp.txt | 14 ------------ runtime/lua/vim/lsp/util.lua | 29 +++++-------------------- test/functional/plugin/lsp/buf_spec.lua | 2 +- 4 files changed, 10 insertions(+), 39 deletions(-) diff --git a/runtime/doc/deprecated.txt b/runtime/doc/deprecated.txt index d9d165036c..959ac3dedc 100644 --- a/runtime/doc/deprecated.txt +++ b/runtime/doc/deprecated.txt @@ -38,6 +38,10 @@ LUA • vim.F.npcall() Renamed to |vim.npcall()| • vim.F.nil_wrap() Use |vim.npcall()| instead +LSP + +• vim.lsp.util.character_offset() Use to |vim.str_utfindex()| instead + ------------------------------------------------------------------------------ DEPRECATED IN 0.12 *deprecated-0.12* diff --git a/runtime/doc/lsp.txt b/runtime/doc/lsp.txt index 973f7a033e..c0a2afb94d 100644 --- a/runtime/doc/lsp.txt +++ b/runtime/doc/lsp.txt @@ -2941,20 +2941,6 @@ buf_highlight_references({bufnr}, {references}, {position_encoding}) See also: ~ • https://microsoft.github.io/language-server-protocol/specification/#textDocumentContentChangeEvent - *vim.lsp.util.character_offset()* -character_offset({buf}, {row}, {col}, {position_encoding}) - Returns the UTF-32 and UTF-16 offsets for a position in a certain buffer. - - Parameters: ~ - • {buf} (`integer`) buffer number (0 for current) - • {row} (`integer`) 0-indexed line - • {col} (`integer`) 0-indexed byte offset in line - • {position_encoding} (`'utf-8'|'utf-16'|'utf-32'`) - - Return: ~ - (`integer`) `position_encoding` index of the character in line {row} - column {col} in buffer {buf} - *vim.lsp.util.convert_input_to_markdown_lines()* convert_input_to_markdown_lines({input}, {contents}) Converts any of `MarkedString` | `MarkedString[]` | `MarkupContent` into a diff --git a/runtime/lua/vim/lsp/util.lua b/runtime/lua/vim/lsp/util.lua index d846ce1d01..7a37d5ebfa 100644 --- a/runtime/lua/vim/lsp/util.lua +++ b/runtime/lua/vim/lsp/util.lua @@ -1856,32 +1856,11 @@ function M.make_given_range_params(start_pos, end_pos, bufnr, position_encoding) validate('position_encoding', position_encoding, 'string') bufnr = vim._resolve_bufnr(bufnr) - --- @type [integer, integer] - local A = { unpack(start_pos or api.nvim_buf_get_mark(bufnr, '<')) } - --- @type [integer, integer] - local B = { unpack(end_pos or api.nvim_buf_get_mark(bufnr, '>')) } - -- convert to 0-index - A[1] = A[1] - 1 - B[1] = B[1] - 1 - -- account for position_encoding. - if A[2] > 0 then - A[2] = M.character_offset(bufnr, A[1], A[2], position_encoding) - end - if B[2] > 0 then - B[2] = M.character_offset(bufnr, B[1], B[2], position_encoding) - end - -- we need to offset the end character position otherwise we loose the last - -- character of the selection, as LSP end position is exclusive - -- see https://microsoft.github.io/language-server-protocol/specification#range - if vim.o.selection ~= 'exclusive' then - B[2] = B[2] + 1 - end + local start_row, start_col = unpack(start_pos or api.nvim_buf_get_mark(bufnr, '<')) + local end_row, end_col = unpack(end_pos or api.nvim_buf_get_mark(bufnr, '>')) return { textDocument = M.make_text_document_params(bufnr), - range = { - start = { line = A[1], character = A[2] }, - ['end'] = { line = B[1], character = B[2] }, - }, + range = vim.range.mark(bufnr, start_row, start_col, end_row, end_col):to_lsp(position_encoding), } end @@ -1933,12 +1912,14 @@ end --- Returns the UTF-32 and UTF-16 offsets for a position in a certain buffer. --- +---@deprecated ---@param buf integer buffer number (0 for current) ---@param row integer 0-indexed line ---@param col integer 0-indexed byte offset in line ---@param position_encoding 'utf-8'|'utf-16'|'utf-32' ---@return integer `position_encoding` index of the character in line {row} column {col} in buffer {buf} function M.character_offset(buf, row, col, position_encoding) + vim.deprecate('vim.lsp.util.character_offset', 'vim.str_utfindex', '0.14') vim.validate('position_encoding', position_encoding, 'string') local line = get_line(buf, row) diff --git a/test/functional/plugin/lsp/buf_spec.lua b/test/functional/plugin/lsp/buf_spec.lua index f5fc498ef6..d2fd84f4e3 100644 --- a/test/functional/plugin/lsp/buf_spec.lua +++ b/test/functional/plugin/lsp/buf_spec.lua @@ -1456,7 +1456,7 @@ describe('vim.lsp.buf', function() eq('textDocument/rangeFormatting', result[3].method) local expected_range = { start = { line = 0, character = 0 }, - ['end'] = { line = 1, character = 4 }, + ['end'] = { line = 2, character = 0 }, } eq(expected_range, result[3].params.range) end)