pass 1: get rid of obsolete modules
Some checks failed
ci / ci (push) Failing after 3m9s

This commit is contained in:
2026-04-04 10:19:23 -06:00
parent 9199a9bc3a
commit b00d8d2fa7
20 changed files with 29 additions and 3758 deletions

View File

@@ -1,131 +0,0 @@
local Range = require 'u.range'
local Renderer = require('u.renderer').Renderer
--- @class u.Buffer
--- @field bufnr integer
--- @field b vim.var_accessor
--- @field bo vim.bo
--- @field renderer u.renderer.Renderer
local Buffer = {}
Buffer.__index = Buffer
--- @param bufnr? number
--- @return u.Buffer
function Buffer.from_nr(bufnr)
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end
local renderer = Renderer.new(bufnr)
return setmetatable({
bufnr = bufnr,
b = vim.b[bufnr],
bo = vim.bo[bufnr],
renderer = renderer,
}, Buffer)
end
--- @return u.Buffer
function Buffer.current() return Buffer.from_nr(0) end
--- @param listed boolean
--- @param scratch boolean
--- @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()
self.bo.bufhidden = 'delete'
self.bo.buflisted = false
self.bo.buftype = 'nowrite'
end
function Buffer:line_count() return vim.api.nvim_buf_line_count(self.bufnr) end
function Buffer:all() return Range.from_buf_text(self.bufnr) end
function Buffer:is_empty() return self:line_count() == 1 and self:line(1):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.bufnr, start, -1, false, { line })
end
--- @param num number 1-based line index
function Buffer:line(num)
if num < 0 then num = self:line_count() + num + 1 end
return Range.from_line(self.bufnr, num)
end
--- @param start number 1-based line index
--- @param stop number 1-based line index
function Buffer:lines(start, stop) return Range.from_lines(self.bufnr, start, stop) end
--- @param motion string
--- @param opts? { contains_cursor?: boolean, pos?: u.Pos }
function Buffer:motion(motion, opts)
opts = vim.tbl_extend('force', opts or {}, { bufnr = self.bufnr })
return Range.from_motion(motion, opts)
end
--- @param event vim.api.keyset.events|vim.api.keyset.events[]
--- @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.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
--- Filter buffer content through an external command (like Vim's :%!)
--- @param cmd string[] Command to run (with arguments)
--- @param opts? {cwd?: string, preserve_cursor?: boolean}
--- @return nil
--- @throws string Error message if command fails
--- @note Special placeholders in cmd:
--- - $FILE: replaced with the buffer's filename (if any)
--- - $DIR: replaced with the buffer's directory (if any)
function Buffer:filter_cmd(cmd, opts)
opts = opts or {}
local cwd = opts.cwd or vim.uv.cwd()
local old_lines = self:all():lines()
-- Save cursor position if needed, defaulting to true
local save_pos = opts.preserve_cursor ~= false and vim.fn.winsaveview()
-- Run the command
local result = vim
.system(
-- Replace special placeholders in `cmd` with their values:
vim
.iter(cmd)
:map(function(x)
if x == '$FILE' then return vim.api.nvim_buf_get_name(self.bufnr) end
if x == '$DIR' then return vim.fs.dirname(vim.api.nvim_buf_get_name(self.bufnr)) end
return x
end)
:totable(),
{
cwd = cwd,
stdin = old_lines,
text = true,
}
)
:wait()
-- Check for command failure
if result.code ~= 0 then error('Command failed: ' .. (result.stderr or '')) end
-- Process and apply the result
local new_lines = vim.split(result.stdout, '\n')
if new_lines[#new_lines] == '' then table.remove(new_lines) end
Renderer.patch_lines(self.bufnr, old_lines, new_lines)
-- Restore cursor position if saved
if save_pos then vim.fn.winrestview(save_pos) end
end
return Buffer

View File

@@ -1,5 +1,3 @@
local Buffer = require 'u.buffer'
--- @class u.CodeWriter
--- @field lines string[]
--- @field indent_level number
@@ -25,7 +23,8 @@ end
--- @param p u.Pos
function CodeWriter.from_pos(p)
local line = Buffer.from_nr(p.bufnr):line(p.lnum):text()
local Range = require 'u.range'
local line = Range.from_line(p.bufnr, p.lnum):text()
return CodeWriter.from_line(line, p.bufnr)
end

View File

@@ -1,73 +0,0 @@
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(LOG_ROOT, name .. '.log.jsonl') end
--------------------------------------------------------------------------------
-- Logger class
--------------------------------------------------------------------------------
--- @class u.Logger
--- @field name string
local Logger = {}
Logger.__index = Logger
M.Logger = Logger
--- @param name string
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 }, Logger)
return self
end
--- @private
--- @param level string
function Logger:write(level, ...)
local data = { ... }
if #data == 1 then data = data[1] end
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
function Logger:debug(...) self:write('DEBUG', ...) end
function Logger:info(...) self:write('INFO', ...) end
function Logger:warn(...) self:write('WARN', ...) end
function Logger:error(...) self:write('ERROR', ...) end
function M.setup()
vim.api.nvim_create_user_command('Logfollow', function(args)
if #args.fargs == 0 then
vim.print 'expected log name'
return
end
local log_file_path = M.file_for_name(args.fargs[1])
vim.fn.mkdir(vim.fs.dirname(log_file_path), 'p')
vim.system({ 'touch', log_file_path }):wait()
vim.cmd.new()
local winnr = vim.api.nvim_get_current_win()
vim.wo[winnr][0].number = false
vim.wo[winnr][0].relativenumber = false
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

@@ -1,832 +0,0 @@
function _G.URendererOpFuncSwallow() end
local ENABLE_LOG = false
local function log(...)
if not ENABLE_LOG then return end
local f = assert(io.open(vim.fs.joinpath(vim.fn.stdpath 'log', 'u.renderer.log'), 'a+'))
f:write(os.date() .. '\t' .. vim.inspect { ... } .. '\n')
f:close()
end
local M = {}
local H = {}
--- @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: 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 {
kind = 'tag',
name = name,
attributes = attributes or {},
children = children,
}
end,
__index = function(_, name)
return function(attributes, children)
return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children)
end
end,
})
-- Renderer {{{
--- @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.Renderer
--- @field bufnr number
--- @field ns number
--- @field changedtick number
--- @field old { lines: string[], extmarks: u.renderer.RendererExtmark[] }
--- @field curr { lines: string[], extmarks: u.renderer.RendererExtmark[] }
local Renderer = {}
Renderer.__index = Renderer
M.Renderer = Renderer
--- @private
--- @param x any
--- @return boolean
function Renderer.is_tag(x) return type(x) == 'table' and x.kind == 'tag' end
--- @private
--- @param x any
--- @return boolean
function Renderer.is_tag_arr(x)
if type(x) ~= 'table' then return false end
return #x == 0 or not Renderer.is_tag(x)
end
--- @param bufnr number|nil
function Renderer.new(bufnr) -- {{{
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))
end
local self = setmetatable({
bufnr = bufnr,
ns = vim.b[bufnr]._renderer_ns,
changedtick = 0,
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 -- }}}
--- @class u.renderer.MarkupOpts
--- @field tree u.renderer.Tree
--- @field on_tag? fun(tag: u.renderer.Tag, start0: [number, number], stop0: [number, number]): any
function Renderer.markup_to_lines(opts) -- {{{
--- @type string[]
local lines = {}
local curr_line1 = 1
local curr_col1 = 1 -- exclusive: sits one position **beyond** the last inserted text
--- @param s string
local function put(s)
lines[curr_line1] = (lines[curr_line1] or '') .. s
curr_col1 = #lines[curr_line1] + 1
end
local function put_line()
table.insert(lines, '')
curr_line1 = curr_line1 + 1
curr_col1 = 1
end
--- @param node u.renderer.Node
local function visit(node) -- {{{
if node == nil or type(node) == 'boolean' then return end
if type(node) == 'string' then
local node_lines = vim.split(node, '\n')
for lnum, s in ipairs(node_lines) do
if lnum > 1 then put_line() end
put(s)
end
elseif Renderer.is_tag(node) then
local start0 = { curr_line1 - 1, curr_col1 - 1 }
-- visit the children:
if Renderer.is_tag_arr(node.children) then
for _, child in
ipairs(node.children --[[@as u.renderer.Node[] ]])
do
-- newlines are not controlled by array entries, do NOT output a line here:
visit(child)
end
else -- luacheck: ignore
visit(node.children)
end
local stop0 = { curr_line1 - 1, curr_col1 - 1 }
if opts.on_tag then opts.on_tag(node, start0, stop0) end
elseif Renderer.is_tag_arr(node) then
for _, child in ipairs(node) do
-- newlines are not controlled by array entries, do NOT output a line here:
visit(child)
end
end
end -- }}}
visit(opts.tree)
return lines
end -- }}}
--- @class u.renderer.StringOpts
--- @field tree u.renderer.Tree
--- @field format_tag? fun(tag: u.renderer.Tag): string
function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end
--- @param bufnr number
--- @param old_lines string[] | nil
--- @param new_lines string[]
function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{
--
-- Helpers:
--
--- @param start integer
--- @param end_ integer
--- @param strict_indexing boolean
--- @param replacement string[]
local function _set_lines(start, end_, strict_indexing, replacement)
vim.api.nvim_buf_set_lines(bufnr, start, end_, strict_indexing, replacement)
end
--- @param start_row integer
--- @param start_col integer
--- @param end_row integer
--- @param end_col integer
--- @param replacement string[]
local function _set_text(start_row, start_col, end_row, end_col, replacement)
vim.api.nvim_buf_set_text(bufnr, start_row, start_col, end_row, end_col, replacement)
end
-- Morph the text to the desired state:
local line_changes =
H.levenshtein(old_lines or vim.api.nvim_buf_get_lines(bufnr, 0, -1, false), new_lines)
for _, line_change in ipairs(line_changes) do
local lnum0 = line_change.index - 1
if line_change.kind == 'add' then
_set_lines(lnum0, lnum0, true, { line_change.item })
elseif line_change.kind == 'change' then
-- Compute inter-line diff, and apply:
local col_changes =
H.levenshtein(vim.split(line_change.from, ''), vim.split(line_change.to, ''))
for _, col_change in ipairs(col_changes) do
local cnum0 = col_change.index - 1
if col_change.kind == 'add' then
_set_text(lnum0, cnum0, lnum0, cnum0, { col_change.item })
elseif col_change.kind == 'change' then
_set_text(lnum0, cnum0, lnum0, cnum0 + 1, { col_change.to })
elseif col_change.kind == 'delete' then
_set_text(lnum0, cnum0, lnum0, cnum0 + 1, {})
else -- luacheck: ignore
-- No change
end
end
elseif line_change.kind == 'delete' then
_set_lines(lnum0, lnum0 + 1, true, {})
else -- luacheck: ignore
-- No change
end
end
end -- }}}
--- @param tree u.renderer.Tree
function Renderer:render(tree) -- {{{
local changedtick = vim.b[self.bufnr].changedtick
if changedtick ~= self.changedtick then
self.curr = { lines = vim.api.nvim_buf_get_lines(self.bufnr, 0, -1, false) }
self.changedtick = changedtick
end
--- @type u.renderer.RendererExtmark[]
local extmarks = {}
--- @type string[]
local lines = Renderer.markup_to_lines {
tree = tree,
on_tag = function(tag, start0, stop0) -- {{{
if tag.name == 'text' then
local hl = tag.attributes.hl
if type(hl) == 'string' then
tag.attributes.extmark = tag.attributes.extmark or {}
tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or hl
end
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:
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
if mode == 'i' then
return ''
else
vim.go.operatorfunc = 'v:lua.URendererOpFuncSwallow'
return 'g@ '
end
end
return result
end, { buffer = self.bufnr, expr = true, replace_keycodes = true })
end
end
table.insert(extmarks, {
start = start0,
stop = stop0,
opts = extmark_opts,
tag = tag,
})
end
end, -- }}}
}
self.old = self.curr
self.curr = { lines = lines, extmarks = extmarks }
self:_reconcile()
vim.cmd.doautocmd { args = { 'User', 'Renderer:' .. tostring(self.bufnr) .. ':render' } }
end -- }}}
--- @private
function Renderer:_reconcile() -- {{{
--
-- Step 1: morph the text to the desired state:
--
Renderer.patch_lines(self.bufnr, self.old.lines, self.curr.lines)
self.changedtick = vim.b[self.bufnr].changedtick
--
-- 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(
self.bufnr,
self.ns,
extmark.start[1],
extmark.start[2],
vim.tbl_extend('force', {
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
self.old = self.curr
end -- }}}
--- @private
--- @param mode string
--- @param lhs string
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
log('_expr_map_callback: pos0:', pos0)
local pos_infos = self:get_tags_at(pos0)
log('_expr_map_callback: pos_infos:', pos_infos)
if #pos_infos == 0 then return lhs end
-- Find the first tag that is listening for this event:
local cancel = false
for _, pos_info in ipairs(pos_infos) do
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(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:
cancel = true
else
return result
end
end
end
-- Resort to default behavior:
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')
log('_on_text_changed', { cursor_0_0 = { l, c }, pos_infos = pos_infos })
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
log('_on_text_changed: fetched current extmark for pos_info', {
pos_info = pos_info,
curr_extmark = {
start_row0 = start_row0,
start_col0 = start_col0,
end_row0 = end_row0,
end_col0 = end_col0,
details = details,
},
})
if start_row0 == end_row0 and start_col0 == end_col0 then
on_change ''
return
end
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
log('_on_text_changed: after position correction', {
curr_extmark = {
start_row0 = start_row0,
start_col0 = start_col0,
end_row0 = end_row0,
end_col0 = end_col0,
},
})
if start_row0 == end_row0 and start_col0 == end_col0 then
on_change ''
return
end
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
log('_on_text_changed: getregion: invalid-pos ', {
{ pos1, pos2 },
})
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 -- }}}
--- @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
local function autocmd_callback()
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({ 'CursorMoved', 'CursorMovedI' }, {
callback = autocmd_callback,
})
)
table.insert(
ids,
vim.api.nvim_create_autocmd({ 'User' }, {
pattern = 'Renderer:' .. tostring(self.bufnr) .. ':render',
callback = autocmd_callback,
})
)
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]
--- @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
mode = mode:sub(1, 1) -- we don't care about sub-modes
local raw_overlapping_extmarks = vim.api.nvim_buf_get_extmarks(
self.bufnr,
self.ns,
pos0,
pos0,
{ details = true, overlap = true }
)
log(
'get_tags_at: context:',
{ pos0 = pos0, mode = mode, raw_overlapping_extmarks = raw_overlapping_extmarks }
)
-- 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 u.renderer.RendererExtmark[]
local mapped_extmarks = vim
.iter(raw_overlapping_extmarks)
:map(function(ext)
--- @type number, number, number, { end_row?: number, end_col?: number }|nil
local id, line0, col0, details = unpack(ext)
local start = { line0, col0 }
local stop = { line0, col0 }
if details and details.end_row ~= nil and details.end_col ~= nil then
stop = { details.end_row, details.end_col }
end
return { id = id, start = start, stop = stop, opts = details }
end)
:totable()
local intersecting_extmarks = vim
.iter(mapped_extmarks)
:filter(function(ext)
if ext.stop[1] ~= nil and ext.stop[2] ~= nil then
-- 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
end)
:totable()
-- Sort the tags into smallest (inner) to largest (outer):
table.sort(
intersecting_extmarks,
--- @param x1 u.renderer.RendererExtmark
--- @param x2 u.renderer.RendererExtmark
function(x1, x2)
if
x1.start[1] == x2.start[1]
and x1.start[2] == x2.start[2]
and x1.stop[1] == x2.stop[1]
and x1.stop[2] == x2.stop[2]
then
return x1.id < x2.id
end
return x1.start[1] >= x2.start[1]
and x1.start[2] >= x2.start[2]
and x1.stop[1] <= x2.stop[1]
and x1.stop[2] <= x2.stop[2]
end
)
-- When we set the extmarks in the step above, we captured the IDs of the
-- 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: u.renderer.RendererExtmark, tag: u.renderer.Tag }[]
local matching_tags = vim
.iter(intersecting_extmarks)
: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
end
end)
:totable()
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.renderer.TreeBuilder
--- @field private nodes u.renderer.Node[]
local TreeBuilder = {}
TreeBuilder.__index = TreeBuilder
M.TreeBuilder = TreeBuilder
function TreeBuilder.new()
local self = setmetatable({ nodes = {} }, TreeBuilder)
return self
end
--- @param nodes u.renderer.Tree
--- @return u.renderer.TreeBuilder
function TreeBuilder:put(nodes)
table.insert(self.nodes, nodes)
return self
end
--- @param name string
--- @param attributes? table<string, any>
--- @param children? u.renderer.Node | u.renderer.Node[]
--- @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(tb: u.renderer.TreeBuilder): any
--- @return u.renderer.TreeBuilder
function TreeBuilder:nest(fn)
local nested_writer = TreeBuilder.new()
fn(nested_writer)
table.insert(self.nodes, nested_writer.nodes)
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 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 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
-- fudging with the costs of operations produces a more optimized change-set
-- that is tailored to working better with how NeoVim manipulates text. I've
-- done no further investigation in this area, however, so it's impossible to
-- tell if such tuning would produce real benefit. For now, I'm leaving this
-- in here even though it's not actively used. Hopefully having this
-- callback-based plumbing does not cause too much of a performance hit to
-- the renderer.
cost = cost or {}
local cost_of_delete_f = cost.of_delete or function() return 1 end
local cost_of_add_f = cost.of_add or function() return 1 end
local cost_of_change_f = cost.of_change or function() return 1 end
local m, n = #x, #y
-- Initialize the distance matrix
local dp = {}
for i = 0, m do
dp[i] = {}
for j = 0, n do
dp[i][j] = 0
end
end
-- Fill the base cases
for i = 0, m do
dp[i][0] = i
end
for j = 0, n do
dp[0][j] = j
end
-- Compute the Levenshtein distance dynamically
for i = 1, m do
for j = 1, n do
if x[i] == y[j] then
dp[i][j] = dp[i - 1][j - 1] -- no cost if items are the same
else
local cost_delete = dp[i - 1][j] + cost_of_delete_f(x[i])
local cost_add = dp[i][j - 1] + cost_of_add_f(y[j])
local cost_change = dp[i - 1][j - 1] + cost_of_change_f(x[i], y[j])
dp[i][j] = math.min(cost_delete, cost_add, cost_change)
end
end
end
-- Backtrack to find the changes
local i = m
local j = n
--- @type u.renderer.LevenshteinChange[]
local changes = {}
while i > 0 or j > 0 do
local default_cost = dp[i][j]
local cost_of_change = (i > 0 and j > 0) and dp[i - 1][j - 1] or default_cost
local cost_of_add = j > 0 and dp[i][j - 1] or default_cost
local cost_of_delete = i > 0 and dp[i - 1][j] or default_cost
--- @param u number
--- @param v number
--- @param w number
local function is_first_min(u, v, w) return u <= v and u <= w end
if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
-- potential change
if x[i] ~= y[j] then
--- @type u.renderer.LevenshteinChange
local change = { kind = 'change', from = x[i], index = i, to = y[j] }
table.insert(changes, change)
end
i = i - 1
j = j - 1
elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
-- addition
--- @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 u.renderer.LevenshteinChange
local change = { kind = 'delete', item = x[i], index = i }
table.insert(changes, change)
i = i - 1
else
error 'unreachable'
end
end
return changes
end
-- }}}
return M

View File

@@ -1,7 +1,7 @@
local M = {}
local IS_REPEATING = false
--- @type function
--- @type function|nil
local REPEAT_ACTION = nil
local function is_repeatable_last_mutator() return vim.b.changedtick <= (vim.b.my_changedtick or 0) end
@@ -9,6 +9,7 @@ local function is_repeatable_last_mutator() return vim.b.changedtick <= (vim.b.m
--- @param f fun()
function M.run_repeatable(f)
REPEAT_ACTION = f
---@diagnostic disable-next-line: need-check-nil
REPEAT_ACTION()
vim.b.my_changedtick = vim.b.changedtick
end

View File

@@ -1,311 +0,0 @@
local M = {}
M.debug = false
--------------------------------------------------------------------------------
-- class Signal
--------------------------------------------------------------------------------
--- @class u.Signal<T>
--- @field name? string
--- @field private changing boolean
--- @field private value T
--- @field private subscribers table<function, boolean>
--- @field private on_dispose_callbacks function[]
local Signal = {}
M.Signal = Signal
Signal.__index = Signal
--- @param value `T`
--- @param name? string
--- @return u.Signal<T>
function Signal:new(value, name)
local obj = setmetatable({
name = name,
changing = false,
value = value,
subscribers = {},
on_dispose_callbacks = {},
}, self)
return obj
end
--- @param value T
function Signal:set(value)
self.value = value
-- We don't handle cyclic updates:
if self.changing then
if M.debug then
vim.notify(
'circular dependency detected' .. (self.name and (' in ' .. self.name) or ''),
vim.log.levels.WARN
)
end
return
end
local prev_changing = self.changing
self.changing = true
local ok = true
local err = nil
for _, cb in ipairs(self.subscribers) do
local ok2, err2 = pcall(cb, value)
if not ok2 then
ok = false
err = err or err2
end
end
self.changing = prev_changing
if not ok then
vim.notify(
'error notifying' .. (self.name and (' in ' .. self.name) or '') .. ': ' .. tostring(err),
vim.log.levels.WARN
)
error(err)
end
end
--- @param value T
function Signal:schedule_set(value)
vim.schedule(function() self:set(value) end)
end
--- @return T
function Signal:get()
local ctx = M.ExecutionContext.current()
if ctx then ctx:track(self) end
return self.value
end
--- @param fn function
function Signal:update(fn) self:set(fn(self.value)) end
--- @param fn function
function Signal:schedule_update(fn) self:schedule_set(fn(self.value)) end
--- @generic U
--- @param fn fun(value: T): `U`
--- @return u.Signal<U>
function Signal:map(fn)
local mapped_signal = M.create_memo(function()
local value = self:get()
return fn(value)
end, self.name and self.name .. ':mapped' or nil)
return mapped_signal
end
--- @return u.Signal<T>
function Signal:clone()
return self:map(function(x) return x end)
end
--- @param fn fun(value: T): boolean
--- @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)
if fn(value) then filtered_signal:set(value) end
end)
filtered_signal:on_dispose(unsubscribe_from_self)
return filtered_signal
end
--- @param ms number
--- @return u.Signal<T>
function Signal:debounce(ms)
local function set_timeout(timeout, callback)
local timer = assert((vim.uv or vim.loop).new_timer(), 'could not create new timer')
timer:start(timeout, 0, function()
timer:stop()
timer:close()
callback()
end)
return timer
end
local filtered = M.create_signal(self.value, self.name and self.name .. ':debounced' or nil)
--- @diagnostic disable-next-line: undefined-doc-name
--- @type { queued: { value: T, ts: number }[], timer?: uv.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
end
local unsubscribe_from_self = self:subscribe(function(value)
-- Stop any previously running timer:
if state.timer then clear_timeout() end
local now_ms = (vim.uv or vim.loop).hrtime() / 1e6
-- If there is anything older than `ms` in our queue, emit it:
local older_than_ms = vim
.iter(state.queued)
:filter(function(item) return now_ms - item.ts > ms end)
:totable()
local last_older_than_ms = older_than_ms[#older_than_ms]
if last_older_than_ms then
filtered:set(last_older_than_ms.value)
state.queued = {}
end
-- overwrite anything young enough
table.insert(state.queued, { value = value, ts = now_ms })
state.timer = set_timeout(ms, function()
vim.schedule(function() filtered:set(value) end)
-- If a timer was allowed to run to completion, that means that no other
-- item has been queued, since the timer is reset every time a new item
-- comes in. This means we can reset the queue
clear_timeout()
state.queued = {}
end)
end)
filtered:on_dispose(unsubscribe_from_self)
return filtered
end
--- @param callback function
function Signal:subscribe(callback)
table.insert(self.subscribers, callback)
return function() self:unsubscribe(callback) end
end
--- @param callback function
function Signal:on_dispose(callback) table.insert(self.on_dispose_callbacks, callback) end
--- @param callback function
function Signal:unsubscribe(callback)
for i, cb in ipairs(self.subscribers) do
if cb == callback then
table.remove(self.subscribers, i)
break
end
end
end
function Signal:dispose()
self.subscribers = {}
for _, callback in ipairs(self.on_dispose_callbacks) do
callback()
end
end
--------------------------------------------------------------------------------
-- class ExecutionContext
--------------------------------------------------------------------------------
--- @type u.ExecutionContext|nil
local CURRENT_CONTEXT = nil
--- @class u.ExecutionContext
--- @field signals table<u.Signal, boolean>
local ExecutionContext = {}
M.ExecutionContext = ExecutionContext
ExecutionContext.__index = ExecutionContext
--- @return u.ExecutionContext
function ExecutionContext.new()
return setmetatable({
signals = {},
subscribers = {},
}, ExecutionContext)
end
function ExecutionContext.current() return CURRENT_CONTEXT end
--- @param fn function
--- @param ctx u.ExecutionContext
function ExecutionContext.run(fn, ctx)
local oldCtx = CURRENT_CONTEXT
CURRENT_CONTEXT = ctx
local result
local success, err = pcall(function() result = fn() end)
CURRENT_CONTEXT = oldCtx
if not success then error(err) end
return result
end
function ExecutionContext:track(signal) self.signals[signal] = true end
--- @param callback function
function ExecutionContext:subscribe(callback)
local wrapped_callback = function() callback() end
for signal in pairs(self.signals) do
signal:subscribe(wrapped_callback)
end
return function()
for signal in pairs(self.signals) do
signal:unsubscribe(wrapped_callback)
end
end
end
function ExecutionContext:dispose()
for signal, _ in pairs(self.signals) do
signal:dispose()
end
self.signals = {}
end
--------------------------------------------------------------------------------
-- Helpers
--------------------------------------------------------------------------------
--- @generic T
--- @param value `T`
--- @param name? string
--- @return u.Signal<T>
function M.create_signal(value, name) return Signal:new(value, name) end
--- @generic T
--- @param fn fun(): `T`
--- @param name? string
--- @return u.Signal
function M.create_memo(fn, name)
--- @type u.Signal<T> | nil
local result
local unsubscribe = M.create_effect(function()
local value = fn()
if name and M.debug then vim.notify(name) end
if result then
result:set(value)
else
result = M.create_signal(value, name and ('m.s:' .. name) or nil)
end
end, name)
assert(result):on_dispose(unsubscribe)
return assert(result)
end
--- @param fn function
--- @param name? string
function M.create_effect(fn, name)
local ctx = M.ExecutionContext.new()
M.ExecutionContext.run(fn, ctx)
return ctx:subscribe(function()
if name and M.debug then
local deps = vim
.iter(vim.tbl_keys(ctx.signals))
:map(function(s) return s.name end)
:filter(function(nm) return nm ~= nil end)
:join ','
vim.notify(name .. '(deps=' .. deps .. ')')
end
fn()
end)
end
return M

View File

@@ -36,39 +36,6 @@ local M = {}
--- @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` 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
table.insert(t, x)
vim.print(t)
return x
end
--- Creates a user command with enhanced argument processing.
--- Automatically computes range information and attaches it as `args.info`.
---
@@ -156,36 +123,4 @@ function M.create_delegated_cmd_args(current_args)
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