range: extmarks/tsquery; renderer: text-change
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
NeoVim tests / code-quality (push) Successful in 1m19s

This commit is contained in:
2025-06-11 20:04:46 -06:00
parent 6f86bfaa42
commit 06e6b88391
17 changed files with 1188 additions and 174 deletions

View File

@@ -5,7 +5,7 @@ local Renderer = require('u.renderer').Renderer
--- @field bufnr number
--- @field b vim.var_accessor
--- @field bo vim.bo
--- @field private renderer u.Renderer
--- @field renderer u.renderer.Renderer
local Buffer = {}
Buffer.__index = Buffer
@@ -75,6 +75,9 @@ function Buffer:autocmd(event, opts)
vim.api.nvim_create_autocmd(event, vim.tbl_extend('force', opts, { buffer = self.bufnr }))
end
--- @param fn function
function Buffer:call(fn) return vim.api.nvim_buf_call(self.bufnr, fn) end
--- @param tree u.renderer.Tree
function Buffer:render(tree) return self.renderer:render(tree) end

View File

@@ -32,7 +32,7 @@ end
--- @param line string
--- @param bufnr? number
function CodeWriter.from_line(line, bufnr)
if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
local ws = line:match '^%s*'
local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr })

View File

@@ -1,9 +1,9 @@
local M = {}
local LOG_ROOT = vim.fs.joinpath(vim.fn.stdpath 'cache', 'u.log')
--- @params name string
function M.file_for_name(name)
return vim.fs.joinpath(vim.fn.stdpath 'cache', 'u.log', name .. '.log.jsonl')
end
function M.file_for_name(name) return vim.fs.joinpath(LOG_ROOT, name .. '.log.jsonl') end
--------------------------------------------------------------------------------
-- Logger class
@@ -11,7 +11,6 @@ end
--- @class u.Logger
--- @field name string
--- @field private fd number
local Logger = {}
Logger.__index = Logger
M.Logger = Logger
@@ -20,10 +19,7 @@ M.Logger = Logger
function Logger.new(name)
local file_path = M.file_for_name(name)
vim.fn.mkdir(vim.fs.dirname(file_path), 'p')
local self = setmetatable({
name = name,
fd = (vim.uv or vim.loop).fs_open(file_path, 'a', tonumber('644', 8)),
}, Logger)
local self = setmetatable({ name = name }, Logger)
return self
end
@@ -32,10 +28,12 @@ end
function Logger:write(level, ...)
local data = { ... }
if #data == 1 then data = data[1] end
(vim.uv or vim.loop).fs_write(
self.fd,
vim.json.encode { ts = os.date(), level = level, data = data } .. '\n'
local f = assert(io.open(M.file_for_name(self.name), 'a'), 'could not open file')
assert(
f:write(vim.json.encode { ts = os.date(), level = level, data = data } .. '\n'),
'could not write to file'
)
f:close()
end
function Logger:trace(...) self:write('INFO', ...) end
@@ -64,6 +62,12 @@ function M.setup()
vim.cmd.terminal('tail -f "' .. log_file_path .. '"')
vim.cmd.startinsert()
end, { nargs = '*' })
vim.api.nvim_create_user_command(
'Logroot',
function() vim.api.nvim_echo({ { LOG_ROOT } }, false, {}) end,
{}
)
end
return M

View File

@@ -67,6 +67,15 @@ function Pos.__sub(x, y)
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)
@@ -96,6 +105,8 @@ 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

View File

@@ -1,6 +1,13 @@
local Pos = require 'u.pos'
local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true)
local NS = vim.api.nvim_create_namespace 'u.range'
---@class u.ExtmarkRange
---@field bufnr number
---@field id number
local ExtmarkRange = {}
ExtmarkRange.__index = ExtmarkRange
--- @class u.Range
--- @field start u.Pos
@@ -83,6 +90,34 @@ function Range.from_marks(lpos, rpos)
return Range.new(start, stop, mode)
end
--- @param bufnr number
--- @param extmark vim.api.keyset.get_extmark_item_by_id
function Range.from_extmark(bufnr, extmark)
---@type integer, integer, vim.api.keyset.extmark_details | nil
local start_row0, start_col0, details = unpack(extmark)
local start = Pos.new(bufnr, start_row0 + 1, start_col0 + 1)
local stop = details and Pos.new(bufnr, details.end_row + 1, details.end_col)
if stop ~= nil then
-- Check for invalid extmark range:
if stop < start then return Range.new(stop) end
-- Check for stop-mark past the end of the buffer:
local buf_max_lines = vim.api.nvim_buf_line_count(bufnr)
if stop.lnum > buf_max_lines then
stop.lnum = buf_max_lines
stop = stop:eol()
end
-- A stop mark at position 0 means it is at the end of the last line.
-- Move it back.
if stop.col == 0 then stop = stop:must_next(-1) end
end
return Range.new(start, stop, 'v')
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
@@ -115,7 +150,7 @@ end
function Range.from_motion(motion, opts)
-- Options handling:
opts = opts or {}
if opts.bufnr == nil then opts.bufnr = vim.api.nvim_get_current_buf() end
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
@@ -146,6 +181,8 @@ function Range.from_motion(motion, opts)
_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',
@@ -153,6 +190,7 @@ function Range.from_motion(motion, opts)
args = { ESC .. 'g@' .. motion },
mods = { silent = true },
}
vim.o.eventignore = old_eventignore
end)
local captured_range = _G.Range__from_motion_opfunc_captured_range
@@ -193,6 +231,26 @@ function Range.from_motion(motion, opts)
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:
@@ -220,19 +278,22 @@ end
--- @param args unknown
--- @return u.Range|nil
function Range.from_cmd_args(args)
--- @type 'v'|'V'
local mode
--- @type nil|u.Pos
local start
local stop
if args.range == 0 then
return nil
else
start = Pos.from_pos "'<"
stop = Pos.from_pos "'>"
mode = stop:is_col_max() and 'V' or 'v'
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
return Range.new(start, stop, mode)
end
function Range.find_nearest_brackets()
@@ -272,6 +333,13 @@ function Range:to_linewise()
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
@@ -313,25 +381,32 @@ end
--- @param left string
--- @param right string
function Range:save_to_pos(left, right)
if self:is_empty() then
self.start:save_to_pos(left)
self.start:save_to_pos(right)
else
self.start:save_to_pos(left)
self.stop:save_to_pos(right)
end
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)
if self:is_empty() then
self.start:save_to_mark(left)
self.start:save_to_mark(right)
else
self.start:save_to_mark(left)
self.stop:save_to_mark(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()
local r = self:to_charwise()
local end_row = r.stop.lnum - 1
local end_col = r.stop.col
if self.mode == 'V' then
end_row = end_row + 1
end_col = 0
end
local id = vim.api.nvim_buf_set_extmark(r.start.bufnr, NS, r.start.lnum - 1, r.start.col - 1, {
right_gravity = false,
end_right_gravity = true,
end_row = end_row,
end_col = end_col,
})
return ExtmarkRange.new(r.start.bufnr, id)
end
function Range:set_visual_selection()
@@ -348,14 +423,46 @@ function Range:set_visual_selection()
self.stop:save_to_pos '.'
end
--------------------------------------------------------------------------------
-- Range.from_* functions:
--------------------------------------------------------------------------------
--------------------------------------------------------------------------------
-- 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<string, u.Range[]>
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
@@ -596,4 +703,17 @@ function Range:highlight(group, opts)
}
end
function ExtmarkRange.new(bufnr, id) return setmetatable({ bufnr = bufnr, id = id }, ExtmarkRange) end
function ExtmarkRange:range()
return Range.from_extmark(
self.bufnr,
vim.api.nvim_buf_get_extmark_by_id(self.bufnr, NS, self.id, {
details = true,
})
)
end
function ExtmarkRange:delete() vim.api.nvim_buf_del_extmark(self.bufnr, NS, self.id) end
return Range

View File

@@ -1,12 +1,23 @@
function _G.URendererOpFuncSwallow() end
local M = {}
local H = {}
--- @alias u.renderer.Tag { kind: 'tag'; name: string, attributes: table<string, unknown>, children: u.renderer.Tree }
--- @alias u.renderer.TagEventHandler fun(tag: u.renderer.Tag, mode: string, lhs: string): string
--- @alias u.renderer.TagAttributes { [string]?: unknown; imap?: table<string, u.renderer.TagEventHandler>; nmap?: table<string, u.renderer.TagEventHandler>; vmap?: table<string, u.renderer.TagEventHandler>; xmap?: table<string, u.renderer.TagEventHandler>; omap?: table<string, u.renderer.TagEventHandler>, on_change?: fun(text: string): unknown }
--- @class u.renderer.Tag
--- @field kind 'tag'
--- @field name string
--- @field attributes u.renderer.TagAttributes
--- @field children u.renderer.Tree
--- @alias u.renderer.Node nil | boolean | string | u.renderer.Tag
--- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[]
-- luacheck: ignore
--- @type table<string, fun(attributes: table<string, any>, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: table<string, any>, children: u.renderer.Tree): u.renderer.Tag>
--- @type table<string, fun(attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag>
M.h = setmetatable({}, {
__call = function(_, name, attributes, children)
return {
@@ -17,7 +28,6 @@ M.h = setmetatable({}, {
}
end,
__index = function(_, name)
-- vim.print('dynamic hl ' .. name)
return function(attributes, children)
return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children)
end
@@ -25,14 +35,19 @@ M.h = setmetatable({}, {
})
-- Renderer {{{
--- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any }
--- @class u.renderer.RendererExtmark
--- @field id? number
--- @field start [number, number]
--- @field stop [number, number]
--- @field opts vim.api.keyset.set_extmark
--- @field tag u.renderer.Tag
--- @class u.Renderer
--- @class u.renderer.Renderer
--- @field bufnr number
--- @field ns number
--- @field changedtick number
--- @field old { lines: string[]; extmarks: RendererExtmark[] }
--- @field curr { lines: string[]; extmarks: RendererExtmark[] }
--- @field old { lines: string[]; extmarks: u.renderer.RendererExtmark[] }
--- @field curr { lines: string[]; extmarks: u.renderer.RendererExtmark[] }
local Renderer = {}
Renderer.__index = Renderer
M.Renderer = Renderer
@@ -51,7 +66,7 @@ function Renderer.is_tag_arr(x)
end
--- @param bufnr number|nil
function Renderer.new(bufnr) -- {{{
if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
if vim.b[bufnr]._renderer_ns == nil then
vim.b[bufnr]._renderer_ns = vim.api.nvim_create_namespace('my.renderer:' .. tostring(bufnr))
@@ -64,6 +79,12 @@ function Renderer.new(bufnr) -- {{{
old = { lines = {}, extmarks = {} },
curr = { lines = {}, extmarks = {} },
}, Renderer)
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, {
buffer = bufnr,
callback = function() self:_on_text_changed() end,
})
return self
end -- }}}
@@ -128,7 +149,7 @@ function Renderer.markup_to_lines(opts) -- {{{
end -- }}}
--- @param opts {
--- tree: string;
--- tree: u.renderer.Tree;
--- format_tag?: fun(tag: u.renderer.Tag): string;
--- }
function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end
@@ -136,7 +157,7 @@ function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_
--- @param bufnr number
--- @param old_lines string[] | nil
--- @param new_lines string[]
function Renderer.patch_lines(bufnr, old_lines, new_lines)
function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{
--
-- Helpers:
--
@@ -189,7 +210,7 @@ function Renderer.patch_lines(bufnr, old_lines, new_lines)
-- No change
end
end
end
end -- }}}
--- @param tree u.renderer.Tree
function Renderer:render(tree) -- {{{
@@ -199,7 +220,7 @@ function Renderer:render(tree) -- {{{
self.changedtick = changedtick
end
--- @type RendererExtmark[]
--- @type u.renderer.RendererExtmark[]
local extmarks = {}
--- @type string[]
@@ -214,31 +235,36 @@ function Renderer:render(tree) -- {{{
tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or hl
end
local extmark = tag.attributes.extmark
local extmark_opts = tag.attributes.extmark or {}
-- Set any necessary keymaps:
for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do
for lhs, _ in pairs(tag.attributes[mode .. 'map'] or {}) do
-- Force creating an extmark if there are key handlers. To accurately
-- sense the bounds of the text, we need an extmark:
extmark = extmark or {}
vim.keymap.set(
'n',
lhs,
function() return self:_expr_map_callback('n', lhs) end,
{ buffer = self.bufnr, expr = true, replace_keycodes = true }
)
vim.keymap.set(mode, lhs, function()
local result = self:_expr_map_callback(mode, lhs)
-- If the handler indicates that it wants to swallow the event,
-- we have to convert that intention into something compatible
-- with expr-mappings, which don't support '<Nop>' (they try to
-- execute the literal characters). We'll use the 'g@' operator
-- to do that, forwarding the event to an operatorfunc that does
-- nothing:
if result == '' then
vim.go.operatorfunc = 'v:lua.URendererOpFuncSwallow'
return 'g@ '
end
return result
end, { buffer = self.bufnr, expr = true, replace_keycodes = true })
end
end
if extmark then
table.insert(extmarks, {
start = start0,
stop = stop0,
opts = extmark,
tag = tag,
})
end
table.insert(extmarks, {
start = start0,
stop = stop0,
opts = extmark_opts,
tag = tag,
})
end
end, -- }}}
}
@@ -248,25 +274,6 @@ function Renderer:render(tree) -- {{{
self:_reconcile()
end -- }}}
--- @private
--- @param start integer
--- @param end_ integer
--- @param strict_indexing boolean
--- @param replacement string[]
function Renderer:_set_lines(start, end_, strict_indexing, replacement)
vim.api.nvim_buf_set_lines(self.bufnr, start, end_, strict_indexing, replacement)
end
--- @private
--- @param start_row integer
--- @param start_col integer
--- @param end_row integer
--- @param end_col integer
--- @param replacement string[]
function Renderer:_set_text(start_row, start_col, end_row, end_col, replacement)
vim.api.nvim_buf_set_text(self.bufnr, start_row, start_col, end_row, end_col, replacement)
end
--- @private
function Renderer:_reconcile() -- {{{
--
@@ -277,10 +284,15 @@ function Renderer:_reconcile() -- {{{
--
-- Step 2: reconcile extmarks:
-- You may be tempted to try to keep track of which extmarks are needed, and
-- only delete those that are not needed. However, each time a tree is
-- rendered, brand new extmarks are created. For simplicity, it is better to
-- just delete all extmarks, and recreate them.
--
-- Clear current extmarks:
vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1)
-- Set current extmarks:
for _, extmark in ipairs(self.curr.extmarks) do
extmark.id = vim.api.nvim_buf_set_extmark(
@@ -292,6 +304,13 @@ function Renderer:_reconcile() -- {{{
id = extmark.id,
end_row = extmark.stop[1],
end_col = extmark.stop[2],
-- If we change the text starting from the beginning (where the extmark
-- is), we don't want the extmark to move to the right.
right_gravity = false,
-- If we change the text starting from the end (where the end extmark
-- is), we don't want the extmark to stay stationary: we want it to
-- move to the right.
end_right_gravity = true,
}, extmark.opts)
)
end
@@ -306,7 +325,7 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
-- find the tag with the smallest intersection that contains the cursor:
local pos0 = vim.api.nvim_win_get_cursor(0)
pos0[1] = pos0[1] - 1 -- make it actually 0-based
local pos_infos = self:get_pos_infos(pos0)
local pos_infos = self:get_tags_at(pos0)
if #pos_infos == 0 then return lhs end
@@ -316,9 +335,10 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
local tag = pos_info.tag
-- is the tag listening?
--- @type u.renderer.TagEventHandler?
local f = vim.tbl_get(tag.attributes, mode .. 'map', lhs)
if type(f) == 'function' then
local result = f()
local result = f(tag, mode, lhs)
if result == '' then
-- bubble-up to the next tag, but set cancel to true, in case there are
-- no more tags to bubble up to:
@@ -333,21 +353,145 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
return cancel and '' or lhs
end -- }}}
function Renderer:_on_text_changed() -- {{{
-- Reset changedtick, so that the reconciler knows to refresh its cached
-- buffer-content before computing the diff:
self.changedtick = 0
--- @type integer, integer
local l, c = unpack(vim.api.nvim_win_get_cursor(0))
l = l - 1 -- make it actually 0-based
local pos_infos = self:get_tags_at({ l, c }, 'i')
for _, pos_info in ipairs(pos_infos) do
local extmark_inf = pos_info.extmark
local tag = pos_info.tag
local on_change = tag.attributes.on_change
if on_change and type(on_change) == 'function' then
local extmark =
vim.api.nvim_buf_get_extmark_by_id(self.bufnr, self.ns, extmark_inf.id, { details = true })
--- @type integer, integer, vim.api.keyset.extmark_details
local start_row0, start_col0, details = unpack(extmark)
local end_row0, end_col0 = details.end_row, details.end_col
local buf_max_line0 = math.max(1, vim.api.nvim_buf_line_count(self.bufnr) - 1)
if end_row0 > buf_max_line0 then
end_row0 = buf_max_line0
local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1]
or ''
end_col0 = last_line:len()
end
if end_col0 == 0 then
end_row0 = end_row0 - 1
local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1]
or ''
end_col0 = last_line:len()
end
if start_row0 == end_row0 and start_col0 == end_col0 then
on_change ''
else
local pos1 = { self.bufnr, start_row0 + 1, start_col0 + 1 }
local pos2 = { self.bufnr, end_row0 + 1, end_col0 }
local ok, lines = pcall(vim.fn.getregion, pos1, pos2, { type = 'v' })
if not ok then
vim.api.nvim_echo({
{ '(u.nvim:getregion:invalid-pos) ', 'ErrorMsg' },
{
'{ start, end } = ' .. vim.inspect({ pos1, pos2 }, { newline = ' ', indent = '' }),
},
}, true, {})
error(lines)
end
local text = table.concat(lines, '\n')
on_change(text)
end
end
end
end -- }}}
--- @private
function Renderer:_debug() -- {{{
local prev_w = vim.api.nvim_get_current_win()
vim.cmd.vnew()
local info_bufnr = vim.api.nvim_get_current_buf()
vim.bo.bufhidden = 'delete'
vim.bo.buflisted = false
vim.bo.buftype = 'nowrite'
local ids = {}
local function cleanup()
for _, id in ipairs(ids) do
vim.api.nvim_del_autocmd(id)
end
vim.api.nvim_buf_delete(info_bufnr, { force = true })
end
table.insert(
ids,
vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, {
callback = function()
if vim.api.nvim_get_current_win() ~= prev_w then return end
local l, c = unpack(vim.api.nvim_win_get_cursor(0))
l = l - 1 -- make it actually 0-based
local info = {
cursor = {
pos = { l, c },
tags = self:get_tags_at { l, c },
extmarks = vim.api.nvim_buf_get_extmarks(
self.bufnr,
self.ns,
{ l, c },
{ l, c },
{ details = true, overlap = true }
),
},
computed = {
extmarks = self.curr.extmarks,
},
}
vim.api.nvim_buf_set_lines(info_bufnr, 0, -1, true, vim.split(vim.inspect(info), '\n'))
end,
})
)
table.insert(
ids,
vim.api.nvim_create_autocmd('WinClosed', {
pattern = tostring(vim.api.nvim_get_current_win()),
callback = cleanup,
})
)
table.insert(
ids,
vim.api.nvim_create_autocmd('WinClosed', {
pattern = tostring(prev_w),
callback = cleanup,
})
)
vim.api.nvim_set_current_win(prev_w)
end -- }}}
--- Returns pairs of extmarks and tags associate with said extmarks. The
--- returned tags/extmarks are sorted smallest (innermost) to largest
--- (outermost).
---
--- @private (private for now)
--- @param pos0 [number; number]
--- @return { extmark: RendererExtmark; tag: u.renderer.Tag; }[]
function Renderer:get_pos_infos(pos0) -- {{{
--- @param mode string?
--- @return { extmark: u.renderer.RendererExtmark; tag: u.renderer.Tag; }[]
function Renderer:get_tags_at(pos0, mode) -- {{{
local cursor_line0, cursor_col0 = pos0[1], pos0[2]
if not mode then mode = vim.api.nvim_get_mode().mode end
-- The cursor (block) occupies **two** extmark spaces: one for it's left
-- edge, and one for it's right. We need to do our own intersection test,
-- because the NeoVim API is over-inclusive in what it returns:
--- @type RendererExtmark[]
local intersecting_extmarks = vim
--- @type u.renderer.RendererExtmark[]
local mapped_extmarks = vim
.iter(
vim.api.nvim_buf_get_extmarks(
self.bufnr,
@@ -357,7 +501,7 @@ function Renderer:get_pos_infos(pos0) -- {{{
{ details = true, overlap = true }
)
)
--- @return RendererExtmark
--- @return u.renderer.RendererExtmark
:map(function(ext)
--- @type number, number, number, { end_row?: number; end_col?: number }|nil
local id, line0, col0, details = unpack(ext)
@@ -368,13 +512,43 @@ function Renderer:get_pos_infos(pos0) -- {{{
end
return { id = id, start = start, stop = stop, opts = details }
end)
--- @param ext RendererExtmark
:totable()
local intersecting_extmarks = vim
.iter(mapped_extmarks)
--- @param ext u.renderer.RendererExtmark
:filter(function(ext)
if ext.stop[1] ~= nil and ext.stop[2] ~= nil then
return cursor_line0 >= ext.start[1]
and cursor_col0 >= ext.start[2]
and cursor_line0 <= ext.stop[1]
and cursor_col0 < ext.stop[2]
-- If we've "ciw" and "collapsed" an extmark onto the cursor,
-- the cursor pos will equal the exmark's start AND end. In this
-- case, we want to include the extmark.
if
cursor_line0 == ext.start[1]
and cursor_col0 == ext.start[2]
and cursor_line0 == ext.stop[1]
and cursor_col0 == ext.stop[2]
then
return true
end
return
-- START: line check
cursor_line0 >= ext.start[1]
-- START: column check
and (cursor_line0 ~= ext.start[1] or cursor_col0 >= ext.start[2])
-- STOP: line check
and cursor_line0 <= ext.stop[1]
-- STOP: column check
and (
cursor_line0 ~= ext.stop[1]
or (
mode == 'i'
-- In insert mode, the cursor is "thin", so <= to compensate:
and cursor_col0 <= ext.stop[2]
-- In normal mode, the cursor is "wide", so < to compensate:
or cursor_col0 < ext.stop[2]
)
)
else
return true
end
@@ -384,8 +558,8 @@ function Renderer:get_pos_infos(pos0) -- {{{
-- Sort the tags into smallest (inner) to largest (outer):
table.sort(
intersecting_extmarks,
--- @param x1 RendererExtmark
--- @param x2 RendererExtmark
--- @param x1 u.renderer.RendererExtmark
--- @param x2 u.renderer.RendererExtmark
function(x1, x2)
if
x1.start[1] == x2.start[1]
@@ -407,10 +581,10 @@ function Renderer:get_pos_infos(pos0) -- {{{
-- created extmarks in self.curr.extmarks, which also has which tag each
-- extmark is associated with. Cross-reference with that list to get a list
-- of tags that we need to fire events for:
--- @type { extmark: RendererExtmark; tag: u.renderer.Tag }[]
--- @type { extmark: u.renderer.RendererExtmark; tag: u.renderer.Tag }[]
local matching_tags = vim
.iter(intersecting_extmarks)
--- @param ext RendererExtmark
--- @param ext u.renderer.RendererExtmark
:map(function(ext)
for _, extmark_cache in ipairs(self.curr.extmarks) do
if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end
@@ -420,10 +594,23 @@ function Renderer:get_pos_infos(pos0) -- {{{
return matching_tags
end -- }}}
--- @private
--- @param tag_or_id string | u.renderer.Tag
--- @return { start: [number, number]; stop: [number, number] } | nil
function Renderer:get_tag_bounds(tag_or_id) -- {{{
for _, x in ipairs(self.curr.extmarks) do
local pos = { start = x.start, stop = x.stop }
local does_tag_match = type(tag_or_id) == 'string' and x.tag.attributes.id == tag_or_id
or x.tag == tag_or_id
if does_tag_match then return pos end
end
end -- }}}
-- }}}
-- TreeBuilder {{{
--- @class u.TreeBuilder
--- @class u.renderer.TreeBuilder
--- @field private nodes u.renderer.Node[]
local TreeBuilder = {}
TreeBuilder.__index = TreeBuilder
@@ -435,7 +622,7 @@ function TreeBuilder.new()
end
--- @param nodes u.renderer.Tree
--- @return u.TreeBuilder
--- @return u.renderer.TreeBuilder
function TreeBuilder:put(nodes)
table.insert(self.nodes, nodes)
return self
@@ -444,15 +631,15 @@ end
--- @param name string
--- @param attributes? table<string, any>
--- @param children? u.renderer.Node | u.renderer.Node[]
--- @return u.TreeBuilder
--- @return u.renderer.TreeBuilder
function TreeBuilder:put_h(name, attributes, children)
local tag = M.h(name, attributes, children)
table.insert(self.nodes, tag)
return self
end
--- @param fn fun(TreeBuilder): any
--- @return u.TreeBuilder
--- @param fn fun(tb: u.renderer.TreeBuilder): any
--- @return u.renderer.TreeBuilder
function TreeBuilder:nest(fn)
local nested_writer = TreeBuilder.new()
fn(nested_writer)
@@ -460,19 +647,40 @@ function TreeBuilder:nest(fn)
return self
end
--- @generic T
--- @param arr T[]
--- @param f fun(tb: u.renderer.TreeBuilder, item: T, idx: number): any
function TreeBuilder:ipairs(arr, f)
return self:nest(function(tb)
for idx, item in ipairs(arr) do
f(tb, item, idx)
end
end)
end
--- @param tab table
--- @param f fun(tb: u.renderer.TreeBuilder, key: any, value: any): any
function TreeBuilder:pairs(tab, f)
return self:nest(function(tb)
for k, v in pairs(tab) do
f(tb, k, v)
end
end)
end
--- @return u.renderer.Tree
function TreeBuilder:tree() return self.nodes end
-- }}}
-- Levenshtein utility {{{
-- luacheck: ignore
--- @alias LevenshteinChange<T> ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; })
--- @alias u.renderer.LevenshteinChange<T> ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; })
--- @private
--- @generic T
--- @param x `T`[]
--- @param y T[]
--- @param cost? { of_delete?: fun(x: T): number; of_add?: fun(x: T): number; of_change?: fun(x: T, y: T): number; }
--- @return LevenshteinChange<T>[] The changes, from last (greatest index) to first (smallest index).
--- @return u.renderer.LevenshteinChange<T>[] The changes, from last (greatest index) to first (smallest index).
function H.levenshtein(x, y, cost)
-- At the moment, this whole `cost` plumbing is not used. Deletes have the
-- same cost as Adds or Changes. I can imagine a future, however, where
@@ -523,7 +731,7 @@ function H.levenshtein(x, y, cost)
-- Backtrack to find the changes
local i = m
local j = n
--- @type LevenshteinChange[]
--- @type u.renderer.LevenshteinChange[]
local changes = {}
while i > 0 or j > 0 do
@@ -540,7 +748,7 @@ function H.levenshtein(x, y, cost)
if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
-- potential change
if x[i] ~= y[j] then
--- @type LevenshteinChange
--- @type u.renderer.LevenshteinChange
local change = { kind = 'change', from = x[i], index = i, to = y[j] }
table.insert(changes, change)
end
@@ -548,13 +756,13 @@ function H.levenshtein(x, y, cost)
j = j - 1
elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
-- addition
--- @type LevenshteinChange
--- @type u.renderer.LevenshteinChange
local change = { kind = 'add', item = y[j], index = i + 1 }
table.insert(changes, change)
j = j - 1
elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then
-- deletion
--- @type LevenshteinChange
--- @type u.renderer.LevenshteinChange
local change = { kind = 'delete', item = x[i], index = i }
table.insert(changes, change)
i = i - 1

View File

@@ -4,15 +4,63 @@ local M = {}
-- Types
--
--- @alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string }
--- @alias KeyMaps table<string, fun(): any | string> }
-- luacheck: ignore
--- @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 }
--- @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
--
-- Functions
--
--- Debug utility that prints a value and returns it unchanged.
--- Useful for debugging in the middle of expressions or function chains.
---
--- @generic T
--- @param x `T`
--- @param message? string
--- @return T
--- @param x `T` The value to debug print
--- @param message? string Optional message to print alongside the value
--- @return T The original value, unchanged
---
--- @usage
--- ```lua
--- -- Debug a value in the middle of a chain:
--- local result = some_function()
--- :map(utils.dbg) -- prints the intermediate value
--- :filter(predicate)
---
--- -- Debug with a custom message:
--- local config = utils.dbg(get_config(), "Current config:")
---
--- -- Debug return values:
--- return utils.dbg(calculate_result(), "Final result")
--- ```
function M.dbg(x, message)
local t = {}
if message ~= nil then table.insert(t, message) end
@@ -21,22 +69,37 @@ function M.dbg(x, message)
return x
end
--- A utility for creating user commands that also pre-computes useful information
--- and attaches it to the arguments.
--- 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
--- -- Example:
--- ucmd('MyCmd', function(args)
--- -- print the visually selected text:
--- -- 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 vtext as an array of lines:
--- -- 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 })
--- ```
--- @param name string
--- @param cmd string | fun(args: CmdArgs): any
-- luacheck: ignore
--- @param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string }
function M.ucmd(name, cmd, opts)
local Range = require 'u.range'
@@ -48,9 +111,81 @@ function M.ucmd(name, cmd, opts)
return cmd(args)
end
end
vim.api.nvim_create_user_command(name, cmd2, opts or {})
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
--- Gets the current editor dimensions.
--- Useful for positioning floating windows or calculating layout sizes.
---
--- @return { width: number, height: number } The editor dimensions in columns and lines
---
--- @usage
--- ```lua
--- -- Center a floating window:
--- local dims = utils.get_editor_dimensions()
--- local win_width = 80
--- local win_height = 20
--- local col = math.floor((dims.width - win_width) / 2)
--- local row = math.floor((dims.height - win_height) / 2)
---
--- vim.api.nvim_open_win(bufnr, true, {
--- relative = 'editor',
--- width = win_width,
--- height = win_height,
--- col = col,
--- row = row,
--- })
---
--- -- Check if editor is wide enough for side-by-side layout:
--- local dims = utils.get_editor_dimensions()
--- if dims.width >= 160 then
--- -- Use side-by-side layout
--- else
--- -- Use stacked layout
--- end
--- ```
function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end
return M