From 416f3482e7f183b039dfe56e158ac42ea42abb3a Mon Sep 17 00:00:00 2001 From: Luis Calle <53507599+TheLeoP@users.noreply.github.com> Date: Fri, 8 May 2026 04:17:00 -0500 Subject: [PATCH] fix(vim.range): empty ranges semantics vs regular ranges #39474 Problem: - Empty ranges have different `<`, `<=`, `has` and `intersect` semantics compared to regular ranges. - `to_inclusive_pos` assumes that the end position of a range is exclusive, which is not true for empty ranges Solution: Special case empty ranges in these operations. --- runtime/lua/vim/range.lua | 37 +++++++++---- test/functional/lua/range_spec.lua | 86 ++++++++++++++++++++++++++++-- 2 files changed, 109 insertions(+), 14 deletions(-) diff --git a/runtime/lua/vim/range.lua b/runtime/lua/vim/range.lua index 9a8f1169a4..f50aff60b4 100644 --- a/runtime/lua/vim/range.lua +++ b/runtime/lua/vim/range.lua @@ -164,6 +164,10 @@ end ---@param r1 vim.Range ---@param r2 vim.Range function M.__lt(r1, r2) + if r1:is_empty() then + return cmp_pos(r1[3], r1[4], r2[1], r2[2]) ~= 1 + end + local r1_inclusive_end_row, r1_inclusive_end_col = to_inclusive_pos(r1.buf, r1[3], r1[4]) return cmp_pos(r1_inclusive_end_row, r1_inclusive_end_col, r2[1], r2[2]) == -1 end @@ -172,6 +176,10 @@ end ---@param r1 vim.Range ---@param r2 vim.Range function M.__le(r1, r2) + if r1:is_empty() then + return cmp_pos(r1[3], r1[4], r2[1], r2[2]) ~= 1 + end + local r1_inclusive_end_row, r1_inclusive_end_col = to_inclusive_pos(r1.buf, r1[3], r1[4]) return cmp_pos(r1_inclusive_end_row, r1_inclusive_end_col, r2[1], r2[2]) ~= 1 end @@ -188,9 +196,7 @@ end ---@param range vim.Range ---@return boolean `true` if the given range is empty. function M.is_empty(range) - local inclusive_end_row, inclusive_end_col = to_inclusive_pos(range.buf, range[3], range[4]) - - return cmp_pos(range[1], range[2], inclusive_end_row, inclusive_end_col) ~= -1 + return cmp_pos(range[1], range[2], range[3], range[4]) ~= -1 end --- Checks whether {outer} range contains {inner} range or position. @@ -206,17 +212,23 @@ function M.has(outer, inner) end ---@cast inner -vim.Pos - local outer_inclusive_end_row, outer_inclusive_end_col = - to_inclusive_pos(outer.buf, outer[3], outer[4]) - local inner_inclusive_end_row, inner_inclusive_end_col = - to_inclusive_pos(inner.buf, inner[3], inner[4]) + if outer:is_empty() then + return false + end + + -- accounts for empty ranges at the start/end of `outer` that per Neovim API and LSP logic insert + -- the text outside `outer` + if + ( + cmp_pos(outer[1], outer[2], inner[3], inner[4]) ~= -1 + or cmp_pos(outer[3], outer[4], inner[1], inner[2]) ~= 1 + ) and inner:is_empty() + then + return false + end return cmp_pos(outer[1], outer[2], inner[1], inner[2]) ~= 1 and cmp_pos(outer[3], outer[4], inner[3], inner[4]) ~= -1 - -- accounts for empty ranges at the start/end of `outer` that per Neovim API and LSP logic - -- insert the text outside `outer` - and cmp_pos(outer[1], outer[2], inner_inclusive_end_row, inner_inclusive_end_col) == -1 - and cmp_pos(outer_inclusive_end_row, outer_inclusive_end_col, inner[1], inner[2]) == 1 end --- Computes the common range shared by the given ranges. @@ -229,6 +241,9 @@ function M.intersect(r1, r2) if r1.buf ~= r2.buf then return nil end + if r1:is_empty() or r2:is_empty() then + return nil + end local r1_inclusive_end_row, r1_inclusive_end_col = to_inclusive_pos(r1.buf, r1[3], r1[4]) local r2_inclusive_end_row, r2_inclusive_end_col = to_inclusive_pos(r2.buf, r2[3], r2[4]) diff --git a/test/functional/lua/range_spec.lua b/test/functional/lua/range_spec.lua index c47dc25363..bb2235d6c6 100644 --- a/test/functional/lua/range_spec.lua +++ b/test/functional/lua/range_spec.lua @@ -90,12 +90,92 @@ describe('vim.range', function() ) end) - it('checks whether a range does not contain an empty range just outside it', function() + it('a range does not contain an empty range just outside it', function() eq( false, exec_lua(function() - local buf = vim.api.nvim_create_buf(false, true) - return vim.range(buf, 0, 0, 0, 4):has(vim.range(buf, 0, 0, 0, 0)) + return vim.range(0, 0, 0, 0, 4):has(vim.range(0, 0, 0, 0, 0)) + end) + ) + + eq( + false, + exec_lua(function() + return vim.range(0, 0, 0, 0, 4):has(vim.range(0, 0, 4, 0, 4)) + end) + ) + end) + + it('an empty range contains no other range', function() + eq( + false, + exec_lua(function() + return vim.range(0, 1, 0, 1, 0):has(vim.range(0, 1, 0, 1, 0)) + end) + ) + eq( + false, + exec_lua(function() + return vim.range(0, 1, 0, 1, 0):has(vim.range(0, 1, 0, 2, 0)) + end) + ) + eq( + false, + exec_lua(function() + return vim.range(0, 1, 0, 1, 0):has(vim.range(0, 0, 0, 1, 0)) + end) + ) + end) + + it('an empty range intersercts with no other range', function() + eq( + nil, + exec_lua(function() + return vim.range(0, 1, 0, 1, 0):intersect(vim.range(0, 1, 0, 1, 0)) + end) + ) + eq( + nil, + exec_lua(function() + return vim.range(0, 1, 0, 1, 0):intersect(vim.range(0, 1, 0, 2, 0)) + end) + ) + eq( + nil, + exec_lua(function() + return vim.range(0, 1, 0, 1, 0):intersect(vim.range(0, 0, 0, 1, 0)) + end) + ) + end) + + it('empty range comparison semantics', function() + eq( + true, + exec_lua(function() + return vim.range(0, 0, 0, 0, 0) < vim.range(0, 0, 0, 0, 1) + end) + ) + + eq( + true, + exec_lua(function() + return vim.range(0, 1, 0, 1, 0) < vim.range(0, 1, 0, 1, 1) + end) + ) + + eq( + true, + exec_lua(function() + return vim.range(0, 1, 1, 1, 1) > vim.range(0, 1, 0, 1, 1) + end) + ) + end) + + it('1 byte wide range is not empty', function() + eq( + false, + exec_lua(function() + return vim.range(0, 1, 0, 1, 1):is_empty() end) ) end)