initial commit

This commit is contained in:
2024-08-31 22:36:09 -06:00
commit 61460f0180
18 changed files with 1646 additions and 0 deletions

8
lua/__tt_test_tools.lua Normal file
View File

@@ -0,0 +1,8 @@
local function withbuf(lines, f)
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

72
lua/tt/buffer.lua Normal file
View File

@@ -0,0 +1,72 @@
local Range = require 'tt.range'
---@class Buffer
---@field buf number
local Buffer = {}
---@param buf? number
---@return Buffer
function Buffer.new(buf)
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
local b = { buf = buf }
setmetatable(b, { __index = Buffer })
return b
end
---@return Buffer
function Buffer.current() return Buffer.new(0) end
---@param listed boolean
---@param scratch boolean
---@return Buffer
function Buffer.create(listed, scratch) return Buffer.new(vim.api.nvim_create_buf(listed, scratch)) end
function Buffer:set_tmp_options()
self:set_option('bufhidden', 'delete')
self:set_option('buflisted', false)
self:set_option('buftype', 'nowrite')
end
---@param nm string
function Buffer:get_option(nm) return vim.api.nvim_get_option_value(nm, { buf = self.buf }) end
---@param nm string
function Buffer:set_option(nm, val) return vim.api.nvim_set_option_value(nm, val, { buf = self.buf }) end
---@param nm string
function Buffer:get_var(nm) return vim.api.nvim_buf_get_var(self.buf, nm) end
---@param nm string
function Buffer:set_var(nm, val) return vim.api.nvim_buf_set_var(self.buf, nm, val) end
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
---@param line string
function Buffer:append_line(line)
local start = -1
if self:is_empty() then start = -2 end
vim.api.nvim_buf_set_lines(self.buf, start, -1, false, { 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
return Range.from_line(self.buf, num)
end
---@param start number 0-based line index
---@param stop number 0-based line index
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 }
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
return Buffer

78
lua/tt/codewriter.lua Normal file
View File

@@ -0,0 +1,78 @@
local Buffer = require 'tt.buffer'
---@class CodeWriter
---@field lines string[]
---@field indent_level number
---@field indent_str string
local CodeWriter = {}
---@param indent_level? number
---@param indent_str? string
---@return 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
local cw = {
lines = {},
indent_level = indent_level,
indent_str = indent_str,
}
setmetatable(cw, { __index = CodeWriter })
return cw
end
---@param p Pos
function CodeWriter.from_pos(p)
local line = Buffer.new(p.buf):line0(p.lnum):text()
return CodeWriter.from_line(line, p.buf)
end
---@param line string
---@param buf? number
function CodeWriter.from_line(line, buf)
if buf == nil then buf = vim.api.nvim_get_current_buf() end
local ws = line:match '^%s*'
local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = buf })
local shiftwidth = vim.api.nvim_get_option_value('shiftwidth', { buf = buf })
local indent_level = 0
local indent_str = ''
if expandtab then
while #indent_str < shiftwidth do
indent_str = indent_str .. ' '
end
indent_level = #ws / shiftwidth
else
indent_str = '\t'
indent_level = #ws
end
return CodeWriter.new(indent_level, indent_str)
end
---@param line string
function CodeWriter:write_raw(line)
if line:find '\n' then error 'line contains newline character' end
line = line:gsub('^\n+', '')
line = line:gsub('\n+$', '')
table.insert(self.lines, line)
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
function CodeWriter:indent(f)
local cw = {
lines = self.lines,
indent_level = self.indent_level + 1,
indent_str = self.indent_str,
}
setmetatable(cw, { __index = CodeWriter })
if f ~= nil then f(cw) end
return cw
end
return CodeWriter

43
lua/tt/opkeymap.lua Normal file
View File

@@ -0,0 +1,43 @@
local Range = require 'tt.range'
local vim_repeat = require 'tt.repeat'
---@type fun(range: Range): nil|(fun():any)
local MyOpKeymapOpFunc_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'
-- selene: allow(unused_variable)
function MyOpKeymapOpFunc(ty)
if MyOpKeymapOpFunc_rhs ~= nil then
local range = Range.from_op_func(ty)
local repeat_inject = MyOpKeymapOpFunc_rhs(range)
vim_repeat.set(function()
vim.o.operatorfunc = 'v:lua.MyOpKeymapOpFunc'
if repeat_inject ~= nil and type(repeat_inject) == 'function' then repeat_inject() end
vim_repeat.native_repeat()
end)
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: Range): nil|(fun():any) This function may return another function, which is called whenever the operator is repeated
---@param opts? vim.keymap.set.Opts
local function opkeymap(mode, lhs, rhs, opts)
vim.keymap.set(mode, lhs, function()
MyOpKeymapOpFunc_rhs = rhs
vim.o.operatorfunc = 'v:lua.MyOpKeymapOpFunc'
return 'g@'
end, vim.tbl_extend('force', opts or {}, { expr = true }))
end
return opkeymap

254
lua/tt/pos.lua Normal file
View File

@@ -0,0 +1,254 @@
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
---@class Pos
---@field buf number buffer number
---@field lnum number 1-based line index
---@field col number 1-based column index
---@field off number
local Pos = {}
Pos.MAX_COL = MAX_COL
---@param buf? number
---@param lnum number
---@param col number
---@param off? number
---@return 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
local pos = {
buf = buf,
lnum = lnum,
col = col,
off = off,
}
local function str()
if pos.off ~= 0 then
return string.format('Pos(%d:%d){buf=%d, off=%d}', pos.lnum, pos.col, pos.buf, pos.off)
else
return string.format('Pos(%d:%d){buf=%d}', pos.lnum, pos.col, pos.buf)
end
end
setmetatable(pos, {
__index = Pos,
__tostring = str,
__lt = Pos.__lt,
__le = Pos.__le,
__eq = Pos.__eq,
})
return pos
end
function Pos.is(x)
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
---@param name string
---@return 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])
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 col = self.col
if self:is_col_max() 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
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
local p = self:as_real()
vim.fn.setpos(pos, { p.buf, p.lnum + 1, p.col + 1, p.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, {})
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)
end
---@param dir? -1|1
---@param must? boolean
---@return 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
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
if col > line_max_col then
col = 0
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 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)
line = line - 1
end
return Pos.new(self.buf, 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: 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: 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
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? Pos[]
---@return 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
---@type number
local i
---@type string
local c_match
if is_opener then
i, _ = vim.iter(openers):enumerate():find(function(_, c2) return c == c2 end)
c_match = closers[i]
else
i, _ = vim.iter(closers):enumerate():find(function(_, c2) return c == c2 end)
c_match = openers[i]
end
---@type Pos|nil
local cur = self
---@return 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
if is_opener then
-- scan forward
return cur:next()
else
-- scan backward
return cur:next(-1)
end
end
-- scan until we find a 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
return Pos

410
lua/tt/range.lua Normal file
View File

@@ -0,0 +1,410 @@
local Pos = require 'tt.pos'
local State = require 'tt.state'
local utils = require 'tt.utils'
---@class Range
---@field start Pos
---@field stop Pos
---@field mode 'v'|'V'
local Range = {}
---@param start Pos
---@param stop Pos
---@param mode? 'v'|'V'
---@return Range
function Range.new(start, stop, mode)
if stop < start then
start, stop = stop, start
end
local r = { start = start, stop = stop, mode = mode or 'v' }
local function str()
---@param p Pos
local function posstr(p)
if 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(r.start)
local _2 = posstr(r.stop)
return string.format('Range{buf=%d, mode=%s, start=%s, stop=%s}', r.start.buf, r.mode, _1, _2)
end
setmetatable(r, { __index = Range, __tostring = str })
return r
end
function Range.is(x)
local mt = getmetatable(x)
return mt and mt.__index == Range
end
---@param lpos string
---@param rpos string
---@return 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 buf? number
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)
return Range.new(start, stop, 'V')
end
---@param buf? number
---@param line number 0-based line index
function Range.from_line(buf, line) return Range.from_lines(buf, line, line) end
---@param buf? number
---@param start_line number 0-based line index
---@param stop_line number 0-based line index
function Range.from_lines(buf, start_line, stop_line)
if buf == nil or buf == 0 then buf = vim.api.nvim_get_current_buf() end
return Range.new(Pos.new(buf, start_line, 0), Pos.new(buf, stop_line, Pos.MAX_COL), 'V')
end
---@param text_obj string
---@param opts? { buf?: number; contains_cursor?: boolean; pos?: Pos }
---@return 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
if opts.contains_cursor == nil then opts.contains_cursor = false end
---@type "a" | "i"
local selection_type = text_obj:sub(1, 1)
local obj_type = text_obj:sub(#text_obj, #text_obj)
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
vim.api.nvim_buf_call(opts.buf, function()
positions = State.run(0, function(s)
s:track_pos '.'
s:track_pos "'["
s:track_pos "']"
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 "']"
-- For some reason,
-- vim.cmd.normal { cmd = 'normal', args = { '""y' .. text_obj }, mods = { silent = true } }
-- does not work in all cases. We resort to using feedkeys instead:
utils.feedkeys('""y' .. text_obj)
local start = Pos.from_pos "'["
local 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)
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 opts.contains_cursor and not Range.new(start, stop):contains(cursor) then return nil end
if is_quote and selection_type == 'a' then
start = start:find_next(1, obj_type) or start
stop = stop:find_next(-1, obj_type) or stop
end
return Range.new(start, stop)
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 Range|nil
function Range.from_cmd_args(args)
---@type 'v'|'V'
local mode
---@type nil|Pos
local start
local stop
if args.range == 0 then
return nil
else
start = Pos.from_pos "'<"
stop = Pos.from_pos "'>"
if stop:is_col_max() then
mode = 'V'
else
mode = 'v'
end
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 }
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 }
end
---@param ranges (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
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]
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
end
return result
end
function Range:clone() return Range.new(self.start:clone(), self.stop:clone(), self.mode) end
function Range:line_count() return self.stop.lnum + self.start.lnum + 1 end
function Range:to_linewise()
local r = self:clone()
r.mode = 'V'
r.start.col = 0
r.stop.col = Pos.MAX_COL
return r
end
function Range:is_empty() return self.start == self.stop end
function Range:trim_start()
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()
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 p Pos
function Range:contains(p) return p >= self.start and p <= self.stop end
---@return string[]
function Range:lines()
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
end
---@return string
function Range:text() return vim.fn.join(self:lines(), '\n') end
---@param i number 1-based
---@param j? number 1-based
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
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 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,
}
end
---@param replacement nil|string|string[]
function Range:replace(replacement)
if type(replacement) == 'string' then replacement = vim.fn.split(replacement, '\n') end
if replacement == nil and self.mode == 'V' then
-- delete the lines:
vim.api.nvim_buf_set_lines(self.start.buf, self.start.lnum, self.stop.lnum + 1, false, {})
else
if replacement == nil then replacement = { '' } end
if self.mode == 'v' then
-- Fixup the bounds:
local last_line = vim.api.nvim_buf_get_lines(self.stop.buf, self.stop.lnum, self.stop.lnum + 1, false)[1] or ''
local max_col = #last_line
if last_line ~= '' then max_col = max_col + 1 end
vim.api.nvim_buf_set_text(
self.start.buf,
self.start.lnum,
self.start.col,
self.stop.lnum,
math.min(self.stop.col + 1, max_col),
replacement
)
else
vim.api.nvim_buf_set_lines(self.start.buf, self.start.lnum, self.stop.lnum + 1, false, replacement)
end
end
end
---@param amount number
function Range:shrink(amount)
local start = self.start
local stop = self.stop
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 = start 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 then error 'error in Range:must_shrink: Range:shrink() returned nil' end
return shrunk
end
---@param left string
---@param right string
function Range:save_to_pos(left, right)
self.start:save_to_pos(left)
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.stop:save_to_mark(right)
end
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
if vim.api.nvim_get_mode().mode == 'n' then
vim.cmd.normal { cmd = 'normal', args = { '`a' .. mode .. '`b' }, bang = true }
else
utils.feedkeys '`ao`b'
end
return nil
end)
end
return Range

66
lua/tt/repeat.lua Normal file
View File

@@ -0,0 +1,66 @@
local M = {}
local function _normal(cmd) vim.cmd.normal { cmd = 'normal', args = { cmd }, bang = true } end
local function _feedkeys(keys, mode)
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), mode or 'nx', true)
end
M.native_repeat = function() _feedkeys '.' end
M.native_undo = function() _feedkeys 'u' end
---@param cmd? string|fun():unknown
function M.set(cmd)
local ts = vim.b.changedtick
vim.b.tt_changedtick = ts
if cmd ~= nil then vim.b.tt_repeatcmd = cmd end
end
---@generic T
---@param cmd string|fun():T
---@return T
function M.run(cmd)
M.set(cmd)
local result = cmd()
M.set()
return result
end
function M.do_repeat()
local ts, tt_ts, tt_cmd = vim.b.changedtick, vim.b.tt_changedtick, vim.b.tt_repeatcmd
if
-- (force formatter break)
tt_ts == nil
or tt_cmd == nil
-- has the buffer been modified after we last modified it?
or ts > tt_ts
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()
M.native_undo()
-- Update the current TS on the next event tick,
-- to make sure we get the latest
vim.schedule(M.set)
end
function M.setup()
vim.keymap.set('n', '.', M.do_repeat)
vim.keymap.set('n', 'u', M.undo)
end
return M

86
lua/tt/state.lua Normal file
View File

@@ -0,0 +1,86 @@
---@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>
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.g[nm] 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.g[nm] = val
end
end
return State

94
lua/tt/utils.lua Normal file
View File

@@ -0,0 +1,94 @@
local M = {}
--
-- Types
--
---@alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string }
---@alias KeyMaps table<string, fun(): any | string> }
---@param keys string
---@param mode? string
function M.feedkeys(keys, mode)
vim.api.nvim_feedkeys(vim.api.nvim_replace_termcodes(keys, true, false, true), mode or 'nx', true)
end
---@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 }
--- A utility for creating user commands that also pre-computes useful information
--- and attaches it to the arguments.
---
--- ```lua
--- -- Example:
--- ucmd('MyCmd', function(args)
--- -- print the visually selected text:
--- vim.print(args.info:text())
--- -- or get the vtext as an array of lines:
--- vim.print(args.info:lines())
--- end, { nargs = '*', range = true })
--- ```
---@param name string
---@param cmd string | fun(args: CmdArgs): any
---@param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string }
function M.ucmd(name, cmd, opts)
opts = opts or {}
local cmd2 = cmd
if type(cmd) == 'function' then
cmd2 = function(args)
args.info = M.Range.from_cmd_args(args)
return cmd(args)
end
end
vim.api.nvim_create_user_command(name, cmd2, opts or {})
end
---@param key_seq string
---@param fn fun(key_seq: string):Range|Pos|nil
---@param opts? { buffer: number|nil }
function M.define_text_object(key_seq, fn, opts)
local Range = require 'tt.range'
local Pos = require 'tt.pos'
if opts ~= nil and opts.buffer == 0 then opts.buffer = vim.api.nvim_get_current_buf() end
local function handle_visual()
local range_or_pos = fn(key_seq)
if range_or_pos == nil then return end
if Range.is(range_or_pos) then
local range = range_or_pos --[[@as Range]]
range:set_visual_selection()
else
M.feedkeys '<Esc>'
end
end
vim.keymap.set({ 'x' }, key_seq, handle_visual, opts and { buffer = opts.buffer } or nil)
local function handle_normal()
local State = require 'tt.state'
-- enter visual mode:
M.feedkeys 'v'
local range_or_pos = fn(key_seq)
if range_or_pos == nil then return end
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.opt_global.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, { ' ' })
-- select the space:
Range.new(p, p, 'v'):set_visual_selection()
end)
end
end
vim.keymap.set({ 'o' }, key_seq, handle_normal, opts and { buffer = opts.buffer } or nil)
end
return M