diff --git a/lua/u.lua b/lua/u.lua index 79990bc..721a51f 100644 --- a/lua/u.lua +++ b/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') end +local BRACKET_MAP = { + ['('] = '(', + [')'] = '(', + ['b'] = '(', + ['{'] = '{', + ['}'] = '{', + ['B'] = '{', + ['['] = '[', + [']'] = '[', + ['<'] = '<', + ['>'] = '<', +} + --- @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 function Range.from_motion(motion, opts) -- SECTION: Normalize options @@ -526,6 +539,7 @@ function Range.from_motion(motion, opts) local scope, motion_rest = motion:sub(1, 1), motion:sub(2) local is_txtobj = scope == 'a' or scope == 'i' 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 local original_state = { @@ -546,6 +560,29 @@ function Range.from_motion(motion, opts) vim.api.nvim_buf_call(opts.bufnr, function() 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_captured_range = Range.from_op_func(ty) end @@ -663,20 +700,28 @@ function Range.from_cmd_args(args) 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 { - Range.from_motion('a<', { contains_cursor = true }), - Range.from_motion('a[', { contains_cursor = true }), - Range.from_motion('a(', { contains_cursor = true }), - Range.from_motion('a{', { contains_cursor = true }), + Range.from_motion('a<', copts), + Range.from_motion('a[', copts), + Range.from_motion('a(', copts), + Range.from_motion('a{', copts), } 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 { - Range.from_motion([[a']], { contains_cursor = true }), - Range.from_motion([[a"]], { contains_cursor = true }), - Range.from_motion([[a`]], { contains_cursor = true }), + Range.from_motion([[a']], copts), + Range.from_motion([[a"]], copts), + Range.from_motion([[a`]], copts), } end diff --git a/spec/u_spec.lua b/spec/u_spec.lua index 3fc65bd..89e63a8 100644 --- a/spec/u_spec.lua +++ b/spec/u_spec.lua @@ -468,6 +468,118 @@ describe('Range', function() 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() withbuf({ '-- a comment',