diff --git a/lua/u.lua b/lua/u.lua new file mode 100644 index 0000000..4632dc5 --- /dev/null +++ b/lua/u.lua @@ -0,0 +1,1309 @@ +local M = {} + +-------------------------------------------------------------------------------- +-- Local helpers +-------------------------------------------------------------------------------- + +local MAX_COL = vim.v.maxcol + +--- @param bufnr number +--- @param lnum number 1-based +local function line_text(bufnr, lnum) + return vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)[1] +end + +local ESC = vim.api.nvim_replace_termcodes('', true, false, true) +local NS = vim.api.nvim_create_namespace 'u.range' + +-------------------------------------------------------------------------------- +-- Pos class +-------------------------------------------------------------------------------- + +--- @class u.Pos +--- @field bufnr integer buffer number +--- @field lnum integer 1-based line index +--- @field col integer 1-based column index +--- @field off number +local Pos = {} +Pos.__index = Pos +Pos.MAX_COL = MAX_COL + +function Pos.__tostring(self) + if self.off ~= 0 then + return string.format('Pos(%d:%d){bufnr=%d, off=%d}', self.lnum, self.col, self.bufnr, self.off) + else + return string.format('Pos(%d:%d){bufnr=%d}', self.lnum, self.col, self.bufnr) + end +end + +--- @param bufnr? number +--- @param lnum number 1-based +--- @param col number 1-based +--- @param off? number +--- @return u.Pos +function Pos.new(bufnr, lnum, col, off) + if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end + if off == nil then off = 0 end + --- @type u.Pos + return setmetatable({ + bufnr = bufnr, + lnum = lnum, + col = col, + off = off, + }, Pos) +end + +--- @param bufnr? number +--- @param lnum0 number 1-based +--- @param col0 number 1-based +--- @param off? number +function Pos.from00(bufnr, lnum0, col0, off) return Pos.new(bufnr, lnum0 + 1, col0 + 1, off) end + +--- @param bufnr? number +--- @param lnum0 number 1-based +--- @param col1 number 1-based +--- @param off? number +function Pos.from01(bufnr, lnum0, col1, off) return Pos.new(bufnr, lnum0 + 1, col1, off) end + +--- @param bufnr? number +--- @param lnum1 number 1-based +--- @param col0 number 1-based +--- @param off? number +function Pos.from10(bufnr, lnum1, col0, off) return Pos.new(bufnr, lnum1, col0 + 1, off) end + +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.__le(a, b) return a < b or a == b end +function Pos.__eq(a, b) + return getmetatable(a) == Pos + and getmetatable(b) == Pos + and a.bufnr == b.bufnr + and a.lnum == b.lnum + and a.col == b.col +end +function Pos.__add(x, y) + if type(x) == 'number' then + x, y = y, x + end + if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end + return x:next(y) +end +function Pos.__sub(x, y) + if type(x) == 'number' then + x, y = y, x + end + if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end + return x:next(-y) +end + +--- @param bufnr number +--- @param lnum number +function Pos.from_eol(bufnr, lnum) + if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end + local pos = Pos.new(bufnr, lnum, 0) + pos.col = pos:line():len() + return pos +end + +--- @param name string +--- @return u.Pos +function Pos.from_pos(name) + local p = vim.fn.getpos(name) + return Pos.new(p[1], p[2], p[3], p[4]) +end + +function Pos:is_invalid() return self.lnum == 0 and self.col == 0 and self.off == 0 end + +function Pos:clone() return Pos.new(self.bufnr, self.lnum, self.col, self.off) end + +--- @return boolean +function Pos:is_col_max() return self.col == MAX_COL end + +--- Normalize the position to a real position (take into account vim.v.maxcol). +function Pos:as_real() + local maxlen = #line_text(self.bufnr, self.lnum) + local col = self.col + if col > maxlen then + -- We could use utilities in this file to get the given line, but + -- since this is a low-level function, we are going to optimize and + -- use the API directly: + col = maxlen + end + return Pos.new(self.bufnr, self.lnum, col, self.off) +end + +function Pos:as_vim() return { self.bufnr, self.lnum, self.col, self.off } end + +function Pos:eol() return Pos.from_eol(self.bufnr, self.lnum) end + +--- @param pos string +function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end + +--- @param winnr? integer +function Pos:save_to_cursor(winnr) + vim.api.nvim_win_set_cursor(winnr or 0, { self.lnum, self.col - 1 }) +end + +--- @param mark string +function Pos:save_to_mark(mark) + local p = self:as_real() + vim.api.nvim_buf_set_mark(p.bufnr, mark, p.lnum, p.col - 1, {}) +end + +--- @return string +function Pos:char() + local line = line_text(self.bufnr, self.lnum) + if line == nil then return '' end + return line:sub(self.col, self.col) +end + +function Pos:line() return line_text(self.bufnr, self.lnum) end + +--- @param dir? -1|1 +--- @param must? boolean +--- @return u.Pos|nil +function Pos:next(dir, must) + if must == nil then must = false end + + if dir == nil or dir == 1 then + -- Next: + local num_lines = vim.api.nvim_buf_line_count(self.bufnr) + local last_line = line_text(self.bufnr, num_lines) + if self.lnum == num_lines and self.col == #last_line then + if must then error 'error in Pos:next(): Pos:next() returned nil' end + return nil + end + + local col = self.col + 1 + local line = self.lnum + local line_max_col = #line_text(self.bufnr, self.lnum) + if col > line_max_col then + col = 1 + line = line + 1 + end + return Pos.new(self.bufnr, line, col, self.off) + else + -- Previous: + if self.col == 1 and self.lnum == 1 then + if must then error 'error in Pos:next(): Pos:next() returned nil' end + return nil + end + + local col = self.col - 1 + local line = self.lnum + local prev_line_max_col = #(line_text(self.bufnr, self.lnum - 1) or '') + if col < 1 then + col = math.max(prev_line_max_col, 1) + line = line - 1 + end + return Pos.new(self.bufnr, line, col, self.off) + end +end + +--- @param dir? -1|1 +function Pos:must_next(dir) + local next = self:next(dir, true) + if next == nil then error 'unreachable' end + return next +end + +--- @param dir -1|1 +--- @param predicate fun(p: u.Pos): boolean +--- @param test_current? boolean +function Pos:next_while(dir, predicate, test_current) + if test_current and not predicate(self) then return end + local curr = self + while true do + local next = curr:next(dir) + if next == nil or not predicate(next) then break end + curr = next + end + return curr +end + +--- @param dir -1|1 +--- @param predicate string|fun(p: u.Pos): boolean +function Pos:find_next(dir, predicate) + if type(predicate) == 'string' then + local s = predicate + predicate = function(p) return s == p:char() end + end + + --- @type u.Pos|nil + local curr = self + while curr ~= nil do + if predicate(curr) then return curr end + curr = curr:next(dir) + end + return curr +end + +--- Finds the matching bracket/paren for the current position. +--- @param max_chars? number|nil +--- @param invocations? u.Pos[] +--- @return u.Pos|nil +function Pos:find_match(max_chars, invocations) + if invocations == nil then invocations = {} end + if vim.tbl_contains(invocations, function(p) return self == p end, { predicate = true }) then + return nil + end + table.insert(invocations, self) + + local openers = { '{', '[', '(', '<' } + local closers = { '}', ']', ')', '>' } + local c = self:char() + local is_opener = vim.tbl_contains(openers, c) + local is_closer = vim.tbl_contains(closers, c) + if not is_opener and not is_closer then return nil end + + local i, _ = vim + .iter(is_opener and openers or closers) + :enumerate() + :find(function(_, c2) return c == c2 end) + -- Store the character we will be looking for: + local c_match = (is_opener and closers or openers)[i] + + --- @type u.Pos|nil + local cur = self + --- `adv` is a helper that moves the current position backward or forward, + --- depending on whether we are looking for an opener or a closer. It returns + --- nil if 1) the watch-dog `max_chars` falls bellow 0, or 2) if we have gone + --- beyond the beginning/end of the file. + --- @return u.Pos|nil + local function adv() + if cur == nil then return nil end + + if max_chars ~= nil then + max_chars = max_chars - 1 + if max_chars < 0 then return nil end + end + + return cur:next(is_opener and 1 or -1) + end + + -- scan until we find `c_match`: + cur = adv() + while cur ~= nil and cur:char() ~= c_match do + cur = adv() + if cur == nil then break end + + local c2 = cur:char() + if c2 == c_match then break end + + if vim.tbl_contains(openers, c2) or vim.tbl_contains(closers, c2) then + cur = cur:find_match(max_chars, invocations) + cur = adv() -- move past the match + end + end + + return cur +end + +--- @param lines string|string[] +function Pos:insert_before(lines) + if type(lines) == 'string' then lines = vim.split(lines, '\n') end + vim.api.nvim_buf_set_text( + self.bufnr, + self.lnum - 1, + self.col - 1, + self.lnum - 1, + self.col - 1, + lines + ) +end + +-------------------------------------------------------------------------------- +-- Range class (forward declared for Extmark) +-------------------------------------------------------------------------------- + +local Range = {} + +-------------------------------------------------------------------------------- +-- Extmark class +-------------------------------------------------------------------------------- + +--- @class u.Extmark +--- @field bufnr integer +--- @field id integer +--- @field nsid integer +local Extmark = {} +Extmark.__index = Extmark + +--- @param bufnr integer +--- @param nsid integer +--- @param id integer +function Extmark.new(bufnr, nsid, id) + return setmetatable({ + bufnr = bufnr, + nsid = nsid, + id = id, + }, Extmark) +end + +--- @param range u.Range +--- @param nsid integer +function Extmark.from_range(range, nsid) + local r = range:to_charwise() + local stop = r.stop or r.start + local end_row = stop.lnum - 1 + local end_col = stop.col + if range.mode == 'V' then + end_row = end_row + 1 + end_col = 0 + end + local id = vim.api.nvim_buf_set_extmark(r.start.bufnr, nsid, r.start.lnum - 1, r.start.col - 1, { + right_gravity = false, + end_right_gravity = true, + end_row = end_row, + end_col = end_col, + }) + return Extmark.new(r.start.bufnr, nsid, id) +end + +function Extmark:range() + local raw_extmark = + vim.api.nvim_buf_get_extmark_by_id(self.bufnr, self.nsid, self.id, { details = true }) + local start_row0, start_col0, details = unpack(raw_extmark) + + --- @type u.Pos + local start = Pos.from00(self.bufnr, start_row0, start_col0) + --- @type u.Pos? + local stop = details + and details.end_row + and details.end_col + and Pos.from01(self.bufnr, details.end_row, details.end_col) + + local n_buf_lines = vim.api.nvim_buf_line_count(self.bufnr) + if stop and stop.lnum > n_buf_lines then + stop.lnum = n_buf_lines + stop = stop:eol() + end + if stop and stop.col == 0 then + stop.col = 1 + stop = stop:next(-1) + end + + return Range.new(start, stop, 'v') +end + +function Extmark:delete() vim.api.nvim_buf_del_extmark(self.bufnr, self.nsid, self.id) end + +-------------------------------------------------------------------------------- +-- Range class (full implementation) +-------------------------------------------------------------------------------- + +Range.__index = Range +function Range.__tostring(self) + --- @param p u.Pos + local function posstr(p) + if p == nil then + return 'nil' + elseif p.off ~= 0 then + return string.format('Pos(%d:%d){off=%d}', p.lnum, p.col, p.off) + else + return string.format('Pos(%d:%d)', p.lnum, p.col) + end + end + + local _1 = posstr(self.start) + local _2 = posstr(self.stop) + return string.format( + 'Range{bufnr=%d, mode=%s, start=%s, stop=%s}', + self.start.bufnr, + self.mode, + _1, + _2 + ) +end + +-------------------------------------------------------------------------------- +-- Range constructors: +-------------------------------------------------------------------------------- + +--- @param start u.Pos +--- @param stop u.Pos|nil +--- @param mode? 'v'|'V' +--- @return u.Range +function Range.new(start, stop, mode) + if stop ~= nil and stop < start then + start, stop = stop, start + end + + local r = { start = start, stop = stop, mode = mode or 'v' } + + setmetatable(r, Range) + return r +end + +--- @param ranges (u.Range|nil)[] +function Range.smallest(ranges) + --- @type u.Range[] + ranges = vim.iter(ranges):filter(function(r) return r ~= nil and not r:is_empty() end):totable() + if #ranges == 0 then return nil end + + -- find smallest match + local smallest = ranges[1] + for _, r in ipairs(ranges) do + local start, stop = r.start, r.stop + if start > smallest.start and stop < smallest.stop then smallest = r end + end + return smallest +end + +--- @param lpos string +--- @param rpos string +--- @return u.Range +function Range.from_marks(lpos, rpos) + local start = Pos.from_pos(lpos) + local stop = Pos.from_pos(rpos) + + --- @type 'v'|'V' + local mode + if stop:is_col_max() then + mode = 'V' + else + mode = 'v' + end + + return Range.new(start, stop, mode) +end + +--- @param bufnr? number +function Range.from_buf_text(bufnr) + if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end + local num_lines = vim.api.nvim_buf_line_count(bufnr) + + local start = Pos.new(bufnr, 1, 1) + local stop = Pos.new(bufnr, num_lines, Pos.MAX_COL) + return Range.new(start, stop, 'V') +end + +--- @param bufnr? number +--- @param line number 1-based line index +function Range.from_line(bufnr, line) return Range.from_lines(bufnr, line, line) end + +--- @param bufnr? number +--- @param start_line number based line index +--- @param stop_line number based line index +function Range.from_lines(bufnr, start_line, stop_line) + if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end + if stop_line < 0 then + local num_lines = vim.api.nvim_buf_line_count(bufnr) + stop_line = num_lines + stop_line + 1 + end + return Range.new(Pos.new(bufnr, start_line, 1), Pos.new(bufnr, stop_line, Pos.MAX_COL), 'V') +end + +--- @param motion string +--- @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: + 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: + --- @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. + local original_state = { + winview = vim.fn.winsaveview(), + regquote = vim.fn.getreg '"', + cursor = vim.fn.getpos '.', + pos_lbrack = vim.fn.getpos "'[", + pos_rbrack = vim.fn.getpos "']", + opfunc = vim.go.operatorfunc, + prev_captured_range = _G.Range__from_motion_opfunc_captured_range, + prev_mode = vim.fn.mode(), + vinf = Range.from_vtext(), + } + --- @type u.Range|nil + _G.Range__from_motion_opfunc_captured_range = nil + + vim.api.nvim_buf_call(opts.bufnr, function() + if opts.pos ~= nil then opts.pos:save_to_pos '.' end + + _G.Range__from_motion_opfunc = function(ty) + _G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty) + end + local old_eventignore = vim.o.eventignore + vim.o.eventignore = 'all' + vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc' + vim.cmd { + cmd = 'normal', + bang = not opts.user_defined, + args = { ESC .. 'g@' .. motion }, + mods = { silent = true }, + } + vim.o.eventignore = old_eventignore + end) + local captured_range = _G.Range__from_motion_opfunc_captured_range + + -- Restore original state: + vim.fn.winrestview(original_state.winview) + vim.fn.setreg('"', original_state.regquote) + vim.fn.setpos('.', original_state.cursor) + vim.fn.setpos("'[", original_state.pos_lbrack) + vim.fn.setpos("']", original_state.pos_rbrack) + 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: + 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- + -- mark is placed before the closing character. + (is_quote_txtobj and scope == 'i' and captured_range.stop:char() == motion_rest) + -- *Sigh*, this also sometimes happens for `it` as well. + or (motion == 'it' and captured_range.stop:char() == '<') + then + captured_range.stop = captured_range.stop:next(-1) or captured_range.stop + end + if is_quote_txtobj and scope == 'a' then + captured_range.start = captured_range.start:find_next(1, motion_rest) or captured_range.start + captured_range.stop = captured_range.stop:find_next(-1, motion_rest) or captured_range.stop + end + + if + opts.contains_cursor and not captured_range:contains(Pos.new(unpack(original_state.cursor))) + then + return nil + end + + return captured_range +end + +--- @param opts? { contains_cursor?: boolean } +function Range.from_tsquery_caps(bufnr, query, opts) + opts = opts or { contains_cursor = true } + + local ranges = Range.from_buf_text(bufnr):tsquery(query) + if not ranges then return end + if not opts.contains_cursor then return ranges end + + local cursor = Pos.from_pos '.' + return vim.tbl_map(function(cap_ranges) + return vim + .iter(cap_ranges) + :filter( + --- @param r u.Range + function(r) return r:contains(cursor) end + ) + :totable() + end, ranges) +end + +--- Get range information from the currently selected visual text. +--- Note: from within a command mapping or an opfunc, use other specialized +--- utilities, such as: +--- * Range.from_cmd_args +--- * Range.from_op_func +function Range.from_vtext() + local r = Range.from_marks('v', '.') + if vim.fn.mode() == 'V' then r = r:to_linewise() end + return r +end + +--- Get range information from the current text range being operated on +--- as defined by an operator-pending function. Infers line-wise vs. char-wise +--- based on the type, as given by the operator-pending function. +--- @param type 'line'|'char'|'block' +function Range.from_op_func(type) + if type == 'block' then error 'block motions not supported' end + + local range = Range.from_marks("'[", "']") + if type == 'line' then range = range:to_linewise() end + return range +end + +--- Get range information from command arguments. +--- @param args unknown +--- @return u.Range|nil +function Range.from_cmd_args(args) + if args.range == 0 then return nil end + + local bufnr = vim.api.nvim_get_current_buf() + if args.range == 1 then + return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line1, Pos.MAX_COL), 'V') + end + + local is_visual = vim.fn.histget('cmd', -1):sub(1, 5) == [['<,'>]] + --- @type 'v'|'V' + local mode = is_visual and vim.fn.visualmode() or 'V' + + if is_visual then + return Range.new(Pos.from_pos "'<", Pos.from_pos "'>", mode) + else + return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line2, Pos.MAX_COL), mode) + end +end + +function Range.find_nearest_brackets() + 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 }), + } +end + +function Range.find_nearest_quotes() + return Range.smallest { + Range.from_motion([[a']], { contains_cursor = true }), + Range.from_motion([[a"]], { contains_cursor = true }), + Range.from_motion([[a`]], { contains_cursor = true }), + } +end + +-------------------------------------------------------------------------------- +-- Structural utilities: +-------------------------------------------------------------------------------- + +function Range:clone() + return Range.new(self.start:clone(), self.stop ~= nil and self.stop:clone() or nil, self.mode) +end + +function Range:is_empty() return self.stop == nil end + +function Range:to_linewise() + local r = self:clone() + + r.mode = 'V' + r.start.col = 1 + if r.stop ~= nil then r.stop.col = Pos.MAX_COL end + + return r +end + +function Range:to_charwise() + local r = self:clone() + r.mode = 'v' + if r.stop:is_col_max() then r.stop = r.stop:as_real() end + return r +end + +--- @param x u.Pos | u.Range +function Range:contains(x) + if getmetatable(x) == Pos then + return not self:is_empty() and x >= self.start and x <= self.stop + elseif getmetatable(x) == Range then + return self:contains(x.start) and self:contains(x.stop) + end + return false +end + +--- @param other u.Range +--- @return u.Range|nil, u.Range|nil +function Range:difference(other) + local outer, inner = self, other + if not outer:contains(inner) then + outer, inner = inner, outer + end + if not outer:contains(inner) then return nil, nil end + + local left + if outer.start ~= inner.start then + local stop = inner.start:clone() - 1 + left = Range.new(outer.start, stop) + else + left = Range.new(outer.start) -- empty range + end + + local right + if inner.stop ~= outer.stop then + local start = inner.stop:clone() + 1 + right = Range.new(start, outer.stop) + else + right = Range.new(inner.stop) -- empty range + end + + return left, right +end + +--- @param left string +--- @param right string +function Range:save_to_pos(left, right) + self.start:save_to_pos(left); + (self:is_empty() and self.start or self.stop):save_to_pos(right) +end + +--- @param left string +--- @param right string +function Range:save_to_marks(left, right) + self.start:save_to_mark(left); + (self:is_empty() and self.start or self.stop):save_to_mark(right) +end + +function Range:save_to_extmark() return Extmark.from_range(self, NS) end + +function Range:set_visual_selection() + if self:is_empty() then return end + 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' + end + + local curr_mode = vim.fn.mode() + if curr_mode ~= self.mode then vim.cmd.normal { args = { self.mode }, bang = true } end + + self.start:save_to_pos '.' + vim.cmd.normal { args = { 'o' }, bang = true } + self.stop:save_to_pos '.' +end + +-------------------------------------------------------------------------------- +-- Text access/manipulation utilities: +-------------------------------------------------------------------------------- + +--- @param query string +function Range:tsquery(query) + local bufnr = self.start.bufnr + + local lang = vim.treesitter.language.get_lang(vim.bo[bufnr].filetype) + if lang == nil then return end + local parser = vim.treesitter.get_parser(bufnr, lang) + if parser == nil then return end + local tree = parser:parse()[1] + if tree == nil then return end + + local root = tree:root() + local q = vim.treesitter.query.parse(lang, query) + --- @type table + local ranges = {} + for id, match, _meta in + q:iter_captures(root, bufnr, self.start.lnum - 1, (self.stop or self.start).lnum) + do + local start_row0, start_col0, stop_row0, stop_col0 = match:range() + local range = Range.new( + Pos.new(bufnr, start_row0 + 1, start_col0 + 1), + Pos.new(bufnr, stop_row0 + 1, stop_col0), + 'v' + ) + if range.stop.lnum > vim.api.nvim_buf_line_count(bufnr) then + range.stop = range.stop:must_next(-1) + end + + local capture_name = q.captures[id] + if not ranges[capture_name] then ranges[capture_name] = {} end + if self:contains(range) then table.insert(ranges[capture_name], range) end + end + + return ranges +end + +function Range:length() + if self:is_empty() then return 0 end + + local line_positions = + vim.fn.getregionpos(self.start:as_real():as_vim(), self.stop:as_real():as_vim(), { type = 'v' }) + + local len = 0 + for linenr, line in ipairs(line_positions) do + if linenr > 1 then len = len + 1 end -- each newline is counted as a char + local line_start_col = line[1][3] + local line_stop_col = line[2][3] + local line_len = line_stop_col - line_start_col + 1 + len = len + line_len + end + return len +end + +function Range:line_count() + if self:is_empty() then return 0 end + return self.stop.lnum - self.start.lnum + 1 +end + +function Range:trim_start() + if self:is_empty() then return end + + local r = self:clone() + while r.start:char():match '%s' do + local next = r.start:next(1) + if next == nil then break end + r.start = next + end + return r +end + +function Range:trim_stop() + if self:is_empty() then return end + + local r = self:clone() + while r.stop:char():match '%s' do + local next = r.stop:next(-1) + if next == nil then break end + r.stop = next + end + return r +end + +--- @param i number 1-based +--- @param j? number 1-based +function Range:sub(i, j) + local line_positions = + vim.fn.getregionpos(self.start:as_real():as_vim(), self.stop:as_real():as_vim(), { type = 'v' }) + + --- @param idx number 1-based + --- @return u.Pos|nil + local function get_pos(idx) + if idx < 0 then return get_pos(self:length() + idx + 1) end + + -- find the position of the first line that contains the i-th character: + local curr_len = 0 + for linenr, line in ipairs(line_positions) do + if linenr > 1 then curr_len = curr_len + 1 end -- each newline is counted as a char + local line_start_col = line[1][3] + local line_stop_col = line[2][3] + local line_len = line_stop_col - line_start_col + 1 + + if curr_len + line_len >= idx then + return Pos.new(self.start.bufnr, line[1][2], line_start_col + (idx - curr_len) - 1) + end + curr_len = curr_len + line_len + end + end + + local start = get_pos(i) + if not start then + -- start is inalid, 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: + return Range.new(start, nil, self.mode) + end + return Range.new(start, stop, 'v') +end + +--- @return string[] +function Range:lines() + if self:is_empty() then return {} end + return vim.fn.getregion(self.start:as_vim(), self.stop:as_vim(), { type = self.mode }) +end + +--- @return string +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 + + local line_indices = + vim.fn.getregionpos(self.start:as_vim(), self.stop:as_vim(), { type = self.mode }) + local line_bounds = line_indices[l] + + local start = Pos.new(unpack(line_bounds[1])) + local stop = Pos.new(unpack(line_bounds[2])) + return Range.new(start, stop) +end + +--- @param replacement nil|string|string[] +function Range:replace(replacement) + if replacement == nil then replacement = {} end + if type(replacement) == 'string' then replacement = vim.fn.split(replacement, '\n') end + + local bufnr = self.start.bufnr + local replace_type = (self:is_empty() and 'insert') or (self.mode == 'v' and 'region') or 'lines' + + local function update_stop_non_linewise() + local new_last_line_num = self.start.lnum + #replacement - 1 + local new_last_col = #(replacement[#replacement] or '') + if new_last_line_num == self.start.lnum then + new_last_col = new_last_col + self.start.col - 1 + end + self.stop = Pos.new(bufnr, new_last_line_num, new_last_col) + end + local function update_stop_linewise() + if #replacement == 0 then + self.stop = nil + else + local new_last_line_num = self.start.lnum - 1 + #replacement - 1 + self.stop = Pos.new(bufnr, new_last_line_num + 1, Pos.MAX_COL, self.stop.off) + end + self.mode = 'v' + end + + if replace_type == 'insert' then + -- To insert text at a given `(row, column)` location, use `start_row = + -- end_row = row` and `start_col = end_col = col`. + vim.api.nvim_buf_set_text( + bufnr, + self.start.lnum - 1, + self.start.col - 1, + self.start.lnum - 1, + self.start.col - 1, + replacement + ) + update_stop_non_linewise() + elseif replace_type == 'region' then + -- Fixup the bounds: + local max_col = #self.stop:line() + + -- Indexing is zero-based. Row indices are end-inclusive, and column indices + -- are end-exclusive. + vim.api.nvim_buf_set_text( + bufnr, + self.start.lnum - 1, + self.start.col - 1, + self.stop.lnum - 1, + math.min(self.stop.col, max_col), + replacement + ) + update_stop_non_linewise() + elseif replace_type == 'lines' then + -- Indexing is zero-based, end-exclusive. + vim.api.nvim_buf_set_lines(bufnr, self.start.lnum - 1, self.stop.lnum, true, replacement) + update_stop_linewise() + else + error 'unreachable' + end +end + +--- @param amount number +function Range:shrink(amount) + local start = self.start + local stop = self.stop + if stop == nil then return self:clone() end + + for _ = 1, amount do + local next_start = start:next(1) + local next_stop = stop:next(-1) + if next_start == nil or next_stop == nil then return end + start = next_start + stop = next_stop + if next_start > next_stop then break end + end + if start > stop then stop = nil end + return Range.new(start, stop, self.mode) +end + +--- @param amount number +function Range:must_shrink(amount) + local shrunk = self:shrink(amount) + if shrunk == nil or shrunk:is_empty() then + error 'error in Range:must_shrink: Range:shrink() returned nil' + end + return shrunk +end + +--- @param group string +--- @param opts? { timeout?: number, priority?: number, on_macro?: boolean } +function Range:highlight(group, opts) + if self:is_empty() then return end + + opts = opts or { on_macro = false } + if opts.on_macro == nil then opts.on_macro = false end + + local in_macro = vim.fn.reg_executing() ~= '' + if not opts.on_macro and in_macro then return { clear = function() end } end + + local ns = vim.api.nvim_create_namespace '' + + local winview = vim.fn.winsaveview() + vim.hl.range( + self.start.bufnr, + ns, + group, + { self.start.lnum - 1, self.start.col - 1 }, + { self.stop.lnum - 1, self.stop.col - 1 }, + { + inclusive = true, + priority = opts.priority, + timeout = opts.timeout, + regtype = self.mode, + } + ) + if not in_macro then vim.fn.winrestview(winview) end + vim.cmd.redraw() + + return { + ns = ns, + clear = function() + vim.api.nvim_buf_clear_namespace(self.start.bufnr, ns, self.start.lnum - 1, self.stop.lnum) + vim.cmd.redraw() + end, + } +end + +-------------------------------------------------------------------------------- +-- opkeymap +-------------------------------------------------------------------------------- + +--- @type fun(range: u.Range): nil|(fun():any) +local __U__OpKeymapOpFunc_rhs = nil + +--- This is the global utility function used for operatorfunc in opkeymap +--- @param ty 'line'|'char'|'block' +-- selene: allow(unused_variable) +function _G.__U__OpKeymapOpFunc(ty) + if __U__OpKeymapOpFunc_rhs ~= nil then + local range = Range.from_op_func(ty) + __U__OpKeymapOpFunc_rhs(range) + end +end + +--- Registers a function that operates on a text-object, triggered by the given prefix (lhs). +--- It works in the following way: +--- 1. An expression-map is set, so that whatever the callback returns is executed by Vim (in this case `g@`) +--- g@: tells vim to way for a motion, and then call operatorfunc. +--- 2. The operatorfunc is set to a lua function that computes the range being operated over, that +--- then calls the original passed callback with said range. +--- @param mode string|string[] +--- @param lhs string +--- @param rhs fun(range: u.Range): nil +--- @diagnostic disable-next-line: undefined-doc-name +--- @param opts? vim.keymap.set.Opts +local function opkeymap(mode, lhs, rhs, opts) + vim.keymap.set(mode, lhs, function() + -- We don't need to wrap the operation in a repeat, because expr mappings are + -- repeated seamlessly by Vim anyway. In addition, the u.repeat:`.` mapping will + -- set IS_REPEATING to true, so that callbacks can check if they should used cached + -- values. + __U__OpKeymapOpFunc_rhs = rhs + vim.o.operatorfunc = 'v:lua.__U__OpKeymapOpFunc' + return 'g@' + end, vim.tbl_extend('force', opts or {}, { expr = true })) +end + +-------------------------------------------------------------------------------- +-- txtobj +-------------------------------------------------------------------------------- + +local txtobj = {} + +--- @param key_seq string +--- @param fn fun(key_seq: string):u.Range|nil +--- @param opts? { buffer: number|nil } +function txtobj.define(key_seq, fn, opts) + if opts ~= nil and opts.buffer == 0 then opts.buffer = vim.api.nvim_get_current_buf() end + + local function handle_visual() + local range = fn(key_seq) + if range == nil or range:is_empty() then + vim.cmd.normal(ESC) + return + end + range:set_visual_selection() + end + vim.keymap.set({ 'x' }, key_seq, handle_visual, opts and { buffer = opts.buffer } or nil) + + local function handle_normal() + local range = fn(key_seq) + if range == nil then return end + + if not range:is_empty() then + range:set_visual_selection() + else + local original_eventignore = vim.go.eventignore + vim.go.eventignore = 'all' + + -- insert a single space, so we can select it: + local p = range.start + p:insert_before ' ' + vim.go.eventignore = original_eventignore + + -- select the space: + Range.new(p, p, 'v'):set_visual_selection() + end + end + vim.keymap.set({ 'o' }, key_seq, handle_normal, opts and { buffer = opts.buffer } or nil) +end + +-------------------------------------------------------------------------------- +-- repeat +-------------------------------------------------------------------------------- + +local repeat_ = {} + +local IS_REPEATING = false +--- @type function|nil +local REPEAT_ACTION = nil + +local function is_repeatable_last_mutator() return vim.b.changedtick <= (vim.b.my_changedtick or 0) end + +--- @param f fun() +function repeat_.run_repeatable(f) + REPEAT_ACTION = f + ---@diagnostic disable-next-line: need-check-nil + REPEAT_ACTION() + vim.b.my_changedtick = vim.b.changedtick +end + +function repeat_.is_repeating() return IS_REPEATING end + +function repeat_.setup() + vim.keymap.set('n', '.', function() + IS_REPEATING = true + for _ = 1, vim.v.count1 do + if is_repeatable_last_mutator() and type(REPEAT_ACTION) == 'function' then + repeat_.run_repeatable(REPEAT_ACTION) + else + vim.cmd { cmd = 'normal', args = { '.' }, bang = true } + end + end + IS_REPEATING = false + end) + vim.keymap.set('n', 'u', function() + local was_repeatable_last_mutator = is_repeatable_last_mutator() + for _ = 1, vim.v.count1 do + vim.cmd { cmd = 'normal', args = { 'u' }, bang = true } + end + if was_repeatable_last_mutator then vim.b.my_changedtick = vim.b.changedtick end + end) +end + +-------------------------------------------------------------------------------- +-- utils +-------------------------------------------------------------------------------- + +local utils = {} + +-- +-- Types +-- + +--- @class u.utils.QfItem +--- @field col number +--- @field filename string +--- @field kind string +--- @field lnum number +--- @field text string + +--- @class u.utils.RawCmdArgs +--- @field args string +--- @field bang boolean +--- @field count number +--- @field fargs string[] +--- @field line1 number +--- @field line2 number +--- @field mods string +--- @field name string +--- @field range 0|1|2 +--- @field reg string +--- @field smods any + +--- @class u.utils.CmdArgs: u.utils.RawCmdArgs +--- @field info u.Range|nil + +--- @class u.utils.UcmdArgs +--- @field nargs? 0|1|'*'|'?'|'+' +--- @field range? boolean|'%'|number +--- @field count? boolean|number +--- @field addr? string +--- @field completion? string +--- @field force? boolean +--- @field preview? fun(opts: u.utils.UcmdArgs, ns: integer, buf: integer):0|1|2 + +--- Creates a user command with enhanced argument processing. +--- Automatically computes range information and attaches it as `args.info`. +--- +--- @param name string The command name (without the leading colon) +--- @param cmd string | fun(args: u.utils.CmdArgs): any Command implementation +--- @param opts? u.utils.UcmdArgs Command options (nargs, range, etc.) +--- +--- @usage +--- ```lua +--- -- Create a command that works with visual selections: +--- utils.ucmd('MyCmd', function(args) +--- -- Print the visually selected text: +--- vim.print(args.info:text()) +--- -- Or get the selection as an array of lines: +--- vim.print(args.info:lines()) +--- end, { nargs = '*', range = true }) +--- +--- -- Create a command that processes the current line: +--- utils.ucmd('ProcessLine', function(args) +--- local line_text = args.info:text() +--- -- Process the line... +--- end, { range = '%' }) +--- +--- -- Create a command with arguments: +--- utils.ucmd('SearchReplace', function(args) +--- local pattern, replacement = args.fargs[1], args.fargs[2] +--- local text = args.info:text() +--- -- Perform search and replace... +--- end, { nargs = 2, range = true }) +--- ``` +-- luacheck: ignore +function utils.ucmd(name, cmd, opts) + opts = opts or {} + local cmd2 = cmd + if type(cmd) == 'function' then + cmd2 = function(args) + args.info = Range.from_cmd_args(args) + return cmd(args) + end + end + vim.api.nvim_create_user_command(name, cmd2, opts or {} --[[@as any]]) +end + +--- Creates command arguments for delegating from one command to another. +--- Preserves all relevant context (range, modifiers, bang, etc.) when +--- implementing a derived command in terms of a base command. +--- +--- @param current_args vim.api.keyset.create_user_command.command_args|u.utils.RawCmdArgs The arguments from the current command +--- @return vim.api.keyset.cmd Arguments suitable for vim.cmd() calls +--- +--- @usage +--- ```lua +--- -- Implement :MyEdit in terms of :edit, preserving all context: +--- utils.ucmd('MyEdit', function(args) +--- local delegated_args = utils.create_delegated_cmd_args(args) +--- -- Add custom logic here... +--- vim.cmd.edit(delegated_args) +--- end, { nargs = '*', range = true, bang = true }) +--- +--- -- Implement :MySubstitute that delegates to :substitute: +--- utils.ucmd('MySubstitute', function(args) +--- -- Pre-process arguments +--- local pattern = preprocess_pattern(args.fargs[1]) +--- +--- local delegated_args = utils.create_delegated_cmd_args(args) +--- delegated_args.args = { pattern, args.fargs[2] } +--- +--- vim.cmd.substitute(delegated_args) +--- end, { nargs = 2, range = true, bang = true }) +--- ``` +function utils.create_delegated_cmd_args(current_args) + --- @type vim.api.keyset.cmd + local args = { + range = current_args.range == 1 and { current_args.line1 } + or current_args.range == 2 and { current_args.line1, current_args.line2 } + or nil, + count = (current_args.count ~= -1 and current_args.range == 0) and current_args.count or nil, + reg = current_args.reg ~= '' and current_args.reg or nil, + bang = current_args.bang or nil, + args = #current_args.fargs > 0 and current_args.fargs or nil, + mods = current_args.smods, + } + return args +end + +-------------------------------------------------------------------------------- +-- Exports +-------------------------------------------------------------------------------- + +M.Pos = Pos +M.Extmark = Extmark +M.Range = Range +M.opkeymap = opkeymap +M.define_txtobj = txtobj.define +M.repeat_ = repeat_ +M.ucmd = utils.ucmd +M.create_delegated_cmd_args = utils.create_delegated_cmd_args + +return M diff --git a/lua/u/.luarc.json b/lua/u/.luarc.json deleted file mode 100644 index 43c1071..0000000 --- a/lua/u/.luarc.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json", - "diagnostics.globals": ["assert", "vim"], - "runtime.version": "LuaJIT" -} diff --git a/lua/u/extmark.lua b/lua/u/extmark.lua deleted file mode 100644 index 36fca25..0000000 --- a/lua/u/extmark.lua +++ /dev/null @@ -1,71 +0,0 @@ -local Pos = require 'u.pos' - ----@class u.Extmark ----@field bufnr integer ----@field id integer ----@field nsid integer -local Extmark = {} -Extmark.__index = Extmark - ---- @param bufnr integer ---- @param nsid integer ---- @param id integer -function Extmark.new(bufnr, nsid, id) - return setmetatable({ - bufnr = bufnr, - nsid = nsid, - id = id, - }, Extmark) -end - ---- @param range u.Range ---- @param nsid integer -function Extmark.from_range(range, nsid) - local r = range:to_charwise() - local stop = r.stop or r.start - local end_row = stop.lnum - 1 - local end_col = stop.col - if range.mode == 'V' then - end_row = end_row + 1 - end_col = 0 - end - local id = vim.api.nvim_buf_set_extmark(r.start.bufnr, nsid, r.start.lnum - 1, r.start.col - 1, { - right_gravity = false, - end_right_gravity = true, - end_row = end_row, - end_col = end_col, - }) - return Extmark.new(r.start.bufnr, nsid, id) -end - -function Extmark:range() - local Range = require 'u.range' - - local raw_extmark = - vim.api.nvim_buf_get_extmark_by_id(self.bufnr, self.nsid, self.id, { details = true }) - local start_row0, start_col0, details = unpack(raw_extmark) - - --- @type u.Pos - local start = Pos.from00(self.bufnr, start_row0, start_col0) - --- @type u.Pos? - local stop = details - and details.end_row - and details.end_col - and Pos.from01(self.bufnr, details.end_row, details.end_col) - - local n_buf_lines = vim.api.nvim_buf_line_count(self.bufnr) - if stop and stop.lnum > n_buf_lines then - stop.lnum = n_buf_lines - stop = stop:eol() - end - if stop and stop.col == 0 then - stop.col = 1 - stop = stop:next(-1) - end - - return Range.new(start, stop, 'v') -end - -function Extmark:delete() vim.api.nvim_buf_del_extmark(self.bufnr, self.nsid, self.id) end - -return Extmark diff --git a/lua/u/opkeymap.lua b/lua/u/opkeymap.lua deleted file mode 100644 index 9a83a9d..0000000 --- a/lua/u/opkeymap.lua +++ /dev/null @@ -1,39 +0,0 @@ -local Range = require 'u.range' - ---- @type fun(range: u.Range): nil|(fun():any) -local __U__OpKeymapOpFunc_rhs = nil - ---- This is the global utility function used for operatorfunc in opkeymap ---- @param ty 'line'|'char'|'block' --- selene: allow(unused_variable) -function _G.__U__OpKeymapOpFunc(ty) - if __U__OpKeymapOpFunc_rhs ~= nil then - local range = Range.from_op_func(ty) - __U__OpKeymapOpFunc_rhs(range) - end -end - ---- Registers a function that operates on a text-object, triggered by the given prefix (lhs). ---- It works in the following way: ---- 1. An expression-map is set, so that whatever the callback returns is executed by Vim (in this case `g@`) ---- g@: tells vim to way for a motion, and then call operatorfunc. ---- 2. The operatorfunc is set to a lua function that computes the range being operated over, that ---- then calls the original passed callback with said range. ---- @param mode string|string[] ---- @param lhs string ---- @param rhs fun(range: u.Range): nil ---- @diagnostic disable-next-line: undefined-doc-name ---- @param opts? vim.keymap.set.Opts -local function opkeymap(mode, lhs, rhs, opts) - vim.keymap.set(mode, lhs, function() - -- We don't need to wrap the operation in a repeat, because expr mappings are - -- repeated seamlessly by Vim anyway. In addition, the u.repeat:`.` mapping will - -- set IS_REPEATING to true, so that callbacks can check if they should used cached - -- values. - __U__OpKeymapOpFunc_rhs = rhs - vim.o.operatorfunc = 'v:lua.__U__OpKeymapOpFunc' - return 'g@' - end, vim.tbl_extend('force', opts or {}, { expr = true })) -end - -return opkeymap diff --git a/lua/u/pos.lua b/lua/u/pos.lua deleted file mode 100644 index ba57352..0000000 --- a/lua/u/pos.lua +++ /dev/null @@ -1,303 +0,0 @@ -local MAX_COL = vim.v.maxcol - ---- @param bufnr number ---- @param lnum number 1-based -local function line_text(bufnr, lnum) - return vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false)[1] -end - ---- @class u.Pos ---- @field bufnr integer buffer number ---- @field lnum integer 1-based line index ---- @field col integer 1-based column index ---- @field off number -local Pos = {} -Pos.__index = Pos -Pos.MAX_COL = MAX_COL - -function Pos.__tostring(self) - if self.off ~= 0 then - return string.format('Pos(%d:%d){bufnr=%d, off=%d}', self.lnum, self.col, self.bufnr, self.off) - else - return string.format('Pos(%d:%d){bufnr=%d}', self.lnum, self.col, self.bufnr) - end -end - ---- @param bufnr? number ---- @param lnum number 1-based ---- @param col number 1-based ---- @param off? number ---- @return u.Pos -function Pos.new(bufnr, lnum, col, off) - if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end - if off == nil then off = 0 end - --- @type u.Pos - return setmetatable({ - bufnr = bufnr, - lnum = lnum, - col = col, - off = off, - }, Pos) -end - ---- @param bufnr? number ---- @param lnum0 number 1-based ---- @param col0 number 1-based ---- @param off? number -function Pos.from00(bufnr, lnum0, col0, off) return Pos.new(bufnr, lnum0 + 1, col0 + 1, off) end - ---- @param bufnr? number ---- @param lnum0 number 1-based ---- @param col1 number 1-based ---- @param off? number -function Pos.from01(bufnr, lnum0, col1, off) return Pos.new(bufnr, lnum0 + 1, col1, off) end - ---- @param bufnr? number ---- @param lnum1 number 1-based ---- @param col0 number 1-based ---- @param off? number -function Pos.from10(bufnr, lnum1, col0, off) return Pos.new(bufnr, lnum1, col0 + 1, off) end - -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.__le(a, b) return a < b or a == b end -function Pos.__eq(a, b) - return getmetatable(a) == Pos - and getmetatable(b) == Pos - and a.bufnr == b.bufnr - and a.lnum == b.lnum - and a.col == b.col -end -function Pos.__add(x, y) - if type(x) == 'number' then - x, y = y, x - end - if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end - return x:next(y) -end -function Pos.__sub(x, y) - if type(x) == 'number' then - x, y = y, x - end - if getmetatable(x) ~= Pos or type(y) ~= 'number' then return nil end - return x:next(-y) -end - ---- @param bufnr number ---- @param lnum number -function Pos.from_eol(bufnr, lnum) - if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end - local pos = Pos.new(bufnr, lnum, 0) - pos.col = pos:line():len() - return pos -end - ---- @param name string ---- @return u.Pos -function Pos.from_pos(name) - local p = vim.fn.getpos(name) - return Pos.new(p[1], p[2], p[3], p[4]) -end - -function Pos:is_invalid() return self.lnum == 0 and self.col == 0 and self.off == 0 end - -function Pos:clone() return Pos.new(self.bufnr, self.lnum, self.col, self.off) end - ---- @return boolean -function Pos:is_col_max() return self.col == MAX_COL end - ---- Normalize the position to a real position (take into account vim.v.maxcol). -function Pos:as_real() - local maxlen = #line_text(self.bufnr, self.lnum) - local col = self.col - if col > maxlen then - -- We could use utilities in this file to get the given line, but - -- since this is a low-level function, we are going to optimize and - -- use the API directly: - col = maxlen - end - return Pos.new(self.bufnr, self.lnum, col, self.off) -end - -function Pos:as_vim() return { self.bufnr, self.lnum, self.col, self.off } end - -function Pos:eol() return Pos.from_eol(self.bufnr, self.lnum) end - ---- @param pos string -function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end - ---- @param winnr? integer -function Pos:save_to_cursor(winnr) - vim.api.nvim_win_set_cursor(winnr or 0, { self.lnum, self.col - 1 }) -end - ---- @param mark string -function Pos:save_to_mark(mark) - local p = self:as_real() - vim.api.nvim_buf_set_mark(p.bufnr, mark, p.lnum, p.col - 1, {}) -end - ---- @return string -function Pos:char() - local line = line_text(self.bufnr, self.lnum) - if line == nil then return '' end - return line:sub(self.col, self.col) -end - -function Pos:line() return line_text(self.bufnr, self.lnum) end - ---- @param dir? -1|1 ---- @param must? boolean ---- @return u.Pos|nil -function Pos:next(dir, must) - if must == nil then must = false end - - if dir == nil or dir == 1 then - -- Next: - local num_lines = vim.api.nvim_buf_line_count(self.bufnr) - local last_line = line_text(self.bufnr, num_lines) - if self.lnum == num_lines and self.col == #last_line then - if must then error 'error in Pos:next(): Pos:next() returned nil' end - return nil - end - - local col = self.col + 1 - local line = self.lnum - local line_max_col = #line_text(self.bufnr, self.lnum) - if col > line_max_col then - col = 1 - line = line + 1 - end - return Pos.new(self.bufnr, line, col, self.off) - else - -- Previous: - if self.col == 1 and self.lnum == 1 then - if must then error 'error in Pos:next(): Pos:next() returned nil' end - return nil - end - - local col = self.col - 1 - local line = self.lnum - local prev_line_max_col = #(line_text(self.bufnr, self.lnum - 1) or '') - if col < 1 then - col = math.max(prev_line_max_col, 1) - line = line - 1 - end - return Pos.new(self.bufnr, line, col, self.off) - end -end - ---- @param dir? -1|1 -function Pos:must_next(dir) - local next = self:next(dir, true) - if next == nil then error 'unreachable' end - return next -end - ---- @param dir -1|1 ---- @param predicate fun(p: u.Pos): boolean ---- @param test_current? boolean -function Pos:next_while(dir, predicate, test_current) - if test_current and not predicate(self) then return end - local curr = self - while true do - local next = curr:next(dir) - if next == nil or not predicate(next) then break end - curr = next - end - return curr -end - ---- @param dir -1|1 ---- @param predicate string|fun(p: u.Pos): boolean -function Pos:find_next(dir, predicate) - if type(predicate) == 'string' then - local s = predicate - predicate = function(p) return s == p:char() end - end - - --- @type u.Pos|nil - local curr = self - while curr ~= nil do - if predicate(curr) then return curr end - curr = curr:next(dir) - end - return curr -end - ---- Finds the matching bracket/paren for the current position. ---- @param max_chars? number|nil ---- @param invocations? u.Pos[] ---- @return u.Pos|nil -function Pos:find_match(max_chars, invocations) - if invocations == nil then invocations = {} end - if vim.tbl_contains(invocations, function(p) return self == p end, { predicate = true }) then - return nil - end - table.insert(invocations, self) - - local openers = { '{', '[', '(', '<' } - local closers = { '}', ']', ')', '>' } - local c = self:char() - local is_opener = vim.tbl_contains(openers, c) - local is_closer = vim.tbl_contains(closers, c) - if not is_opener and not is_closer then return nil end - - local i, _ = vim - .iter(is_opener and openers or closers) - :enumerate() - :find(function(_, c2) return c == c2 end) - -- Store the character we will be looking for: - local c_match = (is_opener and closers or openers)[i] - - --- @type u.Pos|nil - local cur = self - --- `adv` is a helper that moves the current position backward or forward, - --- depending on whether we are looking for an opener or a closer. It returns - --- nil if 1) the watch-dog `max_chars` falls bellow 0, or 2) if we have gone - --- beyond the beginning/end of the file. - --- @return u.Pos|nil - local function adv() - if cur == nil then return nil end - - if max_chars ~= nil then - max_chars = max_chars - 1 - if max_chars < 0 then return nil end - end - - return cur:next(is_opener and 1 or -1) - end - - -- scan until we find `c_match`: - cur = adv() - while cur ~= nil and cur:char() ~= c_match do - cur = adv() - if cur == nil then break end - - local c2 = cur:char() - if c2 == c_match then break end - - if vim.tbl_contains(openers, c2) or vim.tbl_contains(closers, c2) then - cur = cur:find_match(max_chars, invocations) - cur = adv() -- move past the match - end - end - - return cur -end - ---- @param lines string|string[] -function Pos:insert_before(lines) - if type(lines) == 'string' then lines = vim.split(lines, '\n') end - vim.api.nvim_buf_set_text( - self.bufnr, - self.lnum - 1, - self.col - 1, - self.lnum - 1, - self.col - 1, - lines - ) -end - -return Pos diff --git a/lua/u/range.lua b/lua/u/range.lua deleted file mode 100644 index ff5900a..0000000 --- a/lua/u/range.lua +++ /dev/null @@ -1,658 +0,0 @@ -local Extmark = require 'u.extmark' -local Pos = require 'u.pos' - -local ESC = vim.api.nvim_replace_termcodes('', true, false, true) -local NS = vim.api.nvim_create_namespace 'u.range' - ---- @class u.Range ---- @field start u.Pos ---- @field stop u.Pos|nil ---- @field mode 'v'|'V' -local Range = {} -Range.__index = Range -function Range.__tostring(self) - --- @param p u.Pos - local function posstr(p) - if p == nil then - return 'nil' - elseif p.off ~= 0 then - return string.format('Pos(%d:%d){off=%d}', p.lnum, p.col, p.off) - else - return string.format('Pos(%d:%d)', p.lnum, p.col) - end - end - - local _1 = posstr(self.start) - local _2 = posstr(self.stop) - return string.format( - 'Range{bufnr=%d, mode=%s, start=%s, stop=%s}', - self.start.bufnr, - self.mode, - _1, - _2 - ) -end - --------------------------------------------------------------------------------- --- Range constructors: --------------------------------------------------------------------------------- - ---- @param start u.Pos ---- @param stop u.Pos|nil ---- @param mode? 'v'|'V' ---- @return u.Range -function Range.new(start, stop, mode) - if stop ~= nil and stop < start then - start, stop = stop, start - end - - local r = { start = start, stop = stop, mode = mode or 'v' } - - setmetatable(r, Range) - return r -end - ---- @param ranges (u.Range|nil)[] -function Range.smallest(ranges) - --- @type u.Range[] - ranges = vim.iter(ranges):filter(function(r) return r ~= nil and not r:is_empty() end):totable() - if #ranges == 0 then return nil end - - -- find smallest match - local smallest = ranges[1] - for _, r in ipairs(ranges) do - local start, stop = r.start, r.stop - if start > smallest.start and stop < smallest.stop then smallest = r end - end - return smallest -end - ---- @param lpos string ---- @param rpos string ---- @return u.Range -function Range.from_marks(lpos, rpos) - local start = Pos.from_pos(lpos) - local stop = Pos.from_pos(rpos) - - --- @type 'v'|'V' - local mode - if stop:is_col_max() then - mode = 'V' - else - mode = 'v' - end - - return Range.new(start, stop, mode) -end - ---- @param bufnr? number -function Range.from_buf_text(bufnr) - if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end - local num_lines = vim.api.nvim_buf_line_count(bufnr) - - local start = Pos.new(bufnr, 1, 1) - local stop = Pos.new(bufnr, num_lines, Pos.MAX_COL) - return Range.new(start, stop, 'V') -end - ---- @param bufnr? number ---- @param line number 1-based line index -function Range.from_line(bufnr, line) return Range.from_lines(bufnr, line, line) end - ---- @param bufnr? number ---- @param start_line number based line index ---- @param stop_line number based line index -function Range.from_lines(bufnr, start_line, stop_line) - if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end - if stop_line < 0 then - local num_lines = vim.api.nvim_buf_line_count(bufnr) - stop_line = num_lines + stop_line + 1 - end - return Range.new(Pos.new(bufnr, start_line, 1), Pos.new(bufnr, stop_line, Pos.MAX_COL), 'V') -end - ---- @param motion string ---- @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: - 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: - --- @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. - local original_state = { - winview = vim.fn.winsaveview(), - regquote = vim.fn.getreg '"', - cursor = vim.fn.getpos '.', - pos_lbrack = vim.fn.getpos "'[", - pos_rbrack = vim.fn.getpos "']", - opfunc = vim.go.operatorfunc, - prev_captured_range = _G.Range__from_motion_opfunc_captured_range, - prev_mode = vim.fn.mode(), - vinf = Range.from_vtext(), - } - --- @type u.Range|nil - _G.Range__from_motion_opfunc_captured_range = nil - - vim.api.nvim_buf_call(opts.bufnr, function() - if opts.pos ~= nil then opts.pos:save_to_pos '.' end - - _G.Range__from_motion_opfunc = function(ty) - _G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty) - end - local old_eventignore = vim.o.eventignore - vim.o.eventignore = 'all' - vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc' - vim.cmd { - cmd = 'normal', - bang = not opts.user_defined, - args = { ESC .. 'g@' .. motion }, - mods = { silent = true }, - } - vim.o.eventignore = old_eventignore - end) - local captured_range = _G.Range__from_motion_opfunc_captured_range - - -- Restore original state: - vim.fn.winrestview(original_state.winview) - vim.fn.setreg('"', original_state.regquote) - vim.fn.setpos('.', original_state.cursor) - vim.fn.setpos("'[", original_state.pos_lbrack) - vim.fn.setpos("']", original_state.pos_rbrack) - 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: - 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- - -- mark is placed before the closing character. - (is_quote_txtobj and scope == 'i' and captured_range.stop:char() == motion_rest) - -- *Sigh*, this also sometimes happens for `it` as well. - or (motion == 'it' and captured_range.stop:char() == '<') - then - captured_range.stop = captured_range.stop:next(-1) or captured_range.stop - end - if is_quote_txtobj and scope == 'a' then - captured_range.start = captured_range.start:find_next(1, motion_rest) or captured_range.start - captured_range.stop = captured_range.stop:find_next(-1, motion_rest) or captured_range.stop - end - - if - opts.contains_cursor and not captured_range:contains(Pos.new(unpack(original_state.cursor))) - then - return nil - end - - return captured_range -end - ---- @param opts? { contains_cursor?: boolean } -function Range.from_tsquery_caps(bufnr, query, opts) - opts = opts or { contains_cursor = true } - - local ranges = Range.from_buf_text(bufnr):tsquery(query) - if not ranges then return end - if not opts.contains_cursor then return ranges end - - local cursor = Pos.from_pos '.' - return vim.tbl_map(function(cap_ranges) - return vim - .iter(cap_ranges) - :filter( - --- @param r u.Range - function(r) return r:contains(cursor) end - ) - :totable() - end, ranges) -end - ---- Get range information from the currently selected visual text. ---- Note: from within a command mapping or an opfunc, use other specialized ---- utilities, such as: ---- * Range.from_cmd_args ---- * Range.from_op_func -function Range.from_vtext() - local r = Range.from_marks('v', '.') - if vim.fn.mode() == 'V' then r = r:to_linewise() end - return r -end - ---- Get range information from the current text range being operated on ---- as defined by an operator-pending function. Infers line-wise vs. char-wise ---- based on the type, as given by the operator-pending function. ---- @param type 'line'|'char'|'block' -function Range.from_op_func(type) - if type == 'block' then error 'block motions not supported' end - - local range = Range.from_marks("'[", "']") - if type == 'line' then range = range:to_linewise() end - return range -end - ---- Get range information from command arguments. ---- @param args unknown ---- @return u.Range|nil -function Range.from_cmd_args(args) - if args.range == 0 then return nil end - - local bufnr = vim.api.nvim_get_current_buf() - if args.range == 1 then - return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line1, Pos.MAX_COL), 'V') - end - - local is_visual = vim.fn.histget('cmd', -1):sub(1, 5) == [['<,'>]] - --- @type 'v'|'V' - local mode = is_visual and vim.fn.visualmode() or 'V' - - if is_visual then - return Range.new(Pos.from_pos "'<", Pos.from_pos "'>", mode) - else - return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line2, Pos.MAX_COL), mode) - end -end - -function Range.find_nearest_brackets() - 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 }), - } -end - -function Range.find_nearest_quotes() - return Range.smallest { - Range.from_motion([[a']], { contains_cursor = true }), - Range.from_motion([[a"]], { contains_cursor = true }), - Range.from_motion([[a`]], { contains_cursor = true }), - } -end - --------------------------------------------------------------------------------- --- Structural utilities: --------------------------------------------------------------------------------- - -function Range:clone() - return Range.new(self.start:clone(), self.stop ~= nil and self.stop:clone() or nil, self.mode) -end - -function Range:is_empty() return self.stop == nil end - -function Range:to_linewise() - local r = self:clone() - - r.mode = 'V' - r.start.col = 1 - if r.stop ~= nil then r.stop.col = Pos.MAX_COL end - - return r -end - -function Range:to_charwise() - local r = self:clone() - r.mode = 'v' - if r.stop:is_col_max() then r.stop = r.stop:as_real() end - return r -end - ---- @param x u.Pos | u.Range -function Range:contains(x) - if getmetatable(x) == Pos then - return not self:is_empty() and x >= self.start and x <= self.stop - elseif getmetatable(x) == Range then - return self:contains(x.start) and self:contains(x.stop) - end - return false -end - ---- @param other u.Range ---- @return u.Range|nil, u.Range|nil -function Range:difference(other) - local outer, inner = self, other - if not outer:contains(inner) then - outer, inner = inner, outer - end - if not outer:contains(inner) then return nil, nil end - - local left - if outer.start ~= inner.start then - local stop = inner.start:clone() - 1 - left = Range.new(outer.start, stop) - else - left = Range.new(outer.start) -- empty range - end - - local right - if inner.stop ~= outer.stop then - local start = inner.stop:clone() + 1 - right = Range.new(start, outer.stop) - else - right = Range.new(inner.stop) -- empty range - end - - return left, right -end - ---- @param left string ---- @param right string -function Range:save_to_pos(left, right) - self.start:save_to_pos(left); - (self:is_empty() and self.start or self.stop):save_to_pos(right) -end - ---- @param left string ---- @param right string -function Range:save_to_marks(left, right) - self.start:save_to_mark(left); - (self:is_empty() and self.start or self.stop):save_to_mark(right) -end - -function Range:save_to_extmark() return Extmark.from_range(self, NS) end - -function Range:set_visual_selection() - if self:is_empty() then return end - 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' - end - - local curr_mode = vim.fn.mode() - if curr_mode ~= self.mode then vim.cmd.normal { args = { self.mode }, bang = true } end - - self.start:save_to_pos '.' - vim.cmd.normal { args = { 'o' }, bang = true } - self.stop:save_to_pos '.' -end - --------------------------------------------------------------------------------- --- Text access/manipulation utilities: --------------------------------------------------------------------------------- - ---- @param query string -function Range:tsquery(query) - local bufnr = self.start.bufnr - - local lang = vim.treesitter.language.get_lang(vim.bo[bufnr].filetype) - if lang == nil then return end - local parser = vim.treesitter.get_parser(bufnr, lang) - if parser == nil then return end - local tree = parser:parse()[1] - if tree == nil then return end - - local root = tree:root() - local q = vim.treesitter.query.parse(lang, query) - --- @type table - local ranges = {} - for id, match, _meta in - q:iter_captures(root, bufnr, self.start.lnum - 1, (self.stop or self.start).lnum) - do - local start_row0, start_col0, stop_row0, stop_col0 = match:range() - local range = Range.new( - Pos.new(bufnr, start_row0 + 1, start_col0 + 1), - Pos.new(bufnr, stop_row0 + 1, stop_col0), - 'v' - ) - if range.stop.lnum > vim.api.nvim_buf_line_count(bufnr) then - range.stop = range.stop:must_next(-1) - end - - local capture_name = q.captures[id] - if not ranges[capture_name] then ranges[capture_name] = {} end - if self:contains(range) then table.insert(ranges[capture_name], range) end - end - - return ranges -end - -function Range:length() - if self:is_empty() then return 0 end - - local line_positions = - vim.fn.getregionpos(self.start:as_real():as_vim(), self.stop:as_real():as_vim(), { type = 'v' }) - - local len = 0 - for linenr, line in ipairs(line_positions) do - if linenr > 1 then len = len + 1 end -- each newline is counted as a char - local line_start_col = line[1][3] - local line_stop_col = line[2][3] - local line_len = line_stop_col - line_start_col + 1 - len = len + line_len - end - return len -end - -function Range:line_count() - if self:is_empty() then return 0 end - return self.stop.lnum - self.start.lnum + 1 -end - -function Range:trim_start() - if self:is_empty() then return end - - local r = self:clone() - while r.start:char():match '%s' do - local next = r.start:next(1) - if next == nil then break end - r.start = next - end - return r -end - -function Range:trim_stop() - if self:is_empty() then return end - - local r = self:clone() - while r.stop:char():match '%s' do - local next = r.stop:next(-1) - if next == nil then break end - r.stop = next - end - return r -end - ---- @param i number 1-based ---- @param j? number 1-based -function Range:sub(i, j) - local line_positions = - vim.fn.getregionpos(self.start:as_real():as_vim(), self.stop:as_real():as_vim(), { type = 'v' }) - - --- @param idx number 1-based - --- @return u.Pos|nil - local function get_pos(idx) - if idx < 0 then return get_pos(self:length() + idx + 1) end - - -- find the position of the first line that contains the i-th character: - local curr_len = 0 - for linenr, line in ipairs(line_positions) do - if linenr > 1 then curr_len = curr_len + 1 end -- each newline is counted as a char - local line_start_col = line[1][3] - local line_stop_col = line[2][3] - local line_len = line_stop_col - line_start_col + 1 - - if curr_len + line_len >= idx then - return Pos.new(self.start.bufnr, line[1][2], line_start_col + (idx - curr_len) - 1) - end - curr_len = curr_len + line_len - end - end - - local start = get_pos(i) - if not start then - -- start is inalid, 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: - return Range.new(start, nil, self.mode) - end - return Range.new(start, stop, 'v') -end - ---- @return string[] -function Range:lines() - if self:is_empty() then return {} end - return vim.fn.getregion(self.start:as_vim(), self.stop:as_vim(), { type = self.mode }) -end - ---- @return string -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 - - local line_indices = - vim.fn.getregionpos(self.start:as_vim(), self.stop:as_vim(), { type = self.mode }) - local line_bounds = line_indices[l] - - local start = Pos.new(unpack(line_bounds[1])) - local stop = Pos.new(unpack(line_bounds[2])) - return Range.new(start, stop) -end - ---- @param replacement nil|string|string[] -function Range:replace(replacement) - if replacement == nil then replacement = {} end - if type(replacement) == 'string' then replacement = vim.fn.split(replacement, '\n') end - - local bufnr = self.start.bufnr - local replace_type = (self:is_empty() and 'insert') or (self.mode == 'v' and 'region') or 'lines' - - local function update_stop_non_linewise() - local new_last_line_num = self.start.lnum + #replacement - 1 - local new_last_col = #(replacement[#replacement] or '') - if new_last_line_num == self.start.lnum then - new_last_col = new_last_col + self.start.col - 1 - end - self.stop = Pos.new(bufnr, new_last_line_num, new_last_col) - end - local function update_stop_linewise() - if #replacement == 0 then - self.stop = nil - else - local new_last_line_num = self.start.lnum - 1 + #replacement - 1 - self.stop = Pos.new(bufnr, new_last_line_num + 1, Pos.MAX_COL, self.stop.off) - end - self.mode = 'v' - end - - if replace_type == 'insert' then - -- To insert text at a given `(row, column)` location, use `start_row = - -- end_row = row` and `start_col = end_col = col`. - vim.api.nvim_buf_set_text( - bufnr, - self.start.lnum - 1, - self.start.col - 1, - self.start.lnum - 1, - self.start.col - 1, - replacement - ) - update_stop_non_linewise() - elseif replace_type == 'region' then - -- Fixup the bounds: - local max_col = #self.stop:line() - - -- Indexing is zero-based. Row indices are end-inclusive, and column indices - -- are end-exclusive. - vim.api.nvim_buf_set_text( - bufnr, - self.start.lnum - 1, - self.start.col - 1, - self.stop.lnum - 1, - math.min(self.stop.col, max_col), - replacement - ) - update_stop_non_linewise() - elseif replace_type == 'lines' then - -- Indexing is zero-based, end-exclusive. - vim.api.nvim_buf_set_lines(bufnr, self.start.lnum - 1, self.stop.lnum, true, replacement) - update_stop_linewise() - else - error 'unreachable' - end -end - ---- @param amount number -function Range:shrink(amount) - local start = self.start - local stop = self.stop - if stop == nil then return self:clone() end - - for _ = 1, amount do - local next_start = start:next(1) - local next_stop = stop:next(-1) - if next_start == nil or next_stop == nil then return end - start = next_start - stop = next_stop - if next_start > next_stop then break end - end - if start > stop then stop = nil end - return Range.new(start, stop, self.mode) -end - ---- @param amount number -function Range:must_shrink(amount) - local shrunk = self:shrink(amount) - if shrunk == nil or shrunk:is_empty() then - error 'error in Range:must_shrink: Range:shrink() returned nil' - end - return shrunk -end - ---- @param group string ---- @param opts? { timeout?: number, priority?: number, on_macro?: boolean } -function Range:highlight(group, opts) - if self:is_empty() then return end - - opts = opts or { on_macro = false } - if opts.on_macro == nil then opts.on_macro = false end - - local in_macro = vim.fn.reg_executing() ~= '' - if not opts.on_macro and in_macro then return { clear = function() end } end - - local ns = vim.api.nvim_create_namespace '' - - local winview = vim.fn.winsaveview() - vim.hl.range( - self.start.bufnr, - ns, - group, - { self.start.lnum - 1, self.start.col - 1 }, - { self.stop.lnum - 1, self.stop.col - 1 }, - { - inclusive = true, - priority = opts.priority, - timeout = opts.timeout, - regtype = self.mode, - } - ) - if not in_macro then vim.fn.winrestview(winview) end - vim.cmd.redraw() - - return { - ns = ns, - clear = function() - vim.api.nvim_buf_clear_namespace(self.start.bufnr, ns, self.start.lnum - 1, self.stop.lnum) - vim.cmd.redraw() - end, - } -end - -return Range diff --git a/lua/u/repeat.lua b/lua/u/repeat.lua deleted file mode 100644 index 5da9b31..0000000 --- a/lua/u/repeat.lua +++ /dev/null @@ -1,40 +0,0 @@ -local M = {} - -local IS_REPEATING = false ---- @type function|nil -local REPEAT_ACTION = nil - -local function is_repeatable_last_mutator() return vim.b.changedtick <= (vim.b.my_changedtick or 0) end - ---- @param f fun() -function M.run_repeatable(f) - REPEAT_ACTION = f - ---@diagnostic disable-next-line: need-check-nil - REPEAT_ACTION() - vim.b.my_changedtick = vim.b.changedtick -end - -function M.is_repeating() return IS_REPEATING end - -function M.setup() - vim.keymap.set('n', '.', function() - IS_REPEATING = true - for _ = 1, vim.v.count1 do - if is_repeatable_last_mutator() and type(REPEAT_ACTION) == 'function' then - M.run_repeatable(REPEAT_ACTION) - else - vim.cmd { cmd = 'normal', args = { '.' }, bang = true } - end - end - IS_REPEATING = false - end) - vim.keymap.set('n', 'u', function() - local was_repeatable_last_mutator = is_repeatable_last_mutator() - for _ = 1, vim.v.count1 do - vim.cmd { cmd = 'normal', args = { 'u' }, bang = true } - end - if was_repeatable_last_mutator then vim.b.my_changedtick = vim.b.changedtick end - end) -end - -return M diff --git a/lua/u/txtobj.lua b/lua/u/txtobj.lua deleted file mode 100644 index dc399a2..0000000 --- a/lua/u/txtobj.lua +++ /dev/null @@ -1,45 +0,0 @@ -local Range = require 'u.range' - -local M = {} - -local ESC = vim.api.nvim_replace_termcodes('', true, false, true) - ---- @param key_seq string ---- @param fn fun(key_seq: string):u.Range|nil ---- @param opts? { buffer: number|nil } -function M.define(key_seq, fn, opts) - if opts ~= nil and opts.buffer == 0 then opts.buffer = vim.api.nvim_get_current_buf() end - - local function handle_visual() - local range = fn(key_seq) - if range == nil or range:is_empty() then - vim.cmd.normal(ESC) - return - end - range:set_visual_selection() - end - vim.keymap.set({ 'x' }, key_seq, handle_visual, opts and { buffer = opts.buffer } or nil) - - local function handle_normal() - local range = fn(key_seq) - if range == nil then return end - - if not range:is_empty() then - range:set_visual_selection() - else - local original_eventignore = vim.go.eventignore - vim.go.eventignore = 'all' - - -- insert a single space, so we can select it: - local p = range.start - p:insert_before ' ' - vim.go.eventignore = original_eventignore - - -- select the space: - Range.new(p, p, 'v'):set_visual_selection() - end - end - vim.keymap.set({ 'o' }, key_seq, handle_normal, opts and { buffer = opts.buffer } or nil) -end - -return M diff --git a/lua/u/utils.lua b/lua/u/utils.lua deleted file mode 100644 index b85bf04..0000000 --- a/lua/u/utils.lua +++ /dev/null @@ -1,126 +0,0 @@ -local M = {} - --- --- Types --- - ---- @class u.utils.QfItem ---- @field col number ---- @field filename string ---- @field kind string ---- @field lnum number ---- @field text string - ---- @class u.utils.RawCmdArgs ---- @field args string ---- @field bang boolean ---- @field count number ---- @field fargs string[] ---- @field line1 number ---- @field line2 number ---- @field mods string ---- @field name string ---- @field range 0|1|2 ---- @field reg string ---- @field smods any - ---- @class u.utils.CmdArgs: u.utils.RawCmdArgs ---- @field info u.Range|nil - ---- @class u.utils.UcmdArgs ---- @field nargs? 0|1|'*'|'?'|'+' ---- @field range? boolean|'%'|number ---- @field count? boolean|number ---- @field addr? string ---- @field completion? string ---- @field force? boolean ---- @field preview? fun(opts: u.utils.UcmdArgs, ns: integer, buf: integer):0|1|2 - ---- Creates a user command with enhanced argument processing. ---- Automatically computes range information and attaches it as `args.info`. ---- ---- @param name string The command name (without the leading colon) ---- @param cmd string | fun(args: u.utils.CmdArgs): any Command implementation ---- @param opts? u.utils.UcmdArgs Command options (nargs, range, etc.) ---- ---- @usage ---- ```lua ---- -- Create a command that works with visual selections: ---- utils.ucmd('MyCmd', function(args) ---- -- Print the visually selected text: ---- vim.print(args.info:text()) ---- -- Or get the selection as an array of lines: ---- vim.print(args.info:lines()) ---- end, { nargs = '*', range = true }) ---- ---- -- Create a command that processes the current line: ---- utils.ucmd('ProcessLine', function(args) ---- local line_text = args.info:text() ---- -- Process the line... ---- end, { range = '%' }) ---- ---- -- Create a command with arguments: ---- utils.ucmd('SearchReplace', function(args) ---- local pattern, replacement = args.fargs[1], args.fargs[2] ---- local text = args.info:text() ---- -- Perform search and replace... ---- end, { nargs = 2, range = true }) ---- ``` --- luacheck: ignore -function M.ucmd(name, cmd, opts) - local Range = require 'u.range' - - opts = opts or {} - local cmd2 = cmd - if type(cmd) == 'function' then - cmd2 = function(args) - args.info = Range.from_cmd_args(args) - return cmd(args) - end - end - vim.api.nvim_create_user_command(name, cmd2, opts or {} --[[@as any]]) -end - ---- Creates command arguments for delegating from one command to another. ---- Preserves all relevant context (range, modifiers, bang, etc.) when ---- implementing a derived command in terms of a base command. ---- ---- @param current_args vim.api.keyset.create_user_command.command_args|u.utils.RawCmdArgs The arguments from the current command ---- @return vim.api.keyset.cmd Arguments suitable for vim.cmd() calls ---- ---- @usage ---- ```lua ---- -- Implement :MyEdit in terms of :edit, preserving all context: ---- utils.ucmd('MyEdit', function(args) ---- local delegated_args = utils.create_delegated_cmd_args(args) ---- -- Add custom logic here... ---- vim.cmd.edit(delegated_args) ---- end, { nargs = '*', range = true, bang = true }) ---- ---- -- Implement :MySubstitute that delegates to :substitute: ---- utils.ucmd('MySubstitute', function(args) ---- -- Pre-process arguments ---- local pattern = preprocess_pattern(args.fargs[1]) ---- ---- local delegated_args = utils.create_delegated_cmd_args(args) ---- delegated_args.args = { pattern, args.fargs[2] } ---- ---- vim.cmd.substitute(delegated_args) ---- end, { nargs = 2, range = true, bang = true }) ---- ``` -function M.create_delegated_cmd_args(current_args) - --- @type vim.api.keyset.cmd - local args = { - range = current_args.range == 1 and { current_args.line1 } - or current_args.range == 2 and { current_args.line1, current_args.line2 } - or nil, - count = (current_args.count ~= -1 and current_args.range == 0) and current_args.count or nil, - reg = current_args.reg ~= '' and current_args.reg or nil, - bang = current_args.bang or nil, - args = #current_args.fargs > 0 and current_args.fargs or nil, - mods = current_args.smods, - } - return args -end - -return M diff --git a/mise.toml b/mise.toml index b7c1356..6cc41da 100644 --- a/mise.toml +++ b/mise.toml @@ -79,12 +79,12 @@ arg "" help="The version of Neovim to test with" run = 'mise test:version:no-prep $usage_version' [tasks."test"] -run = 'mise test:version 0.11.5' +run = 'mise test:version 0.12.0' [tasks."test:all"] depends = ["test:prepare"] run = ''' -VERSIONS="0.11.5 nightly" +VERSIONS="0.11.5 0.12.0 nightly" for v in $VERSIONS; do mise test:version:no-prep $v done diff --git a/spec/pos_spec.lua b/spec/pos_spec.lua deleted file mode 100644 index 357a2e3..0000000 --- a/spec/pos_spec.lua +++ /dev/null @@ -1,70 +0,0 @@ ---- @diagnostic disable: undefined-field, need-check-nil -local Pos = require 'u.pos' -local withbuf = loadfile './spec/withbuf.lua'() - -describe('Pos', function() - it('get a char from a given position', function() - withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function() - assert.are.same('a', Pos.new(nil, 1, 1):char()) - assert.are.same('d', Pos.new(nil, 1, 3):char()) - assert.are.same('f', Pos.new(nil, 1, 4):char()) - assert.are.same('a', Pos.new(nil, 3, 1):char()) - assert.are.same('', Pos.new(nil, 4, 1):char()) - assert.are.same('o', Pos.new(nil, 5, 3):char()) - end) - end) - - it('comparison operators', function() - local a = Pos.new(0, 0, 0, 0) - local b = Pos.new(0, 1, 0, 0) - assert.are.same(a == a, true) - assert.are.same(a < b, true) - end) - - it('get the next position', function() - withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function() - -- line 1: a => s - assert.are.same(Pos.new(nil, 1, 2), Pos.new(nil, 1, 1):next()) - -- line 1: d => f - assert.are.same(Pos.new(nil, 1, 4), Pos.new(nil, 1, 3):next()) - -- line 1 => 2 - assert.are.same(Pos.new(nil, 2, 1), Pos.new(nil, 1, 4):next()) - -- line 3 => 4 - assert.are.same(Pos.new(nil, 4, 1), Pos.new(nil, 3, 1):next()) - -- line 4 => 5 - assert.are.same(Pos.new(nil, 5, 1), Pos.new(nil, 4, 1):next()) - -- end returns nil - assert.are.same(nil, Pos.new(nil, 5, 3):next()) - end) - end) - - it('get the previous position', function() - withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function() - -- line 1: s => a - assert.are.same(Pos.new(nil, 1, 1), Pos.new(nil, 1, 2):next(-1)) - -- line 1: f => d - assert.are.same(Pos.new(nil, 1, 3), Pos.new(nil, 1, 4):next(-1)) - -- line 2 => 1 - assert.are.same(Pos.new(nil, 1, 4), Pos.new(nil, 2, 1):next(-1)) - -- line 4 => 3 - assert.are.same(Pos.new(nil, 3, 1), Pos.new(nil, 4, 1):next(-1)) - -- line 5 => 4 - assert.are.same(Pos.new(nil, 4, 1), Pos.new(nil, 5, 1):next(-1)) - -- beginning returns nil - assert.are.same(nil, Pos.new(nil, 1, 1):next(-1)) - end) - end) - - it('find matching brackets', function() - withbuf({ 'asdf ({} def <[{}]>) ;lkj' }, function() - -- outer parens are matched: - assert.are.same(Pos.new(nil, 1, 20), Pos.new(nil, 1, 6):find_match()) - -- outer parens are matched (backward): - assert.are.same(Pos.new(nil, 1, 6), Pos.new(nil, 1, 20):find_match()) - -- no potential match returns nil - assert.are.same(nil, Pos.new(nil, 1, 1):find_match()) - -- watchdog expires before an otherwise valid match is found: - assert.are.same(nil, Pos.new(nil, 1, 6):find_match(2)) - end) - end) -end) diff --git a/spec/range_spec.lua b/spec/u_spec.lua similarity index 90% rename from spec/range_spec.lua rename to spec/u_spec.lua index 6770c32..64a506c 100644 --- a/spec/range_spec.lua +++ b/spec/u_spec.lua @@ -1,7 +1,84 @@ +require 'luacov' + --- @diagnostic disable: undefined-field, need-check-nil -local Pos = require 'u.pos' -local Range = require 'u.range' -local withbuf = loadfile './spec/withbuf.lua'() +local Pos = require 'u'.Pos +local Range = require 'u'.Range +local function withbuf(lines, f) + vim.go.swapfile = false + + vim.cmd.new() + vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) + local ok, result = pcall(f) + vim.cmd.bdelete { bang = true } + if not ok then error(result) end +end + +describe('Pos', function() + it('get a char from a given position', function() + withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function() + assert.are.same('a', Pos.new(nil, 1, 1):char()) + assert.are.same('d', Pos.new(nil, 1, 3):char()) + assert.are.same('f', Pos.new(nil, 1, 4):char()) + assert.are.same('a', Pos.new(nil, 3, 1):char()) + assert.are.same('', Pos.new(nil, 4, 1):char()) + assert.are.same('o', Pos.new(nil, 5, 3):char()) + end) + end) + + it('comparison operators', function() + local a = Pos.new(0, 0, 0, 0) + local b = Pos.new(0, 1, 0, 0) + assert.are.same(a == a, true) + assert.are.same(a < b, true) + end) + + it('get the next position', function() + withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function() + -- line 1: a => s + assert.are.same(Pos.new(nil, 1, 2), Pos.new(nil, 1, 1):next()) + -- line 1: d => f + assert.are.same(Pos.new(nil, 1, 4), Pos.new(nil, 1, 3):next()) + -- line 1 => 2 + assert.are.same(Pos.new(nil, 2, 1), Pos.new(nil, 1, 4):next()) + -- line 3 => 4 + assert.are.same(Pos.new(nil, 4, 1), Pos.new(nil, 3, 1):next()) + -- line 4 => 5 + assert.are.same(Pos.new(nil, 5, 1), Pos.new(nil, 4, 1):next()) + -- end returns nil + assert.are.same(nil, Pos.new(nil, 5, 3):next()) + end) + end) + + it('get the previous position', function() + withbuf({ 'asdf', 'bleh', 'a', '', 'goo' }, function() + -- line 1: s => a + assert.are.same(Pos.new(nil, 1, 1), Pos.new(nil, 1, 2):next(-1)) + -- line 1: f => d + assert.are.same(Pos.new(nil, 1, 3), Pos.new(nil, 1, 4):next(-1)) + -- line 2 => 1 + assert.are.same(Pos.new(nil, 1, 4), Pos.new(nil, 2, 1):next(-1)) + -- line 4 => 3 + assert.are.same(Pos.new(nil, 3, 1), Pos.new(nil, 4, 1):next(-1)) + -- line 5 => 4 + assert.are.same(Pos.new(nil, 4, 1), Pos.new(nil, 5, 1):next(-1)) + -- beginning returns nil + assert.are.same(nil, Pos.new(nil, 1, 1):next(-1)) + end) + end) + + it('find matching brackets', function() + withbuf({ 'asdf ({} def <[{}]>) ;lkj' }, function() + -- outer parens are matched: + assert.are.same(Pos.new(nil, 1, 20), Pos.new(nil, 1, 6):find_match()) + -- outer parens are matched (backward): + assert.are.same(Pos.new(nil, 1, 6), Pos.new(nil, 1, 20):find_match()) + -- no potential match returns nil + assert.are.same(nil, Pos.new(nil, 1, 1):find_match()) + -- watchdog expires before an otherwise valid match is found: + assert.are.same(nil, Pos.new(nil, 1, 6):find_match(2)) + end) + end) +end) describe('Range', function() it('get text in buffer', function() diff --git a/spec/withbuf.lua b/spec/withbuf.lua deleted file mode 100644 index ee1d790..0000000 --- a/spec/withbuf.lua +++ /dev/null @@ -1,11 +0,0 @@ -require 'luacov' -local function withbuf(lines, f) - vim.go.swapfile = false - - vim.cmd.new() - vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) - local ok, result = pcall(f) - vim.cmd.bdelete { bang = true } - if not ok then error(result) end -end -return withbuf