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:
Jonathan Apodaca 2025-06-11 20:04:46 -06:00
parent 6f86bfaa42
commit 06e6b88391
17 changed files with 1188 additions and 174 deletions

View File

@ -14,16 +14,16 @@ jobs:
- name: Populate Nix store - name: Populate Nix store
run: run:
nix-shell --run 'true' nix-shell --pure --run 'true'
- name: Type-check with lua-language-server - name: Type-check with lua-language-server
run: run:
nix-shell --run 'make lint' nix-shell --pure --run 'make lint'
- name: Check formatting with stylua - name: Check formatting with stylua
run: run:
nix-shell --run 'make fmt-check' nix-shell --pure --run 'make fmt-check'
- name: Run busted tests - name: Run busted tests
run: run:
nix-shell --run 'make test' nix-shell --pure --run 'make test'

10
.woodpecker/ci.yaml Normal file
View File

@ -0,0 +1,10 @@
when:
- event: push
steps:
- name: build
image: nixos/nix
commands:
- nix-shell --pure --run 'make lint'
- nix-shell --pure --run 'make fmt-check'
- nix-shell --pure --run 'make test'

View File

@ -19,3 +19,6 @@ test:
@echo "## Generating coverage report" @echo "## Generating coverage report"
@luacov @luacov
@awk '/^Summary$$/{flag=1;next} flag{print}' luacov.report.out @awk '/^Summary$$/{flag=1;next} flag{print}' luacov.report.out
watch:
@watchexec -c -e lua make

View File

@ -10,10 +10,10 @@
-- change on the underlying filesystem. -- change on the underlying filesystem.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @alias FsDir { kind: 'dir'; path: string; expanded: boolean; children: FsNode[] } --- @alias u.examples.FsDir { kind: 'dir'; path: string; expanded: boolean; children: u.examples.FsNode[] }
--- @alias FsFile { kind: 'file'; path: string } --- @alias u.examples.FsFile { kind: 'file'; path: string }
--- @alias FsNode FsDir | FsFile --- @alias u.examples.FsNode u.examples.FsDir | u.examples.FsFile
--- @alias ShowOpts { root_path?: string, width?: number, focus_path?: string } --- @alias u.examples.ShowOpts { root_path?: string, width?: number, focus_path?: string }
local Buffer = require 'u.buffer' local Buffer = require 'u.buffer'
local Renderer = require('u.renderer').Renderer local Renderer = require('u.renderer').Renderer
@ -58,13 +58,13 @@ function H.relative(path, base)
end end
--- @param root_path string --- @param root_path string
--- @return { tree: FsDir; path_to_node: table<string, FsNode> } --- @return { tree: u.examples.FsDir; path_to_node: table<string, u.examples.FsNode> }
function H.get_tree_inf(root_path) function H.get_tree_inf(root_path)
logger:info { 'get_tree_inf', root_path } logger:info { 'get_tree_inf', root_path }
--- @type table<string, FsNode> --- @type table<string, u.examples.FsNode>
local path_to_node = {} local path_to_node = {}
--- @type FsDir --- @type u.examples.FsDir
local tree = { local tree = {
kind = 'dir', kind = 'dir',
path = H.normalize(root_path or '.'), path = H.normalize(root_path or '.'),
@ -77,8 +77,8 @@ function H.get_tree_inf(root_path)
return { tree = tree, path_to_node = path_to_node } return { tree = tree, path_to_node = path_to_node }
end end
--- @param tree FsDir --- @param tree u.examples.FsDir
--- @param path_to_node table<string, FsNode> --- @param path_to_node table<string, u.examples.FsNode>
function H.populate_dir_children(tree, path_to_node) function H.populate_dir_children(tree, path_to_node)
tree.children = {} tree.children = {}
@ -135,7 +135,7 @@ local function _render_in_buffer(opts)
local parts = H.split_path(H.relative(focused_path, tree_inf.tree.path)) local parts = H.split_path(H.relative(focused_path, tree_inf.tree.path))
local path_to_node = tree_inf.path_to_node local path_to_node = tree_inf.path_to_node
--- @param node FsDir --- @param node u.examples.FsDir
--- @param child_names string[] --- @param child_names string[]
local function expand_to(node, child_names) local function expand_to(node, child_names)
if #child_names == 0 then return end if #child_names == 0 then return end
@ -310,7 +310,7 @@ local function _render_in_buffer(opts)
-- --
local renderer = Renderer.new(opts.bufnr) local renderer = Renderer.new(opts.bufnr)
tracker.create_effect(function() tracker.create_effect(function()
--- @type { tree: FsDir; path_to_node: table<string, FsNode> } --- @type { tree: u.examples.FsDir; path_to_node: table<string, u.examples.FsNode> }
local tree_inf = s_tree_inf:get() local tree_inf = s_tree_inf:get()
local tree = tree_inf.tree local tree = tree_inf.tree
@ -329,7 +329,7 @@ local function _render_in_buffer(opts)
--- Since the filesystem is a recursive tree of nodes, we need to --- Since the filesystem is a recursive tree of nodes, we need to
--- recursively render each node. This function does just that: --- recursively render each node. This function does just that:
--- @param node FsNode --- @param node u.examples.FsNode
--- @param level number --- @param level number
local function render_node(node, level) local function render_node(node, level)
local name = vim.fs.basename(node.path) local name = vim.fs.basename(node.path)
@ -414,7 +414,7 @@ end
local current_inf = nil local current_inf = nil
--- Show the filetree: --- Show the filetree:
--- @param opts? ShowOpts --- @param opts? u.examples.ShowOpts
function M.show(opts) function M.show(opts)
if current_inf ~= nil then return current_inf.controller end if current_inf ~= nil then return current_inf.controller end
opts = opts or {} opts = opts or {}
@ -456,7 +456,7 @@ function M.hide()
end end
--- Toggle the filetree: --- Toggle the filetree:
--- @param opts? ShowOpts --- @param opts? u.examples.ShowOpts
function M.toggle(opts) function M.toggle(opts)
if current_inf == nil then if current_inf == nil then
M.show(opts) M.show(opts)

117
examples/form.lua Normal file
View File

@ -0,0 +1,117 @@
-- form.lua:
--
-- This is a runnable example of a form. Open this file in Neovim, and execute
-- `:luafile %` to run it. It will create a new buffer to the side, and render
-- an interactive form. Edit the "inputs" between the `[...]` brackets, and
-- watch the buffer react immediately to your changes.
--
local Renderer = require('u.renderer').Renderer
local h = require('u.renderer').h
local tracker = require 'u.tracker'
-- Create a new, temporary, buffer to the side:
vim.cmd.vnew()
vim.bo.buftype = 'nofile'
vim.bo.bufhidden = 'wipe'
vim.bo.buflisted = false
local renderer = Renderer.new()
-- Create two signals:
local s_name = tracker.create_signal 'whoever-you-are'
local s_age = tracker.create_signal 'ideally-a-number'
-- We can create derived information from the signals above. Say we want to do
-- some validation on the input for `age`: we can do that with a memo:
local s_age_info = tracker.create_memo(function()
local age_raw = s_age:get()
local age_digits = age_raw:match '^%s*(%d+)%s*$'
local age_n = age_digits and tonumber(age_digits) or nil
return {
type = age_n and 'number' or 'string',
raw = age_raw,
n = age_n,
n1 = age_n and age_n + 1 or nil,
}
end)
-- This is the render effect that depends on the signals created above. This
-- will re-run every time one of the signals changes.
tracker.create_effect(function()
local name = s_name:get()
local age = s_age:get()
local age_info = s_age_info:get()
-- Each time the signals change, we re-render the buffer:
renderer:render {
h.Type({}, '# Form Example'),
'\n\n',
-- We can also listen for when specific locations in the buffer change, on
-- a tag-by-tag basis. This gives us two-way data-binding between the
-- buffer and the signals.
{
'Name: ',
h.Structure({
on_change = function(text) s_name:set(text) end,
}, name),
},
{
'\nAge: ',
h.Structure({
on_change = function(text) s_age:set(text) end,
}, age),
},
'\n\n',
-- Show the values of the signals here, too, so that we can see the
-- reactivity in action. If you change the values in the tags above, you
-- can see the changes reflected here immediately.
{ 'Hello, "', name, '"!' },
--
-- A more complex example: we can do much more complex rendering, based on
-- the state. For example, if you type different values into the `age`
-- field, you can see not only the displayed information change, but also
-- the color of the highlights in this section will adapt to the type of
-- information that has been detected.
--
-- If string input is detected, values below are shown in the
-- `String`/`ErrorMsg` highlight groups.
--
-- If number input is detected, values below are shown in the `Number`
-- highlight group.
--
-- If a valid number is entered, then this section also displays how old
-- you willl be next year (`n + 1`).
--
'\n\n',
h.Type({}, '## Computed Information (derived from `age`)'),
'\n\n',
{
'Type: ',
h('text', {
hl = age_info.type == 'number' and 'Number' or 'String',
}, age_info.type),
},
{ '\nRaw input: ', h.String({}, '"' .. age_info.raw .. '"') },
{
'\nCurrent age: ',
age_info.n
-- Show the age:
and h.Number({}, tostring(age_info.n))
-- Show an error-placeholder if the age is invalid:
or h.ErrorMsg({}, '(?)'),
},
-- This part is shown conditionally, i.e., only if the age next year can be
-- computed:
age_info.n1
and {
'\nAge next year: ',
h.Number({}, tostring(age_info.n1)),
},
}
end)

View File

@ -14,7 +14,7 @@ local ICONS = {
} }
local DEFAULT_ICON = { text = '', group = 'DiagnosticSignOk' } local DEFAULT_ICON = { text = '', group = 'DiagnosticSignOk' }
--- @alias Notification { --- @alias u.examples.Notification {
--- kind: number; --- kind: number;
--- id: number; --- id: number;
--- text: string; --- text: string;
@ -30,7 +30,7 @@ local s_notifications = s_notifications_raw:debounce(50)
-- Render effect: -- Render effect:
tracker.create_effect(function() tracker.create_effect(function()
--- @type Notification[] --- @type u.examples.Notification[]
local notifs = s_notifications:get() local notifs = s_notifications:get()
if #notifs == 0 then if #notifs == 0 then
@ -99,14 +99,14 @@ local function my_notify(msg, level, opts)
local id = math.random(math.huge) local id = math.random(math.huge)
--- @param notifs Notification[] --- @param notifs u.examples.Notification[]
s_notifications_raw:schedule_update(function(notifs) s_notifications_raw:schedule_update(function(notifs)
table.insert(notifs, { kind = level, id = id, text = msg }) table.insert(notifs, { kind = level, id = id, text = msg })
return notifs return notifs
end) end)
vim.defer_fn(function() vim.defer_fn(function()
--- @param notifs Notification[] --- @param notifs u.examples.Notification[]
s_notifications_raw:schedule_update(function(notifs) s_notifications_raw:schedule_update(function(notifs)
for i, notif in ipairs(notifs) do for i, notif in ipairs(notifs) do
if notif.id == id then if notif.id == id then

View File

@ -44,7 +44,7 @@ local function shallow_copy_arr(arr) return vim.iter(arr):totable() end
-- shortest portion of this function. -- shortest portion of this function.
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
--- @alias SelectController { --- @alias u.examples.SelectController {
--- get_items: fun(): T[]; --- get_items: fun(): T[];
--- set_items: fun(items: T[]); --- set_items: fun(items: T[]);
--- set_filter_text: fun(filter_text: string); --- set_filter_text: fun(filter_text: string);
@ -53,17 +53,17 @@ local function shallow_copy_arr(arr) return vim.iter(arr):totable() end
--- set_selected_indices: fun(indicies: number[], ephemeral?: boolean); --- set_selected_indices: fun(indicies: number[], ephemeral?: boolean);
--- close: fun(); --- close: fun();
--- } --- }
--- @alias SelectOpts<T> { --- @alias u.examples.SelectOpts<T> {
--- items: `T`[]; --- items: `T`[];
--- multi?: boolean; --- multi?: boolean;
--- format_item?: fun(item: T): Tree; --- format_item?: fun(item: T): u.renderer.Tree;
--- on_finish?: fun(items: T[], indicies: number[]); --- on_finish?: fun(items: T[], indicies: number[]);
--- on_selection_changed?: fun(items: T[], indicies: number[]); --- on_selection_changed?: fun(items: T[], indicies: number[]);
--- mappings?: table<string, fun(select: SelectController)>; --- mappings?: table<string, fun(select: u.examples.SelectController)>;
--- } --- }
--- @generic T --- @generic T
--- @param opts SelectOpts<T> --- @param opts u.examples.SelectOpts<T>
function M.create_picker(opts) -- {{{ function M.create_picker(opts) -- {{{
local is_in_insert_mode = vim.api.nvim_get_mode().mode:sub(1, 1) == 'i' local is_in_insert_mode = vim.api.nvim_get_mode().mode:sub(1, 1) == 'i'
local stopinsert = not is_in_insert_mode local stopinsert = not is_in_insert_mode
@ -557,7 +557,7 @@ function M.create_picker(opts) -- {{{
return safe_run(function() H.finish(true) end) return safe_run(function() H.finish(true) end)
end end
return controller --[[@as SelectController]] return controller --[[@as u.examples.SelectController]]
end -- }}} end -- }}}
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------

View File

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

View File

@ -32,7 +32,7 @@ end
--- @param line string --- @param line string
--- @param bufnr? number --- @param bufnr? number
function CodeWriter.from_line(line, bufnr) 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 ws = line:match '^%s*'
local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr }) local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr })

View File

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

View File

@ -67,6 +67,15 @@ function Pos.__sub(x, y)
return x:next(-y) return x:next(-y)
end 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 --- @param name string
--- @return u.Pos --- @return u.Pos
function Pos.from_pos(name) 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: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 --- @param pos string
function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end 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 Pos = require 'u.pos'
local ESC = vim.api.nvim_replace_termcodes('<Esc>', true, false, true) 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 --- @class u.Range
--- @field start u.Pos --- @field start u.Pos
@ -83,6 +90,34 @@ function Range.from_marks(lpos, rpos)
return Range.new(start, stop, mode) return Range.new(start, stop, mode)
end 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 --- @param bufnr? number
function Range.from_buf_text(bufnr) function Range.from_buf_text(bufnr)
if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end 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) function Range.from_motion(motion, opts)
-- Options handling: -- Options handling:
opts = opts or {} 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.contains_cursor == nil then opts.contains_cursor = false end
if opts.user_defined == nil then opts.user_defined = 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 = function(ty)
_G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty) _G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty)
end end
local old_eventignore = vim.o.eventignore
vim.o.eventignore = 'all'
vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc' vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc'
vim.cmd { vim.cmd {
cmd = 'normal', cmd = 'normal',
@ -153,6 +190,7 @@ function Range.from_motion(motion, opts)
args = { ESC .. 'g@' .. motion }, args = { ESC .. 'g@' .. motion },
mods = { silent = true }, mods = { silent = true },
} }
vim.o.eventignore = old_eventignore
end) end)
local captured_range = _G.Range__from_motion_opfunc_captured_range local captured_range = _G.Range__from_motion_opfunc_captured_range
@ -193,6 +231,26 @@ function Range.from_motion(motion, opts)
return captured_range return captured_range
end 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. --- Get range information from the currently selected visual text.
--- Note: from within a command mapping or an opfunc, use other specialized --- Note: from within a command mapping or an opfunc, use other specialized
--- utilities, such as: --- utilities, such as:
@ -220,19 +278,22 @@ end
--- @param args unknown --- @param args unknown
--- @return u.Range|nil --- @return u.Range|nil
function Range.from_cmd_args(args) function Range.from_cmd_args(args)
--- @type 'v'|'V' if args.range == 0 then return nil end
local mode
--- @type nil|u.Pos local bufnr = vim.api.nvim_get_current_buf()
local start if args.range == 1 then
local stop return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line1, Pos.MAX_COL), 'V')
if args.range == 0 then end
return nil
else local is_visual = vim.fn.histget('cmd', -1):sub(1, 5) == [['<,'>]]
start = Pos.from_pos "'<" --- @type 'v'|'V'
stop = Pos.from_pos "'>" local mode = is_visual and vim.fn.visualmode() or 'V'
mode = stop:is_col_max() and 'V' or 'v'
if is_visual then
return Range.new(Pos.from_pos "'<", Pos.from_pos "'>", mode)
else
return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line2, Pos.MAX_COL), mode)
end end
return Range.new(start, stop, mode)
end end
function Range.find_nearest_brackets() function Range.find_nearest_brackets()
@ -272,6 +333,13 @@ function Range:to_linewise()
return r return r
end 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 --- @param x u.Pos | u.Range
function Range:contains(x) function Range:contains(x)
if getmetatable(x) == Pos then if getmetatable(x) == Pos then
@ -313,25 +381,32 @@ end
--- @param left string --- @param left string
--- @param right string --- @param right string
function Range:save_to_pos(left, right) function Range:save_to_pos(left, right)
if self:is_empty() then self.start:save_to_pos(left);
self.start:save_to_pos(left) (self:is_empty() and self.start or self.stop):save_to_pos(right)
self.start:save_to_pos(right)
else
self.start:save_to_pos(left)
self.stop:save_to_pos(right)
end
end end
--- @param left string --- @param left string
--- @param right string --- @param right string
function Range:save_to_marks(left, right) function Range:save_to_marks(left, right)
if self:is_empty() then self.start:save_to_mark(left);
self.start:save_to_mark(left) (self:is_empty() and self.start or self.stop):save_to_mark(right)
self.start:save_to_mark(right) end
else
self.start:save_to_mark(left) function Range:save_to_extmark()
self.stop:save_to_mark(right) 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 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 end
function Range:set_visual_selection() function Range:set_visual_selection()
@ -348,14 +423,46 @@ function Range:set_visual_selection()
self.stop:save_to_pos '.' self.stop:save_to_pos '.'
end end
--------------------------------------------------------------------------------
-- Range.from_* functions:
--------------------------------------------------------------------------------
-------------------------------------------------------------------------------- --------------------------------------------------------------------------------
-- Text access/manipulation utilities: -- 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() function Range:length()
if self:is_empty() then return 0 end if self:is_empty() then return 0 end
@ -596,4 +703,17 @@ function Range:highlight(group, opts)
} }
end 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 return Range

View File

@ -1,12 +1,23 @@
function _G.URendererOpFuncSwallow() end
local M = {} local M = {}
local H = {} 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.Node nil | boolean | string | u.renderer.Tag
--- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[] --- @alias u.renderer.Tree u.renderer.Node | u.renderer.Node[]
-- luacheck: ignore -- 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({}, { M.h = setmetatable({}, {
__call = function(_, name, attributes, children) __call = function(_, name, attributes, children)
return { return {
@ -17,7 +28,6 @@ M.h = setmetatable({}, {
} }
end, end,
__index = function(_, name) __index = function(_, name)
-- vim.print('dynamic hl ' .. name)
return function(attributes, children) return function(attributes, children)
return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children) return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children)
end end
@ -25,14 +35,19 @@ M.h = setmetatable({}, {
}) })
-- Renderer {{{ -- 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 bufnr number
--- @field ns number --- @field ns number
--- @field changedtick number --- @field changedtick number
--- @field old { lines: string[]; extmarks: RendererExtmark[] } --- @field old { lines: string[]; extmarks: u.renderer.RendererExtmark[] }
--- @field curr { lines: string[]; extmarks: RendererExtmark[] } --- @field curr { lines: string[]; extmarks: u.renderer.RendererExtmark[] }
local Renderer = {} local Renderer = {}
Renderer.__index = Renderer Renderer.__index = Renderer
M.Renderer = Renderer M.Renderer = Renderer
@ -51,7 +66,7 @@ function Renderer.is_tag_arr(x)
end end
--- @param bufnr number|nil --- @param bufnr number|nil
function Renderer.new(bufnr) -- {{{ 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 if vim.b[bufnr]._renderer_ns == nil then
vim.b[bufnr]._renderer_ns = vim.api.nvim_create_namespace('my.renderer:' .. tostring(bufnr)) 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 = {} }, old = { lines = {}, extmarks = {} },
curr = { lines = {}, extmarks = {} }, curr = { lines = {}, extmarks = {} },
}, Renderer) }, Renderer)
vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, {
buffer = bufnr,
callback = function() self:_on_text_changed() end,
})
return self return self
end -- }}} end -- }}}
@ -128,7 +149,7 @@ function Renderer.markup_to_lines(opts) -- {{{
end -- }}} end -- }}}
--- @param opts { --- @param opts {
--- tree: string; --- tree: u.renderer.Tree;
--- format_tag?: fun(tag: u.renderer.Tag): string; --- format_tag?: fun(tag: u.renderer.Tag): string;
--- } --- }
function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end 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 bufnr number
--- @param old_lines string[] | nil --- @param old_lines string[] | nil
--- @param new_lines string[] --- @param new_lines string[]
function Renderer.patch_lines(bufnr, old_lines, new_lines) function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{
-- --
-- Helpers: -- Helpers:
-- --
@ -189,7 +210,7 @@ function Renderer.patch_lines(bufnr, old_lines, new_lines)
-- No change -- No change
end end
end end
end end -- }}}
--- @param tree u.renderer.Tree --- @param tree u.renderer.Tree
function Renderer:render(tree) -- {{{ function Renderer:render(tree) -- {{{
@ -199,7 +220,7 @@ function Renderer:render(tree) -- {{{
self.changedtick = changedtick self.changedtick = changedtick
end end
--- @type RendererExtmark[] --- @type u.renderer.RendererExtmark[]
local extmarks = {} local extmarks = {}
--- @type string[] --- @type string[]
@ -214,32 +235,37 @@ function Renderer:render(tree) -- {{{
tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or hl tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or hl
end end
local extmark = tag.attributes.extmark local extmark_opts = tag.attributes.extmark or {}
-- Set any necessary keymaps: -- Set any necessary keymaps:
for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do
for lhs, _ in pairs(tag.attributes[mode .. 'map'] or {}) do for lhs, _ in pairs(tag.attributes[mode .. 'map'] or {}) do
-- Force creating an extmark if there are key handlers. To accurately -- Force creating an extmark if there are key handlers. To accurately
-- sense the bounds of the text, we need an extmark: -- sense the bounds of the text, we need an extmark:
extmark = extmark or {} vim.keymap.set(mode, lhs, function()
vim.keymap.set( local result = self:_expr_map_callback(mode, lhs)
'n', -- If the handler indicates that it wants to swallow the event,
lhs, -- we have to convert that intention into something compatible
function() return self:_expr_map_callback('n', lhs) end, -- with expr-mappings, which don't support '<Nop>' (they try to
{ buffer = self.bufnr, expr = true, replace_keycodes = true } -- 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
end end
if extmark then
table.insert(extmarks, { table.insert(extmarks, {
start = start0, start = start0,
stop = stop0, stop = stop0,
opts = extmark, opts = extmark_opts,
tag = tag, tag = tag,
}) })
end end
end
end, -- }}} end, -- }}}
} }
@ -248,25 +274,6 @@ function Renderer:render(tree) -- {{{
self:_reconcile() self:_reconcile()
end -- }}} 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 --- @private
function Renderer:_reconcile() -- {{{ function Renderer:_reconcile() -- {{{
-- --
@ -277,10 +284,15 @@ function Renderer:_reconcile() -- {{{
-- --
-- Step 2: reconcile extmarks: -- 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: -- Clear current extmarks:
vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1) vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1)
-- Set current extmarks: -- Set current extmarks:
for _, extmark in ipairs(self.curr.extmarks) do for _, extmark in ipairs(self.curr.extmarks) do
extmark.id = vim.api.nvim_buf_set_extmark( extmark.id = vim.api.nvim_buf_set_extmark(
@ -292,6 +304,13 @@ function Renderer:_reconcile() -- {{{
id = extmark.id, id = extmark.id,
end_row = extmark.stop[1], end_row = extmark.stop[1],
end_col = extmark.stop[2], 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) }, extmark.opts)
) )
end end
@ -306,7 +325,7 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
-- find the tag with the smallest intersection that contains the cursor: -- find the tag with the smallest intersection that contains the cursor:
local pos0 = vim.api.nvim_win_get_cursor(0) local pos0 = vim.api.nvim_win_get_cursor(0)
pos0[1] = pos0[1] - 1 -- make it actually 0-based 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 if #pos_infos == 0 then return lhs end
@ -316,9 +335,10 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
local tag = pos_info.tag local tag = pos_info.tag
-- is the tag listening? -- is the tag listening?
--- @type u.renderer.TagEventHandler?
local f = vim.tbl_get(tag.attributes, mode .. 'map', lhs) local f = vim.tbl_get(tag.attributes, mode .. 'map', lhs)
if type(f) == 'function' then if type(f) == 'function' then
local result = f() local result = f(tag, mode, lhs)
if result == '' then if result == '' then
-- bubble-up to the next tag, but set cancel to true, in case there are -- bubble-up to the next tag, but set cancel to true, in case there are
-- no more tags to bubble up to: -- no more tags to bubble up to:
@ -333,21 +353,145 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{
return cancel and '' or lhs return cancel and '' or lhs
end -- }}} 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 --- Returns pairs of extmarks and tags associate with said extmarks. The
--- returned tags/extmarks are sorted smallest (innermost) to largest --- returned tags/extmarks are sorted smallest (innermost) to largest
--- (outermost). --- (outermost).
--- ---
--- @private (private for now) --- @private (private for now)
--- @param pos0 [number; number] --- @param pos0 [number; number]
--- @return { extmark: RendererExtmark; tag: u.renderer.Tag; }[] --- @param mode string?
function Renderer:get_pos_infos(pos0) -- {{{ --- @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] 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 -- 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, -- 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: -- because the NeoVim API is over-inclusive in what it returns:
--- @type RendererExtmark[] --- @type u.renderer.RendererExtmark[]
local intersecting_extmarks = vim local mapped_extmarks = vim
.iter( .iter(
vim.api.nvim_buf_get_extmarks( vim.api.nvim_buf_get_extmarks(
self.bufnr, self.bufnr,
@ -357,7 +501,7 @@ function Renderer:get_pos_infos(pos0) -- {{{
{ details = true, overlap = true } { details = true, overlap = true }
) )
) )
--- @return RendererExtmark --- @return u.renderer.RendererExtmark
:map(function(ext) :map(function(ext)
--- @type number, number, number, { end_row?: number; end_col?: number }|nil --- @type number, number, number, { end_row?: number; end_col?: number }|nil
local id, line0, col0, details = unpack(ext) local id, line0, col0, details = unpack(ext)
@ -368,13 +512,43 @@ function Renderer:get_pos_infos(pos0) -- {{{
end end
return { id = id, start = start, stop = stop, opts = details } return { id = id, start = start, stop = stop, opts = details }
end) end)
--- @param ext RendererExtmark :totable()
local intersecting_extmarks = vim
.iter(mapped_extmarks)
--- @param ext u.renderer.RendererExtmark
:filter(function(ext) :filter(function(ext)
if ext.stop[1] ~= nil and ext.stop[2] ~= nil then if ext.stop[1] ~= nil and ext.stop[2] ~= nil then
return cursor_line0 >= ext.start[1] -- If we've "ciw" and "collapsed" an extmark onto the cursor,
and cursor_col0 >= ext.start[2] -- 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] and cursor_line0 <= ext.stop[1]
and cursor_col0 < ext.stop[2] -- 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 else
return true return true
end end
@ -384,8 +558,8 @@ function Renderer:get_pos_infos(pos0) -- {{{
-- Sort the tags into smallest (inner) to largest (outer): -- Sort the tags into smallest (inner) to largest (outer):
table.sort( table.sort(
intersecting_extmarks, intersecting_extmarks,
--- @param x1 RendererExtmark --- @param x1 u.renderer.RendererExtmark
--- @param x2 RendererExtmark --- @param x2 u.renderer.RendererExtmark
function(x1, x2) function(x1, x2)
if if
x1.start[1] == x2.start[1] 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 -- 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 -- extmark is associated with. Cross-reference with that list to get a list
-- of tags that we need to fire events for: -- 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 local matching_tags = vim
.iter(intersecting_extmarks) .iter(intersecting_extmarks)
--- @param ext RendererExtmark --- @param ext u.renderer.RendererExtmark
:map(function(ext) :map(function(ext)
for _, extmark_cache in ipairs(self.curr.extmarks) do for _, extmark_cache in ipairs(self.curr.extmarks) do
if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end 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 return matching_tags
end -- }}} 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 {{{ -- TreeBuilder {{{
--- @class u.TreeBuilder --- @class u.renderer.TreeBuilder
--- @field private nodes u.renderer.Node[] --- @field private nodes u.renderer.Node[]
local TreeBuilder = {} local TreeBuilder = {}
TreeBuilder.__index = TreeBuilder TreeBuilder.__index = TreeBuilder
@ -435,7 +622,7 @@ function TreeBuilder.new()
end end
--- @param nodes u.renderer.Tree --- @param nodes u.renderer.Tree
--- @return u.TreeBuilder --- @return u.renderer.TreeBuilder
function TreeBuilder:put(nodes) function TreeBuilder:put(nodes)
table.insert(self.nodes, nodes) table.insert(self.nodes, nodes)
return self return self
@ -444,15 +631,15 @@ end
--- @param name string --- @param name string
--- @param attributes? table<string, any> --- @param attributes? table<string, any>
--- @param children? u.renderer.Node | u.renderer.Node[] --- @param children? u.renderer.Node | u.renderer.Node[]
--- @return u.TreeBuilder --- @return u.renderer.TreeBuilder
function TreeBuilder:put_h(name, attributes, children) function TreeBuilder:put_h(name, attributes, children)
local tag = M.h(name, attributes, children) local tag = M.h(name, attributes, children)
table.insert(self.nodes, tag) table.insert(self.nodes, tag)
return self return self
end end
--- @param fn fun(TreeBuilder): any --- @param fn fun(tb: u.renderer.TreeBuilder): any
--- @return u.TreeBuilder --- @return u.renderer.TreeBuilder
function TreeBuilder:nest(fn) function TreeBuilder:nest(fn)
local nested_writer = TreeBuilder.new() local nested_writer = TreeBuilder.new()
fn(nested_writer) fn(nested_writer)
@ -460,19 +647,40 @@ function TreeBuilder:nest(fn)
return self return self
end 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 --- @return u.renderer.Tree
function TreeBuilder:tree() return self.nodes end function TreeBuilder:tree() return self.nodes end
-- }}} -- }}}
-- Levenshtein utility {{{ -- Levenshtein utility {{{
-- luacheck: ignore -- 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 --- @private
--- @generic T --- @generic T
--- @param x `T`[] --- @param x `T`[]
--- @param y 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; } --- @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) function H.levenshtein(x, y, cost)
-- At the moment, this whole `cost` plumbing is not used. Deletes have the -- 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 -- 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 -- Backtrack to find the changes
local i = m local i = m
local j = n local j = n
--- @type LevenshteinChange[] --- @type u.renderer.LevenshteinChange[]
local changes = {} local changes = {}
while i > 0 or j > 0 do 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 if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then
-- potential change -- potential change
if x[i] ~= y[j] then if x[i] ~= y[j] then
--- @type LevenshteinChange --- @type u.renderer.LevenshteinChange
local change = { kind = 'change', from = x[i], index = i, to = y[j] } local change = { kind = 'change', from = x[i], index = i, to = y[j] }
table.insert(changes, change) table.insert(changes, change)
end end
@ -548,13 +756,13 @@ function H.levenshtein(x, y, cost)
j = j - 1 j = j - 1
elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then
-- addition -- addition
--- @type LevenshteinChange --- @type u.renderer.LevenshteinChange
local change = { kind = 'add', item = y[j], index = i + 1 } local change = { kind = 'add', item = y[j], index = i + 1 }
table.insert(changes, change) table.insert(changes, change)
j = j - 1 j = j - 1
elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then
-- deletion -- deletion
--- @type LevenshteinChange --- @type u.renderer.LevenshteinChange
local change = { kind = 'delete', item = x[i], index = i } local change = { kind = 'delete', item = x[i], index = i }
table.insert(changes, change) table.insert(changes, change)
i = i - 1 i = i - 1

View File

@ -4,15 +4,63 @@ local M = {}
-- Types -- Types
-- --
--- @alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string } --- @class u.utils.QfItem
--- @alias KeyMaps table<string, fun(): any | string> } --- @field col number
-- luacheck: ignore --- @field filename string
--- @alias CmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any; info: u.Range|nil } --- @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 --- @generic T
--- @param x `T` --- @param x `T` The value to debug print
--- @param message? string --- @param message? string Optional message to print alongside the value
--- @return T --- @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) function M.dbg(x, message)
local t = {} local t = {}
if message ~= nil then table.insert(t, message) end if message ~= nil then table.insert(t, message) end
@ -21,22 +69,37 @@ function M.dbg(x, message)
return x return x
end end
--- A utility for creating user commands that also pre-computes useful information --- Creates a user command with enhanced argument processing.
--- and attaches it to the arguments. --- 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 --- ```lua
--- -- Example: --- -- Create a command that works with visual selections:
--- ucmd('MyCmd', function(args) --- utils.ucmd('MyCmd', function(args)
--- -- print the visually selected text: --- -- Print the visually selected text:
--- vim.print(args.info: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()) --- vim.print(args.info:lines())
--- end, { nargs = '*', range = true }) --- 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 -- luacheck: ignore
--- @param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string }
function M.ucmd(name, cmd, opts) function M.ucmd(name, cmd, opts)
local Range = require 'u.range' local Range = require 'u.range'
@ -48,9 +111,81 @@ function M.ucmd(name, cmd, opts)
return cmd(args) return cmd(args)
end end
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 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 function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end
return M return M

View File

@ -1,10 +1,10 @@
{ {
pkgs ? pkgs ?
import import
# nixpkgs-unstable (neovim@0.11.2): # nixos-25.05 (neovim@0.11.2):
(fetchTarball { (fetchTarball {
url = "https://github.com/nixos/nixpkgs/archive/e4b09e47ace7d87de083786b404bf232eb6c89d8.tar.gz"; url = "https://github.com/nixos/nixpkgs/archive/32a4e87942101f1c9f9865e04dc3ddb175f5f32e.tar.gz";
sha256 = "1a2qvp2yz8j1jcggl1yvqmdxicbdqq58nv7hihmw3bzg9cjyqm26"; sha256 = "1jvflnbrxa8gjxkwjq6kdpdzgwp0hs59h9l3xjasksv0v7xlwykz";
}) })
{ }, { },
}: }:
@ -19,5 +19,6 @@ pkgs.mkShell {
pkgs.lua51Packages.nlua pkgs.lua51Packages.nlua
pkgs.neovim pkgs.neovim
pkgs.stylua pkgs.stylua
pkgs.watchexec
]; ];
} }

View File

@ -238,6 +238,128 @@ describe('Range', function()
end) end)
end) end)
it('from_tsquery_caps', function()
withbuf({
'-- a comment',
'',
'function foo(bar) end',
'',
'-- a middle comment',
'',
'function bar(baz) end',
'',
'-- another comment',
}, function()
vim.cmd.setfiletype 'lua'
--- @param contains_cursor? boolean
local function get_caps(contains_cursor)
if contains_cursor == nil then contains_cursor = true end
return (Range.from_tsquery_caps(
0,
'(function_declaration) @f',
{ contains_cursor = contains_cursor }
)).f or {}
end
local caps = get_caps(false)
assert.are.same(#caps, 2)
assert.are.same(vim.iter(caps):map(function(c) return c:text() end):totable(), {
'function foo(bar) end',
'function bar(baz) end',
})
Pos.new(0, 1, 1):save_to_pos '.'
caps = get_caps()
assert.are.same(#caps, 0)
Pos.new(0, 3, 18):save_to_pos '.'
caps = get_caps()
assert.are.same(#caps, 1)
assert.are.same(caps[1]:text(), 'function foo(bar) end')
Pos.new(0, 5, 1):save_to_pos '.'
caps = get_caps()
assert.are.same(#caps, 0)
Pos.new(0, 7, 1):save_to_pos '.'
caps = get_caps()
assert.are.same(#caps, 1)
assert.are.same(caps[1]:text(), 'function bar(baz) end')
end)
end)
it('from_tsquery_caps with string array filter', function()
withbuf({
'{',
' sample_key1 = "sample-value1",',
' sample_key2 = "sample-value2",',
'}',
}, function()
vim.cmd.setfiletype 'lua'
-- Place cursor in "sample-value1"
Pos.new(0, 2, 25):save_to_pos '.'
-- Query that captures both keys and values in pairs
local query = [[
(field
name: _ @key
value: _ @value)
]]
local ranges = Range.from_line(0, 2):tsquery(query)
-- Should have both @key and @value captures for the first pair only
-- (since cursor is in sample-value1)
assert(ranges, 'Range should not be nil')
assert(ranges.key, 'Range.key should not be nil')
assert(ranges.value, 'Range.value should not be nil')
-- Should have exactly one key and one value
assert.are.same(#ranges.key, 1)
assert.are.same(#ranges.value, 1)
-- Check that we got sample-key1 and sample-value1
assert.are.same(ranges.key[1]:text(), 'sample_key1')
assert.are.same(ranges.value[1]:text(), '"sample-value1"')
end)
-- Make sure this works when the match is on the last line:
withbuf({
'{sample_key1= "sample-value1",',
'sample_key2= "sample-value2"}',
}, function()
vim.cmd.setfiletype 'lua'
-- Place cursor in "sample-value1"
Pos.new(0, 2, 25):save_to_pos '.'
-- Query that captures both keys and values in pairs
local query = [[
(field
name: _ @key
value: _ @value)
]]
local ranges = Range.from_line(0, 2):tsquery(query)
-- Should have both @key and @value captures for the first pair only
-- (since cursor is in sample-value1)
assert(ranges, 'Range should not be nil')
assert(ranges.key, 'Range.key should not be nil')
assert(ranges.value, 'Range.value should not be nil')
-- Should have exactly one key and one value
assert.are.same(#ranges.key, 1)
assert.are.same(#ranges.value, 1)
-- Check that we got sample-key2 and sample-value2
assert.are.same(ranges.key[1]:text(), 'sample_key2')
assert.are.same(ranges.value[1]:text(), '"sample-value2"')
end)
end)
it('should get nearest block', function() it('should get nearest block', function()
withbuf({ withbuf({
'this is a {', 'this is a {',
@ -318,18 +440,89 @@ describe('Range', function()
end) end)
end) end)
it('from_cmd_args', function() it('from_cmd_args: range=0', function()
local args = { range = 1 } local args = { range = 0 }
withbuf(
{ 'line one', 'and line two' },
function() assert.are.same(Range.from_cmd_args(args), nil) end
)
end)
it('from_cmd_args: range=1', function()
local args = { range = 1, line1 = 1 }
withbuf({ 'line one', 'and line two' }, function() withbuf({ 'line one', 'and line two' }, function()
local a = Pos.new(nil, 1, 1) local a = Pos.new(nil, 1, 1)
local b = Pos.new(nil, 2, 2) local b = Pos.new(nil, 1, Pos.MAX_COL)
local range = Range.from_cmd_args(args) --[[@as u.Range]]
assert.are.same(range.start, a)
assert.are.same(range.stop, b)
assert.are.same(range.mode, 'V')
assert.are.same(range:text(), 'line one')
end)
end)
it('from_cmd_args: range=2: no-visual', function()
local args = { range = 2, line1 = 1, line2 = 2 }
withbuf({ 'line one', 'and line two' }, function()
local range = Range.from_cmd_args(args) --[[@as u.Range]]
assert.are.same(range.start, Pos.new(nil, 1, 1))
assert.are.same(range.stop, Pos.new(nil, 2, Pos.MAX_COL))
assert.are.same(range.mode, 'V')
assert.are.same(range:text(), 'line one\nand line two')
end)
end)
it('from_cmd_args: range=2: visual: linewise', function()
local args = { range = 2, line1 = 1, line2 = 2 }
withbuf({ 'line one', 'and line two' }, function()
local a = Pos.new(nil, 1, 1)
local b = Pos.new(nil, 2, Pos.MAX_COL)
a:save_to_pos "'<" a:save_to_pos "'<"
b:save_to_pos "'>" b:save_to_pos "'>"
local range = Range.from_cmd_args(args) --[[@as u.Range]]
assert.are.same(range.start, a)
assert.are.same(range.stop, b)
assert.are.same(range.mode, 'V')
assert.are.same(range:text(), 'line one\nand line two')
end)
end)
local range = Range.from_cmd_args(args) it('from_cmd_args: range=2: visual: charwise', function()
local args = { range = 2, line1 = 1, line2 = 1 }
withbuf({ 'line one', 'and line two' }, function()
-- Simulate a visual selection:
local a = Pos.new(nil, 1, 1)
local b = Pos.new(nil, 1, 4)
a:save_to_pos "'<"
b:save_to_pos "'>"
Range.new(a, b, 'v'):set_visual_selection()
assert.are.same(vim.fn.mode(), 'v')
-- In this simulated setup, we need to force visualmode() to return
-- 'v' and histget() to return [['<,'>]]:
-- visualmode()
local orig_visualmode = vim.fn.visualmode
--- @diagnostic disable-next-line: duplicate-set-field
function vim.fn.visualmode() return 'v' end
assert.are.same(vim.fn.visualmode(), 'v')
-- histget()
local orig_histget = vim.fn.histget
--- @diagnostic disable-next-line: duplicate-set-field
function vim.fn.histget(x, y) return [['<,'>]] end
-- Now run the test:
local range = Range.from_cmd_args(args) --[[@as u.Range]]
assert.are.same(range.start, a) assert.are.same(range.start, a)
assert.are.same(range.stop, b) assert.are.same(range.stop, b)
assert.are.same(range.mode, 'v') assert.are.same(range.mode, 'v')
assert.are.same(range:text(), 'line')
-- Reset visualmode() and histget():
vim.fn.visualmode = orig_visualmode
vim.fn.histget = orig_histget
end) end)
end) end)
@ -617,4 +810,86 @@ describe('Range', function()
}, vim.api.nvim_buf_get_lines(b, 0, -1, false)) }, vim.api.nvim_buf_get_lines(b, 0, -1, false))
end) end)
end) end)
it('can save to extmark', function()
withbuf({
'The quick brown',
'fox',
'jumps',
'over',
'the lazy dog',
}, function()
-- Construct a range over 'fox jumps'
local r = Range.new(Pos.new(nil, 2, 1), Pos.new(nil, 3, 5), 'v')
local extrange = r:save_to_extmark()
assert.are.same({ 'fox', 'jumps' }, extrange:range():lines())
-- change 'jumps' to 'leaps':
vim.api.nvim_buf_set_text(extrange.bufnr, 2, 0, 2, 4, { 'leap' })
assert.are.same({
'The quick brown',
'fox',
'leaps',
'over',
'the lazy dog',
}, vim.api.nvim_buf_get_lines(extrange.bufnr, 0, -1, false))
assert.are.same({ 'fox', 'leaps' }, extrange:range():lines())
end)
end)
it('can save linewise extmark', function()
withbuf({
'The quick brown',
'fox',
'jumps',
'over',
'the lazy dog',
}, function()
-- Construct a range over 'fox jumps'
local r = Range.new(Pos.new(nil, 2, 1), Pos.new(nil, 3, Pos.MAX_COL), 'V')
local extrange = r:save_to_extmark()
assert.are.same({ 'fox', 'jumps' }, extrange:range():lines())
local extmark = vim.api.nvim_buf_get_extmark_by_id(
extrange.bufnr,
vim.api.nvim_create_namespace 'u.range',
extrange.id,
{ details = true }
)
local row0, col0, details = unpack(extmark)
assert.are.same({ 1, 0 }, { row0, col0 })
assert.are.same({ 3, 0 }, { details.end_row, details.end_col })
end)
end)
it('discerns range bounds from extmarks beyond the end of the buffer', function()
local Buffer = require 'u.buffer'
vim.cmd.vnew()
local left = Buffer.current()
left:set_tmp_options()
vim.cmd.vnew()
local right = Buffer.current()
right:set_tmp_options()
left:all():replace {
'one',
'two',
'three',
}
local left_all_ext = left:all():save_to_extmark()
right:all():replace {
'foo',
'bar',
}
vim.api.nvim_set_current_buf(right.bufnr)
vim.cmd [[normal! ggyG]]
vim.api.nvim_set_current_buf(left.bufnr)
vim.cmd [[normal! ggVGp]]
assert.are.same({ 'foo', 'bar' }, left_all_ext:range():lines())
vim.api.nvim_buf_delete(left.bufnr, { force = true })
vim.api.nvim_buf_delete(right.bufnr, { force = true })
end)
end) end)

View File

@ -83,13 +83,13 @@ describe('Renderer', function()
end) end)
-- --
-- get_pos_infos -- get_tags_at
-- --
it('should return no extmarks for an empty buffer', function() it('should return no extmarks for an empty buffer', function()
withbuf({}, function() withbuf({}, function()
local r = R.Renderer.new(0) local r = R.Renderer.new(0)
local pos_infos = r:get_pos_infos { 0, 0 } local pos_infos = r:get_tags_at { 0, 0 }
assert.are.same(pos_infos, {}) assert.are.same(pos_infos, {})
end) end)
end) end)
@ -102,12 +102,28 @@ describe('Renderer', function()
R.h('text', { hl = 'HighlightGroup2' }, ' World'), R.h('text', { hl = 'HighlightGroup2' }, ' World'),
} }
local pos_infos = r:get_pos_infos { 0, 2 } local pos_infos = r:get_tags_at { 0, 2 }
assert.are.same(#pos_infos, 1) assert.are.same(#pos_infos, 1)
assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1') assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1')
assert.are.same(pos_infos[1].extmark.start, { 0, 0 }) assert.are.same(pos_infos[1].extmark.start, { 0, 0 })
assert.are.same(pos_infos[1].extmark.stop, { 0, 5 }) assert.are.same(pos_infos[1].extmark.stop, { 0, 5 })
pos_infos = r:get_tags_at { 0, 4 }
assert.are.same(#pos_infos, 1)
assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1')
assert.are.same(pos_infos[1].extmark.start, { 0, 0 })
assert.are.same(pos_infos[1].extmark.stop, { 0, 5 })
pos_infos = r:get_tags_at { 0, 5 }
assert.are.same(#pos_infos, 1)
assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2')
assert.are.same(pos_infos[1].extmark.start, { 0, 5 })
assert.are.same(pos_infos[1].extmark.stop, { 0, 11 })
-- In insert mode, bounds are eagerly included:
pos_infos = r:get_tags_at({ 0, 5 }, 'i')
assert.are.same(#pos_infos, 2)
assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup1')
assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup2')
end) end)
end) end)
@ -125,11 +141,122 @@ describe('Renderer', function()
}), }),
} }
local pos_infos = r:get_pos_infos { 0, 5 } local pos_infos = r:get_tags_at { 0, 5 }
assert.are.same(#pos_infos, 2) assert.are.same(#pos_infos, 2)
assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2') assert.are.same(pos_infos[1].tag.attributes.hl, 'HighlightGroup2')
assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup1') assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup1')
end) end)
end) end)
it('repeated patch_lines calls should not change the buffer content', function()
local lines = {
[[{ {]],
[[ bounds = {]],
[[ start1 = { 1, 1 },]],
[[ stop1 = { 4, 1 }]],
[[ },]],
[[ end_right_gravity = true,]],
[[ id = 1,]],
[[ ns_id = 623,]],
[[ ns_name = "my.renderer:91",]],
[[ right_gravity = false]],
[[ } }]],
[[]],
}
withbuf(lines, function()
local Buffer = require 'u.buffer'
R.Renderer.patch_lines(0, nil, lines)
assert.are.same(Buffer.current():all():lines(), lines)
R.Renderer.patch_lines(0, lines, lines)
assert.are.same(Buffer.current():all():lines(), lines)
R.Renderer.patch_lines(0, lines, lines)
assert.are.same(Buffer.current():all():lines(), lines)
end)
end)
it('should fire text-changed events', function()
withbuf({}, function()
local Buffer = require 'u.buffer'
local buf = Buffer.current()
local r = R.Renderer.new(0)
local captured_changed_text = ''
r:render {
R.h('text', {
on_change = function(txt) captured_changed_text = txt end,
}, {
'one\n',
'two\n',
'three\n',
}),
}
vim.fn.setreg('"', 'bleh')
vim.cmd [[normal! ggVGp]]
-- For some reason, the autocmd does not fire in the busted environment.
-- We'll call the handler ourselves:
r:_on_text_changed()
assert.are.same(buf:all():text(), 'bleh')
assert.are.same(captured_changed_text, 'bleh')
end)
end)
it('should find tags by position', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render {
'pre',
R.h('text', {
id = 'outer',
}, {
'inner-pre',
R.h('text', {
id = 'inner',
}, {
'inner-text',
}),
'inner-post',
}),
'post',
}
local tags = r:get_tags_at { 0, 11 }
assert.are.same(#tags, 1)
assert.are.same(tags[1].tag.attributes.id, 'outer')
tags = r:get_tags_at { 0, 12 }
assert.are.same(#tags, 2)
assert.are.same(tags[1].tag.attributes.id, 'inner')
assert.are.same(tags[2].tag.attributes.id, 'outer')
end)
end)
it('should find tags by id', function()
withbuf({}, function()
local r = R.Renderer.new(0)
r:render {
R.h('text', {
id = 'outer',
}, {
'inner-pre',
R.h('text', {
id = 'inner',
}, {
'inner-text',
}),
'inner-post',
}),
'post',
}
local bounds = r:get_tag_bounds 'outer'
assert.are.same(bounds, { start = { 0, 0 }, stop = { 0, 29 } })
bounds = r:get_tag_bounds 'inner'
assert.are.same(bounds, { start = { 0, 9 }, stop = { 0, 19 } })
end)
end)
end) end)