Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4ce60fce2e | |||
| 8b7d7dc968 | |||
| b7605b7976 |
4
.gitmodules
vendored
4
.gitmodules
vendored
@@ -2,7 +2,3 @@
|
|||||||
path = library/busted
|
path = library/busted
|
||||||
url = https://github.com/LuaCATS/busted
|
url = https://github.com/LuaCATS/busted
|
||||||
branch = main
|
branch = main
|
||||||
[submodule "library/luv"]
|
|
||||||
path = library/luv
|
|
||||||
url = https://github.com/LuaCATS/luv
|
|
||||||
branch = main
|
|
||||||
|
|||||||
Submodule library/luv deleted from 3615eb12c9
91
lua/u.lua
91
lua/u.lua
@@ -511,8 +511,21 @@ function Range.from_lines(bufnr, start_line, stop_line)
|
|||||||
return Range.new(Pos.new(bufnr, start_line, 1), Pos.new(bufnr, stop_line, vim.v.maxcol), 'V')
|
return Range.new(Pos.new(bufnr, start_line, 1), Pos.new(bufnr, stop_line, vim.v.maxcol), 'V')
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local BRACKET_MAP = {
|
||||||
|
['('] = '(',
|
||||||
|
[')'] = '(',
|
||||||
|
['b'] = '(',
|
||||||
|
['{'] = '{',
|
||||||
|
['}'] = '{',
|
||||||
|
['B'] = '{',
|
||||||
|
['['] = '[',
|
||||||
|
[']'] = '[',
|
||||||
|
['<'] = '<',
|
||||||
|
['>'] = '<',
|
||||||
|
}
|
||||||
|
|
||||||
--- @param motion string
|
--- @param motion string
|
||||||
--- @param opts? { bufnr?: number, contains_cursor?: boolean, pos?: u.Pos, user_defined?: boolean }
|
--- @param opts? { bufnr?: number, contains_cursor?: boolean, pos?: u.Pos, user_defined?: boolean, max_lines?: number, max_col?: number }
|
||||||
--- @return u.Range|nil
|
--- @return u.Range|nil
|
||||||
function Range.from_motion(motion, opts)
|
function Range.from_motion(motion, opts)
|
||||||
-- SECTION: Normalize options
|
-- SECTION: Normalize options
|
||||||
@@ -526,8 +539,10 @@ function Range.from_motion(motion, opts)
|
|||||||
local scope, motion_rest = motion:sub(1, 1), motion:sub(2)
|
local scope, motion_rest = motion:sub(1, 1), motion:sub(2)
|
||||||
local is_txtobj = scope == 'a' or scope == 'i'
|
local is_txtobj = scope == 'a' or scope == 'i'
|
||||||
local is_quote_txtobj = is_txtobj and vim.tbl_contains({ "'", '"', '`' }, motion_rest)
|
local is_quote_txtobj = is_txtobj and vim.tbl_contains({ "'", '"', '`' }, motion_rest)
|
||||||
|
local is_bracket_txtobj = is_txtobj and BRACKET_MAP[motion_rest] ~= nil
|
||||||
|
|
||||||
-- SECTION: Capture original state for restoration
|
-- SECTION: Capture original state for restoration
|
||||||
|
local vinf, vinf_inverted = Range.from_vtext()
|
||||||
local original_state = {
|
local original_state = {
|
||||||
winview = vim.fn.winsaveview(),
|
winview = vim.fn.winsaveview(),
|
||||||
unnamed_register = vim.fn.getreg '"',
|
unnamed_register = vim.fn.getreg '"',
|
||||||
@@ -537,7 +552,8 @@ function Range.from_motion(motion, opts)
|
|||||||
opfunc = vim.go.operatorfunc,
|
opfunc = vim.go.operatorfunc,
|
||||||
prev_captured_range = _G.Range__from_motion_opfunc_captured_range,
|
prev_captured_range = _G.Range__from_motion_opfunc_captured_range,
|
||||||
prev_mode = vim.fn.mode(),
|
prev_mode = vim.fn.mode(),
|
||||||
vinf = Range.from_vtext(),
|
vinf = vinf,
|
||||||
|
vinf_inverted = vinf_inverted,
|
||||||
}
|
}
|
||||||
--- @type u.Range|nil
|
--- @type u.Range|nil
|
||||||
_G.Range__from_motion_opfunc_captured_range = nil
|
_G.Range__from_motion_opfunc_captured_range = nil
|
||||||
@@ -546,6 +562,29 @@ function Range.from_motion(motion, opts)
|
|||||||
vim.api.nvim_buf_call(opts.bufnr, function()
|
vim.api.nvim_buf_call(opts.bufnr, function()
|
||||||
if opts.pos ~= nil then opts.pos:save_to_pos '.' end
|
if opts.pos ~= nil then opts.pos:save_to_pos '.' end
|
||||||
|
|
||||||
|
-- Pre-check: skip expensive g@ when target char not found within bounds
|
||||||
|
if
|
||||||
|
(is_bracket_txtobj or is_quote_txtobj)
|
||||||
|
and type(opts.max_lines) == 'number'
|
||||||
|
and opts.max_lines > 0
|
||||||
|
then
|
||||||
|
local cur = vim.fn.line '.'
|
||||||
|
local line_start = math.max(1, cur - opts.max_lines)
|
||||||
|
local line_end = math.min(vim.api.nvim_buf_line_count(opts.bufnr), cur + opts.max_lines)
|
||||||
|
local chunk = vim.api.nvim_buf_get_lines(opts.bufnr, line_start - 1, line_end, false)
|
||||||
|
local target = is_bracket_txtobj and BRACKET_MAP[motion_rest] or motion_rest
|
||||||
|
local max_col = opts.max_col
|
||||||
|
local found = false
|
||||||
|
for _, line in ipairs(chunk) do
|
||||||
|
local pos = line:find(target, 1, true)
|
||||||
|
if pos and (max_col == nil or pos <= max_col) then
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if not found then return end
|
||||||
|
end
|
||||||
|
|
||||||
_G.Range__from_motion_opfunc = function(ty)
|
_G.Range__from_motion_opfunc = function(ty)
|
||||||
_G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty)
|
_G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty)
|
||||||
end
|
end
|
||||||
@@ -566,7 +605,9 @@ function Range.from_motion(motion, opts)
|
|||||||
vim.fn.setpos('.', original_state.cursor)
|
vim.fn.setpos('.', original_state.cursor)
|
||||||
vim.fn.setpos("'[", original_state.mark_lbracket)
|
vim.fn.setpos("'[", original_state.mark_lbracket)
|
||||||
vim.fn.setpos("']", original_state.mark_rbracket)
|
vim.fn.setpos("']", original_state.mark_rbracket)
|
||||||
if original_state.prev_mode ~= 'n' then original_state.vinf:set_visual_selection() end
|
if original_state.prev_mode ~= 'n' then
|
||||||
|
original_state.vinf:set_visual_selection(original_state.vinf_inverted)
|
||||||
|
end
|
||||||
vim.go.operatorfunc = original_state.opfunc
|
vim.go.operatorfunc = original_state.opfunc
|
||||||
_G.Range__from_motion_opfunc_captured_range = original_state.prev_captured_range
|
_G.Range__from_motion_opfunc_captured_range = original_state.prev_captured_range
|
||||||
|
|
||||||
@@ -626,7 +667,8 @@ end
|
|||||||
function Range.from_vtext()
|
function Range.from_vtext()
|
||||||
local r = Range.from_marks('v', '.')
|
local r = Range.from_marks('v', '.')
|
||||||
if vim.fn.mode() == 'V' then r = r:to_linewise() end
|
if vim.fn.mode() == 'V' then r = r:to_linewise() end
|
||||||
return r
|
local inverted = Pos.from_pos '.' ~= r.stop
|
||||||
|
return r, inverted
|
||||||
end
|
end
|
||||||
|
|
||||||
--- Get range information from the current text range being operated on
|
--- Get range information from the current text range being operated on
|
||||||
@@ -663,20 +705,28 @@ function Range.from_cmd_args(args)
|
|||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
function Range.find_nearest_brackets()
|
--- @param opts? { max_lines?: number, max_col?: number }
|
||||||
|
--- @return u.Range|nil
|
||||||
|
function Range.find_nearest_brackets(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local copts = { contains_cursor = true, max_lines = opts.max_lines, max_col = opts.max_col }
|
||||||
return Range.smallest {
|
return Range.smallest {
|
||||||
Range.from_motion('a<', { contains_cursor = true }),
|
Range.from_motion('a<', copts),
|
||||||
Range.from_motion('a[', { contains_cursor = true }),
|
Range.from_motion('a[', copts),
|
||||||
Range.from_motion('a(', { contains_cursor = true }),
|
Range.from_motion('a(', copts),
|
||||||
Range.from_motion('a{', { contains_cursor = true }),
|
Range.from_motion('a{', copts),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
function Range.find_nearest_quotes()
|
--- @param opts? { max_lines?: number, max_col?: number }
|
||||||
|
--- @return u.Range|nil
|
||||||
|
function Range.find_nearest_quotes(opts)
|
||||||
|
opts = opts or {}
|
||||||
|
local copts = { contains_cursor = true, max_lines = opts.max_lines, max_col = opts.max_col }
|
||||||
return Range.smallest {
|
return Range.smallest {
|
||||||
Range.from_motion([[a']], { contains_cursor = true }),
|
Range.from_motion([[a']], copts),
|
||||||
Range.from_motion([[a"]], { contains_cursor = true }),
|
Range.from_motion([[a"]], copts),
|
||||||
Range.from_motion([[a`]], { contains_cursor = true }),
|
Range.from_motion([[a`]], copts),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -761,18 +811,23 @@ end
|
|||||||
|
|
||||||
function Range:save_to_extmark() return Extmark.from_range(self, NS) end
|
function Range:save_to_extmark() return Extmark.from_range(self, NS) end
|
||||||
|
|
||||||
function Range:set_visual_selection()
|
--- @param inverted boolean?
|
||||||
|
function Range:set_visual_selection(inverted)
|
||||||
if self:is_empty() then return end
|
if self:is_empty() then return end
|
||||||
if vim.api.nvim_get_current_buf() ~= self.start.bufnr then
|
if vim.api.nvim_get_current_buf() ~= self.start.bufnr then
|
||||||
error 'Range:set_visual_selection() called on a buffer other than the current buffer'
|
error 'Range:set_visual_selection() called on a buffer other than the current buffer'
|
||||||
end
|
end
|
||||||
|
|
||||||
local curr_mode = vim.fn.mode()
|
local curr_mode = vim.fn.mode():sub(1, 1)
|
||||||
if curr_mode ~= self.mode then vim.cmd.normal { args = { self.mode }, bang = true } end
|
if curr_mode ~= self.mode then vim.cmd.normal { args = { ESC .. self.mode }, bang = true } end
|
||||||
|
|
||||||
self.start:save_to_pos '.'
|
local start, stop = self.start, self.stop
|
||||||
|
if inverted then
|
||||||
|
start, stop = stop, start
|
||||||
|
end
|
||||||
|
start:save_to_pos '.'
|
||||||
vim.cmd.normal { args = { 'o' }, bang = true }
|
vim.cmd.normal { args = { 'o' }, bang = true }
|
||||||
self.stop:save_to_pos '.'
|
stop:save_to_pos '.'
|
||||||
end
|
end
|
||||||
|
|
||||||
--------------------------------------------------------------------------------
|
--------------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -14,8 +14,8 @@ jq = "1.8.1"
|
|||||||
# installed. In the tests, we will override with `eval $(nvimv env VERSION)`,
|
# installed. In the tests, we will override with `eval $(nvimv env VERSION)`,
|
||||||
# but to avoid having to litter a bunch of commands with that environment
|
# but to avoid having to litter a bunch of commands with that environment
|
||||||
# initialization, this just makes things simpler:
|
# initialization, this just makes things simpler:
|
||||||
neovim = "0.12.1"
|
neovim = "0.12.2"
|
||||||
"http:nvimv" = { version = "latest", url = "https://raw.githubusercontent.com/jrop/nvimv/refs/heads/main/nvimv" }
|
"http:nvimv" = { version = "2d018f5", url = "https://raw.githubusercontent.com/jrop/nvimv/2d018f55173bb88e6fb41e6f07a4908e5619c136/nvimv" }
|
||||||
stylua = "2.3.1"
|
stylua = "2.3.1"
|
||||||
cargo-binstall = "1.18.1"
|
cargo-binstall = "1.18.1"
|
||||||
"cargo:emmylua_ls" = { version = "0.20.0", depends=["cargo-binstall"] }
|
"cargo:emmylua_ls" = { version = "0.20.0", depends=["cargo-binstall"] }
|
||||||
|
|||||||
148
spec/u_spec.lua
148
spec/u_spec.lua
@@ -468,6 +468,154 @@ describe('Range', function()
|
|||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
|
||||||
|
it('from_motion preserves visual selection orientation (cursor at start)', function()
|
||||||
|
withbuf({ 'the quick brown fox' }, function()
|
||||||
|
vim.fn.setpos('.', { 0, 1, 5, 0 })
|
||||||
|
vim.cmd.normal 'v'
|
||||||
|
vim.cmd.normal { args = { 'o' }, bang = true }
|
||||||
|
vim.fn.setpos('.', { 0, 1, 3, 0 })
|
||||||
|
vim.cmd.normal { args = { 'o' }, bang = true }
|
||||||
|
|
||||||
|
vim.cmd.normal { args = { 'o' }, bang = true }
|
||||||
|
local cursor_before = vim.fn.getpos('.')[3]
|
||||||
|
|
||||||
|
Range.from_motion 'aw'
|
||||||
|
|
||||||
|
vim.cmd.normal { args = { 'o' }, bang = true }
|
||||||
|
local cursor_after = vim.fn.getpos('.')[3]
|
||||||
|
|
||||||
|
assert.are_not_same(cursor_before, cursor_after)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('from_motion preserves visual selection orientation (cursor at stop)', function()
|
||||||
|
withbuf({ 'the quick brown fox' }, function()
|
||||||
|
vim.fn.setpos('.', { 0, 1, 3, 0 })
|
||||||
|
vim.cmd.normal 'vll'
|
||||||
|
|
||||||
|
local cursor_before = vim.fn.getpos('.')[3]
|
||||||
|
|
||||||
|
Range.from_motion 'aw'
|
||||||
|
|
||||||
|
vim.cmd.normal { args = { 'o' }, bang = true }
|
||||||
|
local cursor_after = vim.fn.getpos('.')[3]
|
||||||
|
|
||||||
|
assert.are_not_same(cursor_before, cursor_after)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
describe('from_motion with max_lines', function()
|
||||||
|
it('returns nil when bracket chars are beyond max_lines', function()
|
||||||
|
-- A large block where { and } are far from cursor
|
||||||
|
local lines = {}
|
||||||
|
lines[1] = '{'
|
||||||
|
for i = 2, 200 do
|
||||||
|
lines[i] = 'content'
|
||||||
|
end
|
||||||
|
lines[201] = '}'
|
||||||
|
withbuf(lines, function()
|
||||||
|
vim.fn.setpos('.', { 0, 100, 1, 0 }) -- cursor inside the block, 99 lines from {
|
||||||
|
-- max_lines=50: { is 99 lines above, } is 101 lines below → pre-check fails
|
||||||
|
local range = Range.from_motion('a{', { max_lines = 50 })
|
||||||
|
assert.is_nil(range)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('finds brackets within max_lines range', function()
|
||||||
|
withbuf({ 'this is a {', 'block', '} here' }, function()
|
||||||
|
vim.fn.setpos('.', { 0, 2, 1, 0 })
|
||||||
|
local range = Range.from_motion('a{', { max_lines = 10 })
|
||||||
|
assert.is_not_nil(range)
|
||||||
|
assert.are.same('{\nblock\n}', range:text())
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('works for closing bracket chars ) ] } >', function()
|
||||||
|
withbuf({ 'this is a (', 'block', ') here' }, function()
|
||||||
|
vim.fn.setpos('.', { 0, 2, 1, 0 })
|
||||||
|
local range = Range.from_motion('a)', { max_lines = 10 })
|
||||||
|
assert.is_not_nil(range)
|
||||||
|
assert.are.same('(\nblock\n)', range:text())
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('works for b and B aliases', function()
|
||||||
|
withbuf({ 'this is a {', 'block', '} here' }, function()
|
||||||
|
vim.fn.setpos('.', { 0, 2, 1, 0 })
|
||||||
|
local range = Range.from_motion('aB', { max_lines = 10 })
|
||||||
|
assert.is_not_nil(range)
|
||||||
|
assert.are.same('{\nblock\n}', range:text())
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('returns nil when quote chars are beyond max_lines', function()
|
||||||
|
local lines = {}
|
||||||
|
for i = 1, 200 do
|
||||||
|
lines[i] = 'no quotes here'
|
||||||
|
end
|
||||||
|
lines[1] = '"quoted"'
|
||||||
|
withbuf(lines, function()
|
||||||
|
vim.fn.setpos('.', { 0, 100, 1, 0 }) -- cursor 99 lines from quote char
|
||||||
|
local range = Range.from_motion('a"', { max_lines = 50 })
|
||||||
|
assert.is_nil(range)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('finds quotes within max_lines range', function()
|
||||||
|
withbuf({ [[the "quick" brown fox]] }, function()
|
||||||
|
vim.fn.setpos('.', { 0, 1, 5, 0 })
|
||||||
|
local range = Range.from_motion('a"', { max_lines = 10 })
|
||||||
|
assert.is_not_nil(range)
|
||||||
|
assert.are.same('"quick"', range:text())
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('max_lines nil (default) does not pre-check', function()
|
||||||
|
local lines = {}
|
||||||
|
lines[1] = '{'
|
||||||
|
for i = 2, 200 do
|
||||||
|
lines[i] = 'content'
|
||||||
|
end
|
||||||
|
lines[201] = '}'
|
||||||
|
withbuf(lines, function()
|
||||||
|
vim.fn.setpos('.', { 0, 100, 1, 0 })
|
||||||
|
-- Without max_lines, g@ runs and finds the block
|
||||||
|
local range = Range.from_motion 'a{'
|
||||||
|
assert.is_not_nil(range)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('skips brackets in lines exceeding max_col', function()
|
||||||
|
local long_line = string.rep('x', 10240) .. '{block}'
|
||||||
|
withbuf({ long_line }, function()
|
||||||
|
vim.fn.setpos('.', { 0, 1, 10245, 0 }) -- cursor inside {block}
|
||||||
|
-- { is at col 10241, beyond max_col=10240
|
||||||
|
local range = Range.from_motion('a{', { max_lines = 10, max_col = 10240 })
|
||||||
|
assert.is_nil(range)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('finds brackets within max_col on a long line', function()
|
||||||
|
local long_line = string.rep('x', 100) .. '{block}'
|
||||||
|
withbuf({ long_line }, function()
|
||||||
|
vim.fn.setpos('.', { 0, 1, 105, 0 }) -- cursor inside 'block'
|
||||||
|
local range = Range.from_motion('a{', { max_lines = 10, max_col = 10240 })
|
||||||
|
assert.is_not_nil(range)
|
||||||
|
assert.are.same('{block}', range:text())
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
|
it('max_col nil (default) does not limit column search', function()
|
||||||
|
local long_line = string.rep('x', 10240) .. '{block}'
|
||||||
|
withbuf({ long_line }, function()
|
||||||
|
vim.fn.setpos('.', { 0, 1, 10245, 0 }) -- cursor inside {block}
|
||||||
|
-- { at col 10241, but no max_col limit
|
||||||
|
local range = Range.from_motion('a{', { max_lines = 10 })
|
||||||
|
assert.is_not_nil(range)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
|
||||||
it('from_tsquery_caps', function()
|
it('from_tsquery_caps', function()
|
||||||
withbuf({
|
withbuf({
|
||||||
'-- a comment',
|
'-- a comment',
|
||||||
|
|||||||
Reference in New Issue
Block a user