1-based indexing rewrite
Some checks failed
NeoVim tests / plenary-tests (0.11.0) (push) Failing after 8s
NeoVim tests / plenary-tests (0.10.1) (push) Failing after 10s
NeoVim tests / plenary-tests (0.9.5) (push) Failing after 10s

This commit is contained in:
2025-04-11 13:20:46 -06:00
parent d9bb01be8b
commit f8883ecd4f
23 changed files with 449 additions and 572 deletions

5
lua/u/.luarc.json Normal file
View File

@@ -0,0 +1,5 @@
{
"$schema": "https://raw.githubusercontent.com/LuaLS/vscode-lua/master/setting/schema.json",
"diagnostics.globals": ["assert", "vim"],
"runtime.version": "LuaJIT"
}

View File

@@ -1,13 +1,13 @@
local Range = require 'u.range'
local Renderer = require 'u.renderer'.Renderer
---@class Buffer
---@class u.Buffer
---@field buf number
---@field private renderer Renderer
---@field private renderer u.Renderer
local Buffer = {}
---@param buf? number
---@return Buffer
---@return u.Buffer
function Buffer.from_nr(buf)
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
@@ -18,12 +18,12 @@ function Buffer.from_nr(buf)
}, { __index = Buffer })
end
---@return Buffer
---@return u.Buffer
function Buffer.current() return Buffer.from_nr(0) end
---@param listed boolean
---@param scratch boolean
---@return Buffer
---@return u.Buffer
function Buffer.create(listed, scratch) return Buffer.from_nr(vim.api.nvim_create_buf(listed, scratch)) end
function Buffer:set_tmp_options()
@@ -48,7 +48,7 @@ function Buffer:line_count() return vim.api.nvim_buf_line_count(self.buf) end
function Buffer:all() return Range.from_buf_text(self.buf) end
function Buffer:is_empty() return self:line_count() == 1 and self:line0(0):text() == '' end
function Buffer:is_empty() return self:line_count() == 1 and self:line(1):text() == '' end
---@param line string
function Buffer:append_line(line)
@@ -58,8 +58,8 @@ function Buffer:append_line(line)
end
---@param num number 0-based line index
function Buffer:line0(num)
if num < 0 then return self:line0(self:line_count() + num) end
function Buffer:line(num)
if num < 0 then num = self:line_count() + num + 1 end
return Range.from_line(self.buf, num)
end
@@ -68,13 +68,14 @@ end
function Buffer:lines(start, stop) return Range.from_lines(self.buf, start, stop) end
---@param txt_obj string
---@param opts? { contains_cursor?: boolean; pos?: Pos }
---@param opts? { contains_cursor?: boolean; pos?: u.Pos }
function Buffer:text_object(txt_obj, opts)
opts = vim.tbl_extend('force', opts or {}, { buf = self.buf })
return Range.from_text_object(txt_obj, opts)
end
--- @param event string|string[]
--- @diagnostic disable-next-line: undefined-doc-name
--- @param opts vim.api.keyset.create_autocmd
function Buffer:autocmd(event, opts)
vim.api.nvim_create_autocmd(event, vim.tbl_extend('force', opts, { buffer = self.buf }))

View File

@@ -1,6 +1,6 @@
local Buffer = require 'u.buffer'
---@class CodeWriter
---@class u.CodeWriter
---@field lines string[]
---@field indent_level number
---@field indent_str string
@@ -8,7 +8,7 @@ local CodeWriter = {}
---@param indent_level? number
---@param indent_str? string
---@return CodeWriter
---@return u.CodeWriter
function CodeWriter.new(indent_level, indent_str)
if indent_level == nil then indent_level = 0 end
if indent_str == nil then indent_str = ' ' end
@@ -22,9 +22,9 @@ function CodeWriter.new(indent_level, indent_str)
return cw
end
---@param p Pos
---@param p u.Pos
function CodeWriter.from_pos(p)
local line = Buffer.from_nr(p.buf):line0(p.lnum):text()
local line = Buffer.from_nr(p.buf):line(p.lnum):text()
return CodeWriter.from_line(line, p.buf)
end
@@ -61,7 +61,7 @@ end
---@param line string
function CodeWriter:write(line) self:write_raw(self.indent_str:rep(self.indent_level) .. line) end
---@param f? fun(cw: CodeWriter):any
---@param f? fun(cw: u.CodeWriter):any
function CodeWriter:indent(f)
local cw = {
lines = self.lines,

View File

@@ -7,7 +7,7 @@ function M.file_for_name(name) return vim.fs.joinpath(vim.fn.stdpath 'cache', 'u
-- Logger class
--------------------------------------------------------------------------------
--- @class Logger
--- @class u.Logger
--- @field name string
--- @field private fd number
local Logger = {}

View File

@@ -1,24 +1,17 @@
local Range = require 'u.range'
local vim_repeat = require 'u.repeat'
---@type fun(range: Range): nil|(fun():any)
--- @type fun(range: u.Range): nil|(fun():any)
local __U__OpKeymapOpFunc_rhs = nil
--- This is the global utility function used for operatorfunc
--- in opkeymap
---@type nil|fun(range: Range): fun():any|nil
---@param ty 'line'|'char'|'block'
--- @type nil|fun(range: u.Range): fun():any|nil
--- @param ty 'line'|'char'|'block'
-- selene: allow(unused_variable)
function __U__OpKeymapOpFunc(ty)
if __U__OpKeymapOpFunc_rhs ~= nil then
local range = Range.from_op_func(ty)
local repeat_inject = __U__OpKeymapOpFunc_rhs(range)
vim_repeat.set(function()
vim.o.operatorfunc = 'v:lua.__U__OpKeymapOpFunc'
if repeat_inject ~= nil and type(repeat_inject) == 'function' then repeat_inject() end
vim_repeat.native_repeat()
end)
__U__OpKeymapOpFunc_rhs(range)
end
end
@@ -28,12 +21,17 @@ end
--- 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: Range): nil|(fun():any) This function may return another function, which is called whenever the operator is repeated
---@param opts? vim.keymap.set.Opts
--- @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@'

View File

@@ -1,10 +1,10 @@
local MAX_COL = vim.v.maxcol
---@param buf number
---@param lnum number
local function line_text(buf, lnum) return vim.api.nvim_buf_get_lines(buf, lnum, lnum + 1, false)[1] end
---@param lnum number 1-based
local function line_text(buf, lnum) return vim.api.nvim_buf_get_lines(buf, lnum - 1, lnum, false)[1] end
---@class Pos
---@class u.Pos
---@field buf number buffer number
---@field lnum number 1-based line index
---@field col number 1-based column index
@@ -13,10 +13,10 @@ local Pos = {}
Pos.MAX_COL = MAX_COL
---@param buf? number
---@param lnum number
---@param col number
---@param lnum number 1-based
---@param col number 1-based
---@param off? number
---@return Pos
---@return u.Pos
function Pos.new(buf, lnum, col, off)
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
if off == nil then off = 0 end
@@ -44,103 +44,114 @@ function Pos.new(buf, lnum, col, off)
return pos
end
function Pos.invalid() return Pos.new(0, 0, 0, 0) end
function Pos.is(x)
if not type(x) == 'table' then return false end
local mt = getmetatable(x)
return mt and mt.__index == Pos
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 a.lnum == b.lnum and a.col == b.col end
function Pos.__eq(a, b) return Pos.is(a) and Pos.is(b) and a.buf == b.buf 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 not Pos.is(x) 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 not Pos.is(x) or type(y) ~= 'number' then return nil end
return x:next(-y)
end
---@param name string
---@return Pos
---@return u.Pos
function Pos.from_pos(name)
local p = vim.fn.getpos(name)
local col = p[3]
if col ~= MAX_COL then col = col - 1 end
return Pos.new(p[1], p[2] - 1, col, p[4])
return Pos.new(p[1], p[2], p[3], p[4])
end
function Pos:is_invalid() return self.buf == 0 and self.lnum == 0 and self.col == 0 and self.off == 0 end
function Pos:clone() return Pos.new(self.buf, self.lnum, self.col, self.off) end
---@return boolean
function Pos:is_col_max() return self.col == MAX_COL end
---@return number[]
function Pos:as_vim() return { self.buf, self.lnum, self.col, self.off } end
--- Normalize the position to a real position (take into account vim.v.maxcol).
function Pos:as_real()
local maxlen = #line_text(self.buf, self.lnum)
local col = self.col
if self:is_col_max() then
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 = #line_text(self.buf, self.lnum) - 1
col = maxlen
end
return Pos.new(self.buf, self.lnum, col, self.off)
end
---@param pos string
function Pos:save_to_pos(pos)
if pos == '.' then
vim.api.nvim_win_set_cursor(0, { self.lnum + 1, self.col })
return
end
function Pos:as_vim() return { self.buf, self.lnum, self.col, self.off } end
local p = self:as_real()
vim.fn.setpos(pos, { p.buf, p.lnum + 1, p.col + 1, p.off })
end
---@param pos string
function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.buf, self.lnum, self.col, self.off }) end
---@param mark string
function Pos:save_to_mark(mark)
local p = self:as_real()
vim.api.nvim_buf_set_mark(p.buf, mark, p.lnum + 1, p.col, {})
vim.api.nvim_buf_set_mark(p.buf, mark, p.lnum, p.col, {})
end
---@return string
function Pos:char()
local line = line_text(self.buf, self.lnum)
if line == nil then return '' end
return line:sub(self.col + 1, self.col + 1)
return line:sub(self.col, self.col)
end
function Pos:line() return line_text(self.buf, self.lnum) end
---@param dir? -1|1
---@param must? boolean
---@return Pos|nil
---@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.buf)
local last_line = line_text(self.buf, num_lines - 1) -- buf:line0(-1)
if self.lnum == num_lines - 1 and self.col == (#last_line - 1) then
local last_line = line_text(self.buf, 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.buf, self.lnum) - 1
local line_max_col = #line_text(self.buf, self.lnum)
if col > line_max_col then
col = 0
col = 1
line = line + 1
end
return Pos.new(self.buf, line, col, self.off)
else
-- Previous:
if self.col == 0 and self.lnum == 0 then
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.buf, self.lnum - 1) or '') - 1
if col < 0 then
col = math.max(prev_line_max_col, 0)
local prev_line_max_col = #(line_text(self.buf, self.lnum - 1) or '')
if col < 1 then
col = math.max(prev_line_max_col, 1)
line = line - 1
end
return Pos.new(self.buf, line, col, self.off)
@@ -155,7 +166,7 @@ function Pos:must_next(dir)
end
---@param dir -1|1
---@param predicate fun(p: Pos): boolean
---@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
@@ -169,14 +180,14 @@ function Pos:next_while(dir, predicate, test_current)
end
---@param dir -1|1
---@param predicate string|fun(p: Pos): boolean
---@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 Pos|nil
---@type u.Pos|nil
local curr = self
while curr ~= nil do
if predicate(curr) then return curr end
@@ -187,8 +198,8 @@ end
--- Finds the matching bracket/paren for the current position.
---@param max_chars? number|nil
---@param invocations? Pos[]
---@return Pos|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
@@ -202,11 +213,16 @@ function Pos:find_match(max_chars, invocations)
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 Pos|nil
---@type u.Pos|nil
local cur = self
---@return Pos|nil
--- `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
@@ -218,7 +234,7 @@ function Pos:find_match(max_chars, invocations)
return cur:next(is_opener and 1 or -1)
end
-- scan until we find a match:
-- scan until we find `c_match`:
cur = adv()
while cur ~= nil and cur:char() ~= c_match do
cur = adv()

View File

@@ -1,5 +1,4 @@
local Pos = require 'u.pos'
local State = require 'u.state'
local orig_on_yank = (vim.hl or vim.highlight).on_yank
local on_yank_enabled = true;
@@ -8,16 +7,16 @@ local on_yank_enabled = true;
return orig_on_yank(opts)
end
---@class Range
---@field start Pos
---@field stop Pos|nil
---@class u.Range
---@field start u.Pos
---@field stop u.Pos|nil
---@field mode 'v'|'V'
local Range = {}
---@param start Pos
---@param stop Pos|nil
---@param start u.Pos
---@param stop u.Pos|nil
---@param mode? 'v'|'V'
---@return Range
---@return u.Range
function Range.new(start, stop, mode)
if stop ~= nil and stop < start then
start, stop = stop, start
@@ -25,7 +24,7 @@ function Range.new(start, stop, mode)
local r = { start = start, stop = stop, mode = mode or 'v' }
local function str()
---@param p Pos
---@param p u.Pos
local function posstr(p)
if p == nil then
return 'nil'
@@ -51,7 +50,7 @@ end
---@param lpos string
---@param rpos string
---@return Range
---@return u.Range
function Range.from_marks(lpos, rpos)
local start = Pos.from_pos(lpos)
local stop = Pos.from_pos(rpos)
@@ -72,15 +71,17 @@ function Range.from_buf_text(buf)
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
local num_lines = vim.api.nvim_buf_line_count(buf)
local start = Pos.new(buf, 0, 0)
local stop = Pos.new(buf, num_lines - 1, Pos.MAX_COL)
local start = Pos.new(buf, 1, 1)
local stop = Pos.new(buf, num_lines, Pos.MAX_COL)
return Range.new(start, stop, 'V')
end
-- TODO: make 1-based
---@param buf? number
---@param line number 0-based line index
function Range.from_line(buf, line) return Range.from_lines(buf, line, line) end
-- TODO: make 1-based
---@param buf? number
---@param start_line number 0-based line index
---@param stop_line number 0-based line index
@@ -90,12 +91,12 @@ function Range.from_lines(buf, start_line, stop_line)
local num_lines = vim.api.nvim_buf_line_count(buf)
stop_line = num_lines + stop_line
end
return Range.new(Pos.new(buf, start_line, 0), Pos.new(buf, stop_line, Pos.MAX_COL), 'V')
return Range.new(Pos.new(buf, start_line + 1, 1), Pos.new(buf, stop_line + 1, Pos.MAX_COL), 'V')
end
---@param text_obj string
---@param opts? { buf?: number; contains_cursor?: boolean; pos?: Pos, user_defined?: boolean }
---@return Range|nil
---@param opts? { buf?: number; contains_cursor?: boolean; pos?: u.Pos, user_defined?: boolean }
---@return u.Range|nil
function Range.from_text_object(text_obj, opts)
opts = opts or {}
if opts.buf == nil then opts.buf = vim.api.nvim_get_current_buf() end
@@ -108,52 +109,58 @@ function Range.from_text_object(text_obj, opts)
local is_quote = vim.tbl_contains({ "'", '"', '`' }, obj_type)
local cursor = Pos.from_pos '.'
-- Yank, then read '[ and '] to know the bounds:
---@type { start: Pos; stop: Pos }
local positions
--- @type u.Pos
local start
--- @type u.Pos
local stop
vim.api.nvim_buf_call(opts.buf, function()
positions = State.run(0, function(s)
s:track_winview()
s:track_register '"'
s:track_pos '.'
s:track_pos "'["
s:track_pos "']"
local original_state = {
winview = vim.fn.winsaveview(),
regquote = vim.fn.getreg '"',
posdot = vim.fn.getpos '.',
poslb = vim.fn.getpos "'[",
posrb = vim.fn.getpos "']",
}
if opts.pos ~= nil then opts.pos:save_to_pos '.' end
if opts.pos ~= nil then opts.pos:save_to_pos '.' end
local null_pos = Pos.new(0, 0, 0, 0)
null_pos:save_to_pos "'["
null_pos:save_to_pos "']"
Pos.invalid():save_to_pos "'["
Pos.invalid():save_to_pos "']"
local prev_on_yank_enabled = on_yank_enabled
on_yank_enabled = false
vim.cmd {
cmd = 'normal',
bang = not opts.user_defined,
args = { '""y' .. text_obj },
mods = { silent = true },
}
on_yank_enabled = prev_on_yank_enabled
local prev_on_yank_enabled = on_yank_enabled
on_yank_enabled = false
vim.cmd {
cmd = 'normal',
bang = not opts.user_defined,
args = { '""y' .. text_obj },
mods = { silent = true },
}
on_yank_enabled = prev_on_yank_enabled
local start = Pos.from_pos "'["
local stop = Pos.from_pos "']"
start = Pos.from_pos "'["
stop = Pos.from_pos "']"
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 and selection_type == 'i' and stop:char() == obj_type)
-- *Sigh*, this also sometimes happens for `it` as well.
or (text_obj == 'it' and stop:char() == '<')
then
stop = stop:next(-1) or stop
end
return { start = start, stop = stop }
end)
-- Restore original state:
vim.fn.winrestview(original_state.winview)
vim.fn.setreg('"', original_state.regquote)
vim.fn.setpos('.', original_state.posdot)
vim.fn.setpos("'[", original_state.poslb)
vim.fn.setpos("']", original_state.posrb)
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 and selection_type == 'i' and stop:char() == obj_type)
-- *Sigh*, this also sometimes happens for `it` as well.
or (text_obj == 'it' and stop:char() == '<')
then
stop = stop:next(-1) or stop
end
end)
local start = positions.start
local stop = positions.stop
if start == stop and start.lnum == 0 and start.col == 0 and start.off == 0 then return nil end
if start == stop and start:is_invalid() then return nil end
if opts.contains_cursor and not Range.new(start, stop):contains(cursor) then return nil end
if is_quote and selection_type == 'a' then
@@ -189,11 +196,11 @@ end
--- Get range information from command arguments.
---@param args unknown
---@return Range|nil
---@return u.Range|nil
function Range.from_cmd_args(args)
---@type 'v'|'V'
local mode
---@type nil|Pos
---@type nil|u.Pos
local start
local stop
if args.range == 0 then
@@ -201,59 +208,42 @@ function Range.from_cmd_args(args)
else
start = Pos.from_pos "'<"
stop = Pos.from_pos "'>"
if stop:is_col_max() then
mode = 'V'
else
mode = 'v'
end
mode = stop:is_col_max() and 'V' or 'v'
end
return Range.new(start, stop, mode)
end
---
function Range.find_nearest_brackets()
local a = Range.from_text_object('a<', { contains_cursor = true })
local b = Range.from_text_object('a[', { contains_cursor = true })
local c = Range.from_text_object('a(', { contains_cursor = true })
local d = Range.from_text_object('a{', { contains_cursor = true })
return Range.smallest { a, b, c, d }
return Range.smallest {
Range.from_text_object('a<', { contains_cursor = true }),
Range.from_text_object('a[', { contains_cursor = true }),
Range.from_text_object('a(', { contains_cursor = true }),
Range.from_text_object('a{', { contains_cursor = true }),
}
end
function Range.find_nearest_quotes()
local a = Range.from_text_object([[a']], { contains_cursor = true })
if a ~= nil and a:is_empty() then a = nil end
local b = Range.from_text_object([[a"]], { contains_cursor = true })
if b ~= nil and b:is_empty() then b = nil end
local c = Range.from_text_object([[a`]], { contains_cursor = true })
if c ~= nil and c:is_empty() then c = nil end
return Range.smallest { a, b, c }
return Range.smallest {
Range.from_text_object([[a']], { contains_cursor = true }),
Range.from_text_object([[a"]], { contains_cursor = true }),
Range.from_text_object([[a`]], { contains_cursor = true }),
}
end
---@param ranges (Range|nil)[]
---@param ranges (u.Range|nil)[]
function Range.smallest(ranges)
---@type Range[]
local new_ranges = {}
for _, r in pairs(ranges) do
if r ~= nil then table.insert(new_ranges, r) end
end
ranges = new_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 max_start = ranges[1].start
local min_stop = ranges[1].stop
local result = ranges[1]
local smallest = ranges[1]
for _, r in ipairs(ranges) do
local start, stop = r.start, r.stop
if start > max_start and stop < min_stop then
max_start = start
min_stop = stop
result = r
end
if start > smallest.start and stop < smallest.stop then smallest = r end
end
return result
return smallest
end
function Range:clone() return Range.new(self.start:clone(), self.stop ~= nil and self.stop:clone() or nil, self.mode) end
@@ -266,7 +256,7 @@ function Range:to_linewise()
local r = self:clone()
r.mode = 'V'
r.start.col = 0
r.start.col = 1
if r.stop ~= nil then r.stop.col = Pos.MAX_COL end
return r
@@ -298,19 +288,13 @@ function Range:trim_stop()
return r
end
---@param p Pos
---@param p u.Pos
function Range:contains(p) return not self:is_empty() and p >= self.start and p <= self.stop end
---@return string[]
function Range:lines()
if self:is_empty() then return {} end
local lines = {}
for i = 0, self.stop.lnum - self.start.lnum do
local line = self:line0(i)
if line ~= nil then table.insert(lines, line.text()) end
end
return lines
return vim.fn.getregion(self.start:as_vim(), self.stop:as_vim(), { type = self.mode })
end
---@return string
@@ -321,34 +305,17 @@ function Range:text() return vim.fn.join(self:lines(), '\n') end
function Range:sub(i, j) return self:text():sub(i, j) end
---@param l number
---@return { line: string; idx0: { start: number; stop: number; }; lnum: number; range: fun():Range; text: fun():string }|nil
function Range:line0(l)
if l < 0 then return self:line0(self:line_count() + l) end
---@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 = vim.api.nvim_buf_get_lines(self.start.buf, self.start.lnum + l, self.start.lnum + l + 1, false)[1]
if line == nil 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 = 0
local stop = #line - 1
if l == 0 then start = self.start.col end
if l == self.stop.lnum - self.start.lnum then stop = self.stop.col end
if stop == Pos.MAX_COL then stop = #line - 1 end
local lnum = self.start.lnum + l
return {
line = line,
idx0 = { start = start, stop = stop },
lnum = lnum,
range = function()
return Range.new(
Pos.new(self.start.buf, lnum, start, self.start.off),
Pos.new(self.start.buf, lnum, stop, self.stop.off),
'v'
)
end,
text = function() return line:sub(start + 1, stop + 1) end,
}
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[]
@@ -357,51 +324,55 @@ function Range:replace(replacement)
if type(replacement) == 'string' then replacement = vim.fn.split(replacement, '\n') end
local buf = self.start.buf
-- convert to start-inclusive, stop-exclusive coordinates:
local start_lnum, stop_lnum = self.start.lnum, (self.stop and self.stop.lnum or self.start.lnum) + 1
local start_col, stop_col = self.start.col, (self.stop and self.stop.col or self.start.col) + 1
local replace_type = (self:is_empty() and 'insert') or (self.mode == 'v' and 'region') or 'lines'
---@param alnum number
---@param acol number
---@param blnum number
---@param bcol number
local function set_text(alnum, acol, blnum, bcol, repl)
-- row indices are end-inclusive, and column indices are end-exclusive.
vim.api.nvim_buf_set_text(buf, alnum, acol, blnum, bcol, repl)
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 == start_lnum then new_last_col = new_last_col + start_col - 1 end
if new_last_line_num == self.start.lnum then new_last_col = new_last_col + self.start.col - 1 end
self.stop = Pos.new(buf, new_last_line_num, new_last_col)
end
---@param alnum number
---@param blnum number
local function set_lines(alnum, blnum, repl)
-- indexing is zero-based, end-exclusive
vim.api.nvim_buf_set_lines(buf, alnum, blnum, false, repl)
if #repl == 0 then
local function update_stop_linewise()
if #replacement == 0 then
self.stop = nil
else
local new_last_line_num = start_lnum + #replacement - 1
self.stop = Pos.new(self.start.buf, new_last_line_num, Pos.MAX_COL, self.stop.off)
local new_last_line_num = self.start.lnum - 1 + #replacement - 1
self.stop = Pos.new(buf, new_last_line_num + 1, Pos.MAX_COL, self.stop.off)
end
self.mode = 'v'
end
if replace_type == 'insert' then
set_text(start_lnum, start_col, start_lnum, start_col, replacement)
-- 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(
buf,
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 last_line = vim.api.nvim_buf_get_lines(buf, stop_lnum - 1, stop_lnum, false)[1] or ''
local max_col = #last_line
set_text(start_lnum, start_col, stop_lnum - 1, math.min(stop_col, max_col), replacement)
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(
buf,
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
set_lines(start_lnum, stop_lnum, replacement)
-- Indexing is zero-based, end-exclusive.
vim.api.nvim_buf_set_lines(buf, self.start.lnum - 1, self.stop.lnum, true, replacement)
update_stop_linewise()
else
error 'unreachable'
end
@@ -461,21 +432,11 @@ function Range:set_visual_selection()
if vim.api.nvim_get_current_buf() ~= self.start.buf then vim.api.nvim_set_current_buf(self.start.buf) end
State.run(self.start.buf, function(s)
s:track_mark 'a'
s:track_mark 'b'
self.start:save_to_mark 'a'
self.stop:save_to_mark 'b'
local mode = self.mode
local normal_cmd_args = ''
if vim.api.nvim_get_mode().mode == 'n' then normal_cmd_args = normal_cmd_args .. mode end
normal_cmd_args = normal_cmd_args .. '`ao`b'
vim.cmd { cmd = 'normal', args = { normal_cmd_args }, bang = true }
return nil
end)
local curr_mode = vim.fn.mode()
if curr_mode ~= self.mode then vim.fn.feedkeys(self.mode, 'x') end
self.start:save_to_pos '.'
vim.fn.feedkeys('o', 'x')
self.stop:save_to_pos '.'
end
---@param group string
@@ -490,33 +451,31 @@ function Range:highlight(group, opts)
if not opts.on_macro and in_macro then return { clear = function() end } end
local ns = vim.api.nvim_create_namespace ''
State.run(self.start.buf, function(s)
if not in_macro then s:track_winview() end
(vim.hl or vim.highlight).range(
self.start.buf,
ns,
group,
{ self.start.lnum, self.start.col },
{ self.stop.lnum, self.stop.col },
{
inclusive = true,
priority = opts.priority,
regtype = self.mode,
}
)
return nil
end)
local winview = vim.fn.winsaveview();
(vim.hl or vim.highlight).range(
self.start.buf,
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()
local function clear()
vim.api.nvim_buf_clear_namespace(self.start.buf, ns, self.start.lnum, self.stop.lnum + 1)
vim.cmd.redraw()
end
if opts.timeout ~= nil then vim.defer_fn(clear, opts.timeout) end
return { ns = ns, clear = clear }
return {
ns = ns,
clear = function()
vim.api.nvim_buf_clear_namespace(self.start.buf, ns, self.start.lnum - 1, self.stop.lnum)
vim.cmd.redraw()
end,
}
end
return Range

View File

@@ -21,7 +21,7 @@ end
-- Renderer {{{
--- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any }
--- @class Renderer
--- @class u.Renderer
--- @field bufnr number
--- @field ns number
--- @field changedtick number
@@ -377,7 +377,7 @@ end -- }}}
-- }}}
-- TreeBuilder {{{
--- @class TreeBuilder
--- @class u.TreeBuilder
--- @field private nodes Node[]
local TreeBuilder = {}
TreeBuilder.__index = TreeBuilder
@@ -389,7 +389,7 @@ function TreeBuilder.new()
end
--- @param nodes Tree
--- @return TreeBuilder
--- @return u.TreeBuilder
function TreeBuilder:put(nodes)
table.insert(self.nodes, nodes)
return self
@@ -398,7 +398,7 @@ end
--- @param name string
--- @param attributes? table<string, any>
--- @param children? Node | Node[]
--- @return TreeBuilder
--- @return u.TreeBuilder
function TreeBuilder:put_h(name, attributes, children)
local tag = M.h(name, attributes, children)
table.insert(self.nodes, tag)
@@ -406,7 +406,7 @@ function TreeBuilder:put_h(name, attributes, children)
end
--- @param fn fun(TreeBuilder): any
--- @return TreeBuilder
--- @return u.TreeBuilder
function TreeBuilder:nest(fn)
local nested_writer = TreeBuilder.new()
fn(nested_writer)

View File

@@ -1,61 +1,39 @@
local M = {}
local function _normal(cmd) vim.cmd { cmd = 'normal', args = { cmd }, bang = true } end
local IS_REPEATING = false
--- @type function
local REPEAT_ACTION = nil
M.native_repeat = function() _normal '.' end
M.native_undo = function() _normal 'u' end
local function is_repeatable_last_mutator() return vim.b.changedtick <= (vim.b.my_changedtick or 0) end
local function update_ts() vim.b.tt_changedtick = vim.b.changedtick end
---@param cmd? string|fun():unknown
function M.set(cmd)
update_ts()
if cmd ~= nil then vim.b.tt_repeatcmd = cmd end
--- @param f fun()
function M.run_repeatable(f)
REPEAT_ACTION = f
REPEAT_ACTION()
vim.b.my_changedtick = vim.b.changedtick
end
local function tt_was_last_repeatable()
local ts, tt_ts = vim.b.changedtick, vim.b.tt_changedtick
return tt_ts ~= nil and ts <= tt_ts
end
---@generic T
---@param cmd string|fun():T
---@return T
function M.run(cmd)
M.set(cmd)
local result = cmd()
update_ts()
return result
end
function M.do_repeat()
local tt_cmd = vim.b.tt_repeatcmd
if not tt_was_last_repeatable() or (type(tt_cmd) ~= 'function' and type(tt_cmd) ~= 'string') then
return M.native_repeat()
end
-- execute the cached command:
local count = vim.api.nvim_get_vvar 'count1'
if type(tt_cmd) == 'string' then
_normal(count .. tt_cmd --[[@as string]])
else
local last_return
for _ = 1, count do
last_return = M.run(tt_cmd --[[@as fun():any]])
end
return last_return
end
end
function M.undo()
local tt_was_last_repeatable_before_undo = tt_was_last_repeatable()
M.native_undo()
if tt_was_last_repeatable_before_undo then update_ts() end
end
function M.is_repeating() return IS_REPEATING end
function M.setup()
vim.keymap.set('n', '.', M.do_repeat)
vim.keymap.set('n', 'u', M.undo)
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

View File

@@ -1,90 +0,0 @@
---@class State
---@field buf number
---@field registers table
---@field marks table
---@field positions table
---@field keymaps { mode: string; lhs: any, rhs: any, buffer?: number }[]
---@field global_options table<string, any>
---@field win_view vim.fn.winsaveview.ret|nil
local State = {}
---@param buf number
---@return State
function State.new(buf)
if buf == 0 then buf = vim.api.nvim_get_current_buf() end
local s = { buf = buf, registers = {}, marks = {}, positions = {}, keymaps = {}, global_options = {} }
setmetatable(s, { __index = State })
return s
end
---@generic T
---@param buf number
---@param f fun(s: State):T
---@return T
function State.run(buf, f)
local s = State.new(buf)
local ok, result = pcall(f, s)
s:restore()
if not ok then error(result) end
return result
end
---@param buf number
---@param f fun(s: State, callback: fun(): any):any
---@param callback fun():any
function State.run_async(buf, f, callback)
local s = State.new(buf)
f(s, function()
s:restore()
callback()
end)
end
function State:track_keymap(mode, lhs)
local old =
-- Look up the mapping in buffer-local maps:
vim.iter(vim.api.nvim_buf_get_keymap(self.buf, mode)):find(function(map) return map.lhs == lhs end)
-- Look up the mapping in global maps:
or vim.iter(vim.api.nvim_get_keymap(mode)):find(function(map) return map.lhs == lhs end)
-- Did we find a mapping?
if old == nil then return end
-- Track it:
table.insert(self.keymaps, { mode = mode, lhs = lhs, rhs = old.rhs or old.callback, buffer = old.buffer })
end
---@param reg string
function State:track_register(reg) self.registers[reg] = vim.fn.getreg(reg) end
---@param mark string
function State:track_mark(mark) self.marks[mark] = vim.api.nvim_buf_get_mark(self.buf, mark) end
---@param pos string
function State:track_pos(pos) self.positions[pos] = vim.fn.getpos(pos) end
---@param nm string
function State:track_global_option(nm) self.global_options[nm] = vim.go[nm] end
function State:track_winview() self.win_view = vim.fn.winsaveview() end
function State:restore()
for reg, val in pairs(self.registers) do
vim.fn.setreg(reg, val)
end
for mark, val in pairs(self.marks) do
vim.api.nvim_buf_set_mark(self.buf, mark, val[1], val[2], {})
end
for pos, val in pairs(self.positions) do
vim.fn.setpos(pos, val)
end
for _, map in ipairs(self.keymaps) do
vim.keymap.set(map.mode, map.lhs, map.rhs, { buffer = map.buffer })
end
for nm, val in pairs(self.global_options) do
vim.go[nm] = val
end
if self.win_view ~= nil then vim.fn.winrestview(self.win_view) end
end
return State

View File

@@ -6,7 +6,7 @@ M.debug = false
-- class Signal
--------------------------------------------------------------------------------
--- @class Signal
--- @class u.Signal
--- @field name? string
--- @field private changing boolean
--- @field private value any
@@ -18,7 +18,7 @@ Signal.__index = Signal
--- @param value any
--- @param name? string
--- @return Signal
--- @return u.Signal
function Signal:new(value, name)
local obj = setmetatable({
name = name,
@@ -83,7 +83,7 @@ function Signal:schedule_update(fn) self:schedule_set(fn(self.value)) end
--- @generic U
--- @param fn fun(value: T): U
--- @return Signal --<U>
--- @return u.Signal --<U>
function Signal:map(fn)
local mapped_signal = M.create_memo(function()
local value = self:get()
@@ -92,13 +92,13 @@ function Signal:map(fn)
return mapped_signal
end
--- @return Signal
--- @return u.Signal
function Signal:clone()
return self:map(function(x) return x end)
end
--- @param fn fun(value: T): boolean
--- @return Signal -- <T>
--- @return u.Signal -- <T>
function Signal:filter(fn)
local filtered_signal = M.create_signal(nil, self.name and self.name .. ':filtered' or nil)
local unsubscribe_from_self = self:subscribe(function(value)
@@ -109,7 +109,7 @@ function Signal:filter(fn)
end
--- @param ms number
--- @return Signal -- <T>
--- @return u.Signal -- <T>
function Signal:debounce(ms)
local function set_timeout(timeout, callback)
local timer = (vim.uv or vim.loop).new_timer()
@@ -123,15 +123,15 @@ function Signal:debounce(ms)
local filtered = M.create_signal(self.value, self.name and self.name .. ':debounced' or nil)
--- @type {
-- queued: { value: T, ts: number }[]
-- timer?: uv_timer_t
-- }
--- @diagnostic disable-next-line: undefined-doc-name
--- @type { queued: { value: T, ts: number }[]; timer?: uv_timer_t; }
local state = { queued = {}, timer = nil }
local function clear_timeout()
if state.timer == nil then return end
pcall(function()
--- @diagnostic disable-next-line: undefined-field
state.timer:stop()
--- @diagnostic disable-next-line: undefined-field
state.timer:close()
end)
state.timer = nil
@@ -198,13 +198,13 @@ end
CURRENT_CONTEXT = nil
--- @class ExecutionContext
--- @field signals table<Signal, boolean>
--- @class u.ExecutionContext
--- @field signals table<u.Signal, boolean>
local ExecutionContext = {}
M.ExecutionContext = ExecutionContext
ExecutionContext.__index = ExecutionContext
--- @return ExecutionContext
--- @return u.ExecutionContext
function ExecutionContext:new()
return setmetatable({
signals = {},
@@ -215,7 +215,7 @@ end
function ExecutionContext.current() return CURRENT_CONTEXT end
--- @param fn function
--- @param ctx ExecutionContext
--- @param ctx u.ExecutionContext
function ExecutionContext:run(fn, ctx)
local oldCtx = CURRENT_CONTEXT
CURRENT_CONTEXT = ctx
@@ -258,14 +258,14 @@ end
--- @param value any
--- @param name? string
--- @return Signal
--- @return u.Signal
function M.create_signal(value, name) return Signal:new(value, name) end
--- @param fn function
--- @param name? string
--- @return Signal
--- @return u.Signal
function M.create_memo(fn, name)
--- @type Signal
--- @type u.Signal
local result
local unsubscribe = M.create_effect(function()
local value = fn()

View File

@@ -6,7 +6,7 @@ local M = {}
---@alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string }
---@alias KeyMaps table<string, fun(): any | string> }
---@alias CmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any; info: Range|nil }
---@alias CmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any; info: u.Range|nil }
--- @generic T
--- @param x `T`
@@ -50,7 +50,7 @@ function M.ucmd(name, cmd, opts)
end
---@param key_seq string
---@param fn fun(key_seq: string):Range|Pos|nil
---@param fn fun(key_seq: string):u.Range|u.Pos|nil
---@param opts? { buffer: number|nil }
function M.define_text_object(key_seq, fn, opts)
local Range = require 'u.range'
@@ -64,7 +64,7 @@ function M.define_text_object(key_seq, fn, opts)
if Range.is(range_or_pos) and range_or_pos:is_empty() then range_or_pos = range_or_pos.start end
if Range.is(range_or_pos) then
local range = range_or_pos --[[@as Range]]
local range = range_or_pos --[[@as u.Range]]
range:set_visual_selection()
else
vim.cmd { cmd = 'normal', args = { '<Esc>' }, bang = true }
@@ -73,8 +73,6 @@ function M.define_text_object(key_seq, fn, opts)
vim.keymap.set({ 'x' }, key_seq, handle_visual, opts and { buffer = opts.buffer } or nil)
local function handle_normal()
local State = require 'u.state'
-- enter visual mode:
vim.cmd { cmd = 'normal', args = { 'v' }, bang = true }
@@ -85,40 +83,21 @@ function M.define_text_object(key_seq, fn, opts)
if Range.is(range_or_pos) then
range_or_pos:set_visual_selection()
elseif Pos.is(range_or_pos) then
local p = range_or_pos --[[@as Pos]]
State.run(0, function(s)
s:track_global_option 'eventignore'
vim.go.eventignore = 'all'
local p = range_or_pos --[[@as u.Pos]]
-- insert a single space, so we can select it:
vim.api.nvim_buf_set_text(0, p.lnum, p.col, p.lnum, p.col, { ' ' })
-- select the space:
Range.new(p, p, 'v'):set_visual_selection()
end)
local original_eventignore = vim.go.eventignore
vim.go.eventignore = 'all'
-- insert a single space, so we can select it:
vim.api.nvim_buf_set_text(0, p.lnum, p.col, p.lnum, p.col, { ' ' })
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
---@type fun(): nil|(fun():any)
local __U__RepeatableOpFunc_rhs = nil
--- This is the global utility function used for operatorfunc
--- in repeatablemap
---@type nil|fun(range: Range): fun():any|nil
-- selene: allow(unused_variable)
function __U__RepeatableOpFunc()
if __U__RepeatableOpFunc_rhs ~= nil then __U__RepeatableOpFunc_rhs() end
end
function M.repeatablemap(mode, lhs, rhs, opts)
vim.keymap.set(mode, lhs, function()
__U__RepeatableOpFunc_rhs = rhs
vim.o.operatorfunc = 'v:lua.__U__RepeatableOpFunc'
return 'g@ '
end, vim.tbl_extend('force', opts or {}, { expr = true }))
end
function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end
return M