diff --git a/lua/u.lua b/lua/u.lua index 4632dc5..0740f24 100644 --- a/lua/u.lua +++ b/lua/u.lua @@ -6,8 +6,8 @@ local M = {} local MAX_COL = vim.v.maxcol ---- @param bufnr number ---- @param lnum number 1-based +--- @param bufnr integer +--- @param lnum integer 1-based local function line_text(bufnr, lnum) return vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)[1] end @@ -73,7 +73,10 @@ function Pos.from10(bufnr, lnum1, col0, off) return Pos.new(bufnr, lnum1, col0 + function Pos.invalid() return Pos.new(0, 0, 0, 0) end -function Pos.__lt(a, b) return a.lnum < b.lnum or (a.lnum == b.lnum and a.col < b.col) end +function Pos.__lt(a, b) + return a.bufnr == b.bufnr + and (a.lnum < b.lnum or (a.lnum == b.lnum and a.col < b.col)) +end function Pos.__le(a, b) return a < b or a == b end function Pos.__eq(a, b) return getmetatable(a) == Pos @@ -240,8 +243,14 @@ function Pos:find_next(dir, predicate) end --- Finds the matching bracket/paren for the current position. ---- @param max_chars? number|nil ---- @param invocations? u.Pos[] +--- +--- Algorithm: Scans forward (for openers) or backward (for closers) until we find +--- a matching character. When encountering a nested bracket of the same type, we +--- recursively find its match first, then continue scanning. This handles deeply +--- nested structures correctly. +--- +--- @param max_chars? number|nil Safety limit to prevent infinite loops +--- @param invocations? u.Pos[] Tracks positions to prevent infinite recursion --- @return u.Pos|nil function Pos:find_match(max_chars, invocations) if invocations == nil then invocations = {} end @@ -317,6 +326,10 @@ end -- Range class (forward declared for Extmark) -------------------------------------------------------------------------------- +--- @class u.Range +--- @field start u.Pos +--- @field stop u.Pos|nil +--- @field mode 'v'|'V' local Range = {} -------------------------------------------------------------------------------- @@ -406,14 +419,14 @@ function Range.__tostring(self) end end - local _1 = posstr(self.start) - local _2 = posstr(self.stop) + local start_str = posstr(self.start) + local stop_str = posstr(self.stop) return string.format( 'Range{bufnr=%d, mode=%s, start=%s, stop=%s}', self.start.bufnr, self.mode, - _1, - _2 + start_str, + stop_str ) end @@ -499,25 +512,25 @@ end --- @param opts? { bufnr?: number, contains_cursor?: boolean, pos?: u.Pos, user_defined?: boolean } --- @return u.Range|nil function Range.from_motion(motion, opts) - -- Options handling: + -- SECTION: Normalize options opts = opts or {} if opts.bufnr == nil or opts.bufnr == 0 then opts.bufnr = vim.api.nvim_get_current_buf() end if opts.contains_cursor == nil then opts.contains_cursor = false end if opts.user_defined == nil then opts.user_defined = false end - -- Extract some information from the motion: + -- SECTION: Parse motion string --- @type 'a'|'i', string 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) - -- Capture the original state of the buffer for restoration later. + -- SECTION: Capture original state for restoration local original_state = { winview = vim.fn.winsaveview(), - regquote = vim.fn.getreg '"', + unnamed_register = vim.fn.getreg '"', cursor = vim.fn.getpos '.', - pos_lbrack = vim.fn.getpos "'[", - pos_rbrack = vim.fn.getpos "']", + mark_lbracket = vim.fn.getpos "'[", + mark_rbracket = vim.fn.getpos "']", opfunc = vim.go.operatorfunc, prev_captured_range = _G.Range__from_motion_opfunc_captured_range, prev_mode = vim.fn.mode(), @@ -526,6 +539,7 @@ function Range.from_motion(motion, opts) --- @type u.Range|nil _G.Range__from_motion_opfunc_captured_range = nil + -- SECTION: Execute motion and capture result vim.api.nvim_buf_call(opts.bufnr, function() if opts.pos ~= nil then opts.pos:save_to_pos '.' end @@ -545,19 +559,19 @@ function Range.from_motion(motion, opts) end) local captured_range = _G.Range__from_motion_opfunc_captured_range - -- Restore original state: + -- SECTION: Restore state vim.fn.winrestview(original_state.winview) - vim.fn.setreg('"', original_state.regquote) + vim.fn.setreg('"', original_state.unnamed_register) vim.fn.setpos('.', original_state.cursor) - vim.fn.setpos("'[", original_state.pos_lbrack) - vim.fn.setpos("']", original_state.pos_rbrack) + vim.fn.setpos("'[", original_state.mark_lbracket) + vim.fn.setpos("']", original_state.mark_rbracket) if original_state.prev_mode ~= 'n' then original_state.vinf:set_visual_selection() end vim.go.operatorfunc = original_state.opfunc _G.Range__from_motion_opfunc_captured_range = original_state.prev_captured_range if not captured_range then return nil end - -- Fixup the bounds: + -- SECTION: Fixup edge cases (quote text objects, 'it' tag) if -- I have no idea why, but when yanking `i"`, the stop-mark is -- placed on the ending quote. For other text-objects, the stop- @@ -573,6 +587,7 @@ function Range.from_motion(motion, opts) captured_range.stop = captured_range.stop:find_next(-1, motion_rest) or captured_range.stop end + -- SECTION: Validate cursor containment if opts.contains_cursor and not captured_range:contains(Pos.new(unpack(original_state.cursor))) then @@ -873,14 +888,14 @@ function Range:sub(i, j) local start = get_pos(i) if not start then - -- start is inalid, so return an empty range: + -- start is invalid, so return an empty range: return Range.new(self.start, nil, self.mode) end local stop if j then stop = get_pos(j) end if not stop then - -- stop is inalid, so return an empty range: + -- stop is invalid, so return an empty range: return Range.new(start, nil, self.mode) end return Range.new(start, stop, 'v') @@ -896,8 +911,6 @@ end function Range:text() return vim.fn.join(self:lines(), '\n') end --- @param l number --- luacheck: ignore ---- @return { line: string, idx0: { start: number, stop: number }, lnum: number, range: fun():u.Range, text: fun():string }|nil function Range:line(l) if l < 0 then l = self:line_count() + l + 1 end if l > self:line_count() then return end @@ -1043,6 +1056,9 @@ end -- opkeymap -------------------------------------------------------------------------------- +-- NOTE: These must be global because Neovim's 'operatorfunc' option only +-- accepts VimScript expressions (v:lua.FunctionName), not direct Lua +-- references. This is a Neovim API limitation. --- @type fun(range: u.Range): nil|(fun():any) local __U__OpKeymapOpFunc_rhs = nil @@ -1238,7 +1254,6 @@ local utils = {} --- -- Perform search and replace... --- end, { nargs = 2, range = true }) --- ``` --- luacheck: ignore function utils.ucmd(name, cmd, opts) opts = opts or {} local cmd2 = cmd