From 06e6b883917651c50ea97142ae97dbc1c3061cf5 Mon Sep 17 00:00:00 2001 From: Jonathan Apodaca Date: Wed, 11 Jun 2025 20:04:46 -0600 Subject: [PATCH] range: extmarks/tsquery; renderer: text-change --- .github/workflows/ci.yaml | 8 +- .woodpecker/ci.yaml | 10 ++ Makefile | 3 + examples/filetree.lua | 28 +-- examples/form.lua | 117 +++++++++++++ examples/notify.lua | 8 +- examples/picker.lua | 12 +- lua/u/buffer.lua | 5 +- lua/u/codewriter.lua | 2 +- lua/u/logger.lua | 26 +-- lua/u/pos.lua | 11 ++ lua/u/range.lua | 180 +++++++++++++++---- lua/u/renderer.lua | 356 ++++++++++++++++++++++++++++++-------- lua/u/utils.lua | 169 ++++++++++++++++-- shell.nix | 7 +- spec/range_spec.lua | 283 +++++++++++++++++++++++++++++- spec/renderer_spec.lua | 137 ++++++++++++++- 17 files changed, 1188 insertions(+), 174 deletions(-) create mode 100644 .woodpecker/ci.yaml create mode 100644 examples/form.lua diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index c8e6c01..55d9ab4 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,16 +14,16 @@ jobs: - name: Populate Nix store run: - nix-shell --run 'true' + nix-shell --pure --run 'true' - name: Type-check with lua-language-server run: - nix-shell --run 'make lint' + nix-shell --pure --run 'make lint' - name: Check formatting with stylua run: - nix-shell --run 'make fmt-check' + nix-shell --pure --run 'make fmt-check' - name: Run busted tests run: - nix-shell --run 'make test' + nix-shell --pure --run 'make test' diff --git a/.woodpecker/ci.yaml b/.woodpecker/ci.yaml new file mode 100644 index 0000000..8374cb6 --- /dev/null +++ b/.woodpecker/ci.yaml @@ -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' diff --git a/Makefile b/Makefile index 0c4a074..4e5b84f 100644 --- a/Makefile +++ b/Makefile @@ -19,3 +19,6 @@ test: @echo "## Generating coverage report" @luacov @awk '/^Summary$$/{flag=1;next} flag{print}' luacov.report.out + +watch: + @watchexec -c -e lua make diff --git a/examples/filetree.lua b/examples/filetree.lua index d3b3998..ed175b0 100644 --- a/examples/filetree.lua +++ b/examples/filetree.lua @@ -10,10 +10,10 @@ -- change on the underlying filesystem. -------------------------------------------------------------------------------- ---- @alias FsDir { kind: 'dir'; path: string; expanded: boolean; children: FsNode[] } ---- @alias FsFile { kind: 'file'; path: string } ---- @alias FsNode FsDir | FsFile ---- @alias ShowOpts { root_path?: string, width?: number, focus_path?: string } +--- @alias u.examples.FsDir { kind: 'dir'; path: string; expanded: boolean; children: u.examples.FsNode[] } +--- @alias u.examples.FsFile { kind: 'file'; path: string } +--- @alias u.examples.FsNode u.examples.FsDir | u.examples.FsFile +--- @alias u.examples.ShowOpts { root_path?: string, width?: number, focus_path?: string } local Buffer = require 'u.buffer' local Renderer = require('u.renderer').Renderer @@ -58,13 +58,13 @@ function H.relative(path, base) end --- @param root_path string ---- @return { tree: FsDir; path_to_node: table } +--- @return { tree: u.examples.FsDir; path_to_node: table } function H.get_tree_inf(root_path) logger:info { 'get_tree_inf', root_path } - --- @type table + --- @type table local path_to_node = {} - --- @type FsDir + --- @type u.examples.FsDir local tree = { kind = 'dir', 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 } end ---- @param tree FsDir ---- @param path_to_node table +--- @param tree u.examples.FsDir +--- @param path_to_node table function H.populate_dir_children(tree, path_to_node) 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 path_to_node = tree_inf.path_to_node - --- @param node FsDir + --- @param node u.examples.FsDir --- @param child_names string[] local function expand_to(node, child_names) if #child_names == 0 then return end @@ -310,7 +310,7 @@ local function _render_in_buffer(opts) -- local renderer = Renderer.new(opts.bufnr) tracker.create_effect(function() - --- @type { tree: FsDir; path_to_node: table } + --- @type { tree: u.examples.FsDir; path_to_node: table } local tree_inf = s_tree_inf:get() 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 --- recursively render each node. This function does just that: - --- @param node FsNode + --- @param node u.examples.FsNode --- @param level number local function render_node(node, level) local name = vim.fs.basename(node.path) @@ -414,7 +414,7 @@ end local current_inf = nil --- Show the filetree: ---- @param opts? ShowOpts +--- @param opts? u.examples.ShowOpts function M.show(opts) if current_inf ~= nil then return current_inf.controller end opts = opts or {} @@ -456,7 +456,7 @@ function M.hide() end --- Toggle the filetree: ---- @param opts? ShowOpts +--- @param opts? u.examples.ShowOpts function M.toggle(opts) if current_inf == nil then M.show(opts) diff --git a/examples/form.lua b/examples/form.lua new file mode 100644 index 0000000..875e128 --- /dev/null +++ b/examples/form.lua @@ -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) diff --git a/examples/notify.lua b/examples/notify.lua index 869404f..7d289d5 100644 --- a/examples/notify.lua +++ b/examples/notify.lua @@ -14,7 +14,7 @@ local ICONS = { } local DEFAULT_ICON = { text = '', group = 'DiagnosticSignOk' } ---- @alias Notification { +--- @alias u.examples.Notification { --- kind: number; --- id: number; --- text: string; @@ -30,7 +30,7 @@ local s_notifications = s_notifications_raw:debounce(50) -- Render effect: tracker.create_effect(function() - --- @type Notification[] + --- @type u.examples.Notification[] local notifs = s_notifications:get() if #notifs == 0 then @@ -99,14 +99,14 @@ local function my_notify(msg, level, opts) local id = math.random(math.huge) - --- @param notifs Notification[] + --- @param notifs u.examples.Notification[] s_notifications_raw:schedule_update(function(notifs) table.insert(notifs, { kind = level, id = id, text = msg }) return notifs end) vim.defer_fn(function() - --- @param notifs Notification[] + --- @param notifs u.examples.Notification[] s_notifications_raw:schedule_update(function(notifs) for i, notif in ipairs(notifs) do if notif.id == id then diff --git a/examples/picker.lua b/examples/picker.lua index a388733..866d8fc 100644 --- a/examples/picker.lua +++ b/examples/picker.lua @@ -44,7 +44,7 @@ local function shallow_copy_arr(arr) return vim.iter(arr):totable() end -- shortest portion of this function. -------------------------------------------------------------------------------- ---- @alias SelectController { +--- @alias u.examples.SelectController { --- get_items: fun(): T[]; --- set_items: fun(items: T[]); --- 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); --- close: fun(); --- } ---- @alias SelectOpts { +--- @alias u.examples.SelectOpts { --- items: `T`[]; --- multi?: boolean; ---- format_item?: fun(item: T): Tree; +--- format_item?: fun(item: T): u.renderer.Tree; --- on_finish?: fun(items: T[], indicies: number[]); --- on_selection_changed?: fun(items: T[], indicies: number[]); ---- mappings?: table; +--- mappings?: table; --- } --- @generic T ---- @param opts SelectOpts +--- @param opts u.examples.SelectOpts function M.create_picker(opts) -- {{{ local is_in_insert_mode = vim.api.nvim_get_mode().mode:sub(1, 1) == 'i' local stopinsert = not is_in_insert_mode @@ -557,7 +557,7 @@ function M.create_picker(opts) -- {{{ return safe_run(function() H.finish(true) end) end - return controller --[[@as SelectController]] + return controller --[[@as u.examples.SelectController]] end -- }}} -------------------------------------------------------------------------------- diff --git a/lua/u/buffer.lua b/lua/u/buffer.lua index 0897a4f..0305349 100644 --- a/lua/u/buffer.lua +++ b/lua/u/buffer.lua @@ -5,7 +5,7 @@ local Renderer = require('u.renderer').Renderer --- @field bufnr number --- @field b vim.var_accessor --- @field bo vim.bo ---- @field private renderer u.Renderer +--- @field renderer u.renderer.Renderer local Buffer = {} Buffer.__index = Buffer @@ -75,6 +75,9 @@ function Buffer:autocmd(event, opts) vim.api.nvim_create_autocmd(event, vim.tbl_extend('force', opts, { buffer = self.bufnr })) end +--- @param fn function +function Buffer:call(fn) return vim.api.nvim_buf_call(self.bufnr, fn) end + --- @param tree u.renderer.Tree function Buffer:render(tree) return self.renderer:render(tree) end diff --git a/lua/u/codewriter.lua b/lua/u/codewriter.lua index d8e810b..8dfb2b7 100644 --- a/lua/u/codewriter.lua +++ b/lua/u/codewriter.lua @@ -32,7 +32,7 @@ end --- @param line string --- @param bufnr? number function CodeWriter.from_line(line, bufnr) - if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end + if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end local ws = line:match '^%s*' local expandtab = vim.api.nvim_get_option_value('expandtab', { buf = bufnr }) diff --git a/lua/u/logger.lua b/lua/u/logger.lua index 07fdf04..f89480a 100644 --- a/lua/u/logger.lua +++ b/lua/u/logger.lua @@ -1,9 +1,9 @@ local M = {} +local LOG_ROOT = vim.fs.joinpath(vim.fn.stdpath 'cache', 'u.log') + --- @params name string -function M.file_for_name(name) - return vim.fs.joinpath(vim.fn.stdpath 'cache', 'u.log', name .. '.log.jsonl') -end +function M.file_for_name(name) return vim.fs.joinpath(LOG_ROOT, name .. '.log.jsonl') end -------------------------------------------------------------------------------- -- Logger class @@ -11,7 +11,6 @@ end --- @class u.Logger --- @field name string ---- @field private fd number local Logger = {} Logger.__index = Logger M.Logger = Logger @@ -20,10 +19,7 @@ M.Logger = Logger function Logger.new(name) local file_path = M.file_for_name(name) vim.fn.mkdir(vim.fs.dirname(file_path), 'p') - local self = setmetatable({ - name = name, - fd = (vim.uv or vim.loop).fs_open(file_path, 'a', tonumber('644', 8)), - }, Logger) + local self = setmetatable({ name = name }, Logger) return self end @@ -32,10 +28,12 @@ end function Logger:write(level, ...) local data = { ... } if #data == 1 then data = data[1] end - (vim.uv or vim.loop).fs_write( - self.fd, - vim.json.encode { ts = os.date(), level = level, data = data } .. '\n' + local f = assert(io.open(M.file_for_name(self.name), 'a'), 'could not open file') + assert( + f:write(vim.json.encode { ts = os.date(), level = level, data = data } .. '\n'), + 'could not write to file' ) + f:close() end function Logger:trace(...) self:write('INFO', ...) end @@ -64,6 +62,12 @@ function M.setup() vim.cmd.terminal('tail -f "' .. log_file_path .. '"') vim.cmd.startinsert() end, { nargs = '*' }) + + vim.api.nvim_create_user_command( + 'Logroot', + function() vim.api.nvim_echo({ { LOG_ROOT } }, false, {}) end, + {} + ) end return M diff --git a/lua/u/pos.lua b/lua/u/pos.lua index a3216d7..7b2b42b 100644 --- a/lua/u/pos.lua +++ b/lua/u/pos.lua @@ -67,6 +67,15 @@ function Pos.__sub(x, y) return x:next(-y) end +--- @param bufnr number +--- @param lnum number +function Pos.from_eol(bufnr, lnum) + if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end + local pos = Pos.new(bufnr, lnum, 0) + pos.col = pos:line():len() + return pos +end + --- @param name string --- @return u.Pos function Pos.from_pos(name) @@ -96,6 +105,8 @@ end function Pos:as_vim() return { self.bufnr, self.lnum, self.col, self.off } end +function Pos:eol() return Pos.from_eol(self.bufnr, self.lnum) end + --- @param pos string function Pos:save_to_pos(pos) vim.fn.setpos(pos, { self.bufnr, self.lnum, self.col, self.off }) end diff --git a/lua/u/range.lua b/lua/u/range.lua index b8d8c0f..fbb0969 100644 --- a/lua/u/range.lua +++ b/lua/u/range.lua @@ -1,6 +1,13 @@ local Pos = require 'u.pos' local ESC = vim.api.nvim_replace_termcodes('', true, false, true) +local NS = vim.api.nvim_create_namespace 'u.range' + +---@class u.ExtmarkRange +---@field bufnr number +---@field id number +local ExtmarkRange = {} +ExtmarkRange.__index = ExtmarkRange --- @class u.Range --- @field start u.Pos @@ -83,6 +90,34 @@ function Range.from_marks(lpos, rpos) return Range.new(start, stop, mode) end +--- @param bufnr number +--- @param extmark vim.api.keyset.get_extmark_item_by_id +function Range.from_extmark(bufnr, extmark) + ---@type integer, integer, vim.api.keyset.extmark_details | nil + local start_row0, start_col0, details = unpack(extmark) + + local start = Pos.new(bufnr, start_row0 + 1, start_col0 + 1) + local stop = details and Pos.new(bufnr, details.end_row + 1, details.end_col) + + if stop ~= nil then + -- Check for invalid extmark range: + if stop < start then return Range.new(stop) end + + -- Check for stop-mark past the end of the buffer: + local buf_max_lines = vim.api.nvim_buf_line_count(bufnr) + if stop.lnum > buf_max_lines then + stop.lnum = buf_max_lines + stop = stop:eol() + end + + -- A stop mark at position 0 means it is at the end of the last line. + -- Move it back. + if stop.col == 0 then stop = stop:must_next(-1) end + end + + return Range.new(start, stop, 'v') +end + --- @param bufnr? number function Range.from_buf_text(bufnr) if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end @@ -115,7 +150,7 @@ end function Range.from_motion(motion, opts) -- Options handling: opts = opts or {} - if opts.bufnr == nil then opts.bufnr = vim.api.nvim_get_current_buf() end + if opts.bufnr == nil or opts.bufnr == 0 then opts.bufnr = vim.api.nvim_get_current_buf() end if opts.contains_cursor == nil then opts.contains_cursor = false end if opts.user_defined == nil then opts.user_defined = false end @@ -146,6 +181,8 @@ function Range.from_motion(motion, opts) _G.Range__from_motion_opfunc = function(ty) _G.Range__from_motion_opfunc_captured_range = Range.from_op_func(ty) end + local old_eventignore = vim.o.eventignore + vim.o.eventignore = 'all' vim.go.operatorfunc = 'v:lua.Range__from_motion_opfunc' vim.cmd { cmd = 'normal', @@ -153,6 +190,7 @@ function Range.from_motion(motion, opts) args = { ESC .. 'g@' .. motion }, mods = { silent = true }, } + vim.o.eventignore = old_eventignore end) local captured_range = _G.Range__from_motion_opfunc_captured_range @@ -193,6 +231,26 @@ function Range.from_motion(motion, opts) return captured_range end +--- @param opts? { contains_cursor?: boolean } +function Range.from_tsquery_caps(bufnr, query, opts) + opts = opts or { contains_cursor = true } + + local ranges = Range.from_buf_text(bufnr):tsquery(query) + if not ranges then return end + if not opts.contains_cursor then return ranges end + + local cursor = Pos.from_pos '.' + return vim.tbl_map(function(cap_ranges) + return vim + .iter(cap_ranges) + :filter( + --- @param r u.Range + function(r) return r:contains(cursor) end + ) + :totable() + end, ranges) +end + --- Get range information from the currently selected visual text. --- Note: from within a command mapping or an opfunc, use other specialized --- utilities, such as: @@ -220,19 +278,22 @@ end --- @param args unknown --- @return u.Range|nil function Range.from_cmd_args(args) - --- @type 'v'|'V' - local mode - --- @type nil|u.Pos - local start - local stop - if args.range == 0 then - return nil - else - start = Pos.from_pos "'<" - stop = Pos.from_pos "'>" - mode = stop:is_col_max() and 'V' or 'v' + if args.range == 0 then return nil end + + local bufnr = vim.api.nvim_get_current_buf() + if args.range == 1 then + return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line1, Pos.MAX_COL), 'V') + end + + local is_visual = vim.fn.histget('cmd', -1):sub(1, 5) == [['<,'>]] + --- @type 'v'|'V' + local mode = is_visual and vim.fn.visualmode() or 'V' + + if is_visual then + return Range.new(Pos.from_pos "'<", Pos.from_pos "'>", mode) + else + return Range.new(Pos.new(bufnr, args.line1, 1), Pos.new(bufnr, args.line2, Pos.MAX_COL), mode) end - return Range.new(start, stop, mode) end function Range.find_nearest_brackets() @@ -272,6 +333,13 @@ function Range:to_linewise() return r end +function Range:to_charwise() + local r = self:clone() + r.mode = 'v' + if r.stop:is_col_max() then r.stop = r.stop:as_real() end + return r +end + --- @param x u.Pos | u.Range function Range:contains(x) if getmetatable(x) == Pos then @@ -313,25 +381,32 @@ end --- @param left string --- @param right string function Range:save_to_pos(left, right) - if self:is_empty() then - self.start:save_to_pos(left) - self.start:save_to_pos(right) - else - self.start:save_to_pos(left) - self.stop:save_to_pos(right) - end + self.start:save_to_pos(left); + (self:is_empty() and self.start or self.stop):save_to_pos(right) end --- @param left string --- @param right string function Range:save_to_marks(left, right) - if self:is_empty() then - self.start:save_to_mark(left) - self.start:save_to_mark(right) - else - self.start:save_to_mark(left) - self.stop:save_to_mark(right) + self.start:save_to_mark(left); + (self:is_empty() and self.start or self.stop):save_to_mark(right) +end + +function Range:save_to_extmark() + local r = self:to_charwise() + local end_row = r.stop.lnum - 1 + local end_col = r.stop.col + if self.mode == 'V' then + end_row = end_row + 1 + end_col = 0 end + local id = vim.api.nvim_buf_set_extmark(r.start.bufnr, NS, r.start.lnum - 1, r.start.col - 1, { + right_gravity = false, + end_right_gravity = true, + end_row = end_row, + end_col = end_col, + }) + return ExtmarkRange.new(r.start.bufnr, id) end function Range:set_visual_selection() @@ -348,14 +423,46 @@ function Range:set_visual_selection() self.stop:save_to_pos '.' end --------------------------------------------------------------------------------- --- Range.from_* functions: --------------------------------------------------------------------------------- - -------------------------------------------------------------------------------- -- Text access/manipulation utilities: -------------------------------------------------------------------------------- +--- @param query string +function Range:tsquery(query) + local bufnr = self.start.bufnr + + local lang = vim.treesitter.language.get_lang(vim.bo[bufnr].filetype) + if lang == nil then return end + local parser = vim.treesitter.get_parser(bufnr, lang) + if parser == nil then return end + local tree = parser:parse()[1] + if tree == nil then return end + + local root = tree:root() + local q = vim.treesitter.query.parse(lang, query) + --- @type table + local ranges = {} + for id, match, _meta in + q:iter_captures(root, bufnr, self.start.lnum - 1, (self.stop or self.start).lnum) + do + local start_row0, start_col0, stop_row0, stop_col0 = match:range() + local range = Range.new( + Pos.new(bufnr, start_row0 + 1, start_col0 + 1), + Pos.new(bufnr, stop_row0 + 1, stop_col0), + 'v' + ) + if range.stop.lnum > vim.api.nvim_buf_line_count(bufnr) then + range.stop = range.stop:must_next(-1) + end + + local capture_name = q.captures[id] + if not ranges[capture_name] then ranges[capture_name] = {} end + if self:contains(range) then table.insert(ranges[capture_name], range) end + end + + return ranges +end + function Range:length() if self:is_empty() then return 0 end @@ -596,4 +703,17 @@ function Range:highlight(group, opts) } end +function ExtmarkRange.new(bufnr, id) return setmetatable({ bufnr = bufnr, id = id }, ExtmarkRange) end + +function ExtmarkRange:range() + return Range.from_extmark( + self.bufnr, + vim.api.nvim_buf_get_extmark_by_id(self.bufnr, NS, self.id, { + details = true, + }) + ) +end + +function ExtmarkRange:delete() vim.api.nvim_buf_del_extmark(self.bufnr, NS, self.id) end + return Range diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua index 1873ecc..aa68168 100644 --- a/lua/u/renderer.lua +++ b/lua/u/renderer.lua @@ -1,12 +1,23 @@ +function _G.URendererOpFuncSwallow() end + local M = {} local H = {} ---- @alias u.renderer.Tag { kind: 'tag'; name: string, attributes: table, 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; nmap?: table; vmap?: table; xmap?: table; omap?: table, 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, children: u.renderer.Tree): u.renderer.Tag> & fun(name: string, attributes: table, children: u.renderer.Tree): u.renderer.Tag> +--- @type table & fun(name: string, attributes: u.renderer.TagAttributes, children: u.renderer.Tree): u.renderer.Tag> M.h = setmetatable({}, { __call = function(_, name, attributes, children) return { @@ -17,7 +28,6 @@ M.h = setmetatable({}, { } end, __index = function(_, name) - -- vim.print('dynamic hl ' .. name) return function(attributes, children) return M.h('text', vim.tbl_deep_extend('force', { hl = name }, attributes), children) end @@ -25,14 +35,19 @@ M.h = setmetatable({}, { }) -- Renderer {{{ ---- @alias RendererExtmark { id?: number; start: [number, number]; stop: [number, number]; opts: any; tag: any } +--- @class u.renderer.RendererExtmark +--- @field id? number +--- @field start [number, number] +--- @field stop [number, number] +--- @field opts vim.api.keyset.set_extmark +--- @field tag u.renderer.Tag ---- @class u.Renderer +--- @class u.renderer.Renderer --- @field bufnr number --- @field ns number --- @field changedtick number ---- @field old { lines: string[]; extmarks: RendererExtmark[] } ---- @field curr { lines: string[]; extmarks: RendererExtmark[] } +--- @field old { lines: string[]; extmarks: u.renderer.RendererExtmark[] } +--- @field curr { lines: string[]; extmarks: u.renderer.RendererExtmark[] } local Renderer = {} Renderer.__index = Renderer M.Renderer = Renderer @@ -51,7 +66,7 @@ function Renderer.is_tag_arr(x) end --- @param bufnr number|nil function Renderer.new(bufnr) -- {{{ - if bufnr == nil then bufnr = vim.api.nvim_get_current_buf() end + if bufnr == nil or bufnr == 0 then bufnr = vim.api.nvim_get_current_buf() end if vim.b[bufnr]._renderer_ns == nil then vim.b[bufnr]._renderer_ns = vim.api.nvim_create_namespace('my.renderer:' .. tostring(bufnr)) @@ -64,6 +79,12 @@ function Renderer.new(bufnr) -- {{{ old = { lines = {}, extmarks = {} }, curr = { lines = {}, extmarks = {} }, }, Renderer) + + vim.api.nvim_create_autocmd({ 'TextChanged', 'TextChangedI', 'TextChangedP' }, { + buffer = bufnr, + callback = function() self:_on_text_changed() end, + }) + return self end -- }}} @@ -128,7 +149,7 @@ function Renderer.markup_to_lines(opts) -- {{{ end -- }}} --- @param opts { ---- tree: string; +--- tree: u.renderer.Tree; --- format_tag?: fun(tag: u.renderer.Tag): string; --- } function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_lines(opts), '\n') end @@ -136,7 +157,7 @@ function Renderer.markup_to_string(opts) return table.concat(Renderer.markup_to_ --- @param bufnr number --- @param old_lines string[] | nil --- @param new_lines string[] -function Renderer.patch_lines(bufnr, old_lines, new_lines) +function Renderer.patch_lines(bufnr, old_lines, new_lines) -- {{{ -- -- Helpers: -- @@ -189,7 +210,7 @@ function Renderer.patch_lines(bufnr, old_lines, new_lines) -- No change end end -end +end -- }}} --- @param tree u.renderer.Tree function Renderer:render(tree) -- {{{ @@ -199,7 +220,7 @@ function Renderer:render(tree) -- {{{ self.changedtick = changedtick end - --- @type RendererExtmark[] + --- @type u.renderer.RendererExtmark[] local extmarks = {} --- @type string[] @@ -214,31 +235,36 @@ function Renderer:render(tree) -- {{{ tag.attributes.extmark.hl_group = tag.attributes.extmark.hl_group or hl end - local extmark = tag.attributes.extmark + local extmark_opts = tag.attributes.extmark or {} -- Set any necessary keymaps: for _, mode in ipairs { 'i', 'n', 'v', 'x', 'o' } do for lhs, _ in pairs(tag.attributes[mode .. 'map'] or {}) do -- Force creating an extmark if there are key handlers. To accurately -- sense the bounds of the text, we need an extmark: - extmark = extmark or {} - vim.keymap.set( - 'n', - lhs, - function() return self:_expr_map_callback('n', lhs) end, - { buffer = self.bufnr, expr = true, replace_keycodes = true } - ) + vim.keymap.set(mode, lhs, function() + local result = self:_expr_map_callback(mode, lhs) + -- If the handler indicates that it wants to swallow the event, + -- we have to convert that intention into something compatible + -- with expr-mappings, which don't support '' (they try to + -- execute the literal characters). We'll use the 'g@' operator + -- to do that, forwarding the event to an operatorfunc that does + -- nothing: + if result == '' then + vim.go.operatorfunc = 'v:lua.URendererOpFuncSwallow' + return 'g@ ' + end + return result + end, { buffer = self.bufnr, expr = true, replace_keycodes = true }) end end - if extmark then - table.insert(extmarks, { - start = start0, - stop = stop0, - opts = extmark, - tag = tag, - }) - end + table.insert(extmarks, { + start = start0, + stop = stop0, + opts = extmark_opts, + tag = tag, + }) end end, -- }}} } @@ -248,25 +274,6 @@ function Renderer:render(tree) -- {{{ self:_reconcile() end -- }}} ---- @private ---- @param start integer ---- @param end_ integer ---- @param strict_indexing boolean ---- @param replacement string[] -function Renderer:_set_lines(start, end_, strict_indexing, replacement) - vim.api.nvim_buf_set_lines(self.bufnr, start, end_, strict_indexing, replacement) -end - ---- @private ---- @param start_row integer ---- @param start_col integer ---- @param end_row integer ---- @param end_col integer ---- @param replacement string[] -function Renderer:_set_text(start_row, start_col, end_row, end_col, replacement) - vim.api.nvim_buf_set_text(self.bufnr, start_row, start_col, end_row, end_col, replacement) -end - --- @private function Renderer:_reconcile() -- {{{ -- @@ -277,10 +284,15 @@ function Renderer:_reconcile() -- {{{ -- -- Step 2: reconcile extmarks: + -- You may be tempted to try to keep track of which extmarks are needed, and + -- only delete those that are not needed. However, each time a tree is + -- rendered, brand new extmarks are created. For simplicity, it is better to + -- just delete all extmarks, and recreate them. -- -- Clear current extmarks: vim.api.nvim_buf_clear_namespace(self.bufnr, self.ns, 0, -1) + -- Set current extmarks: for _, extmark in ipairs(self.curr.extmarks) do extmark.id = vim.api.nvim_buf_set_extmark( @@ -292,6 +304,13 @@ function Renderer:_reconcile() -- {{{ id = extmark.id, end_row = extmark.stop[1], end_col = extmark.stop[2], + -- If we change the text starting from the beginning (where the extmark + -- is), we don't want the extmark to move to the right. + right_gravity = false, + -- If we change the text starting from the end (where the end extmark + -- is), we don't want the extmark to stay stationary: we want it to + -- move to the right. + end_right_gravity = true, }, extmark.opts) ) end @@ -306,7 +325,7 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{ -- find the tag with the smallest intersection that contains the cursor: local pos0 = vim.api.nvim_win_get_cursor(0) pos0[1] = pos0[1] - 1 -- make it actually 0-based - local pos_infos = self:get_pos_infos(pos0) + local pos_infos = self:get_tags_at(pos0) if #pos_infos == 0 then return lhs end @@ -316,9 +335,10 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{ local tag = pos_info.tag -- is the tag listening? + --- @type u.renderer.TagEventHandler? local f = vim.tbl_get(tag.attributes, mode .. 'map', lhs) if type(f) == 'function' then - local result = f() + local result = f(tag, mode, lhs) if result == '' then -- bubble-up to the next tag, but set cancel to true, in case there are -- no more tags to bubble up to: @@ -333,21 +353,145 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{ return cancel and '' or lhs end -- }}} +function Renderer:_on_text_changed() -- {{{ + -- Reset changedtick, so that the reconciler knows to refresh its cached + -- buffer-content before computing the diff: + self.changedtick = 0 + + --- @type integer, integer + local l, c = unpack(vim.api.nvim_win_get_cursor(0)) + l = l - 1 -- make it actually 0-based + local pos_infos = self:get_tags_at({ l, c }, 'i') + for _, pos_info in ipairs(pos_infos) do + local extmark_inf = pos_info.extmark + local tag = pos_info.tag + + local on_change = tag.attributes.on_change + if on_change and type(on_change) == 'function' then + local extmark = + vim.api.nvim_buf_get_extmark_by_id(self.bufnr, self.ns, extmark_inf.id, { details = true }) + + --- @type integer, integer, vim.api.keyset.extmark_details + local start_row0, start_col0, details = unpack(extmark) + local end_row0, end_col0 = details.end_row, details.end_col + + local buf_max_line0 = math.max(1, vim.api.nvim_buf_line_count(self.bufnr) - 1) + if end_row0 > buf_max_line0 then + end_row0 = buf_max_line0 + local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1] + or '' + end_col0 = last_line:len() + end + if end_col0 == 0 then + end_row0 = end_row0 - 1 + local last_line = vim.api.nvim_buf_get_lines(self.bufnr, end_row0, end_row0 + 1, false)[1] + or '' + end_col0 = last_line:len() + end + + if start_row0 == end_row0 and start_col0 == end_col0 then + on_change '' + else + local pos1 = { self.bufnr, start_row0 + 1, start_col0 + 1 } + local pos2 = { self.bufnr, end_row0 + 1, end_col0 } + local ok, lines = pcall(vim.fn.getregion, pos1, pos2, { type = 'v' }) + if not ok then + vim.api.nvim_echo({ + { '(u.nvim:getregion:invalid-pos) ', 'ErrorMsg' }, + { + '{ start, end } = ' .. vim.inspect({ pos1, pos2 }, { newline = ' ', indent = '' }), + }, + }, true, {}) + error(lines) + end + local text = table.concat(lines, '\n') + on_change(text) + end + end + end +end -- }}} + +--- @private +function Renderer:_debug() -- {{{ + local prev_w = vim.api.nvim_get_current_win() + vim.cmd.vnew() + local info_bufnr = vim.api.nvim_get_current_buf() + vim.bo.bufhidden = 'delete' + vim.bo.buflisted = false + vim.bo.buftype = 'nowrite' + + local ids = {} + local function cleanup() + for _, id in ipairs(ids) do + vim.api.nvim_del_autocmd(id) + end + vim.api.nvim_buf_delete(info_bufnr, { force = true }) + end + + table.insert( + ids, + vim.api.nvim_create_autocmd({ 'CursorMoved', 'CursorMovedI' }, { + callback = function() + if vim.api.nvim_get_current_win() ~= prev_w then return end + + local l, c = unpack(vim.api.nvim_win_get_cursor(0)) + l = l - 1 -- make it actually 0-based + + local info = { + cursor = { + pos = { l, c }, + tags = self:get_tags_at { l, c }, + extmarks = vim.api.nvim_buf_get_extmarks( + self.bufnr, + self.ns, + { l, c }, + { l, c }, + { details = true, overlap = true } + ), + }, + computed = { + extmarks = self.curr.extmarks, + }, + } + vim.api.nvim_buf_set_lines(info_bufnr, 0, -1, true, vim.split(vim.inspect(info), '\n')) + end, + }) + ) + table.insert( + ids, + vim.api.nvim_create_autocmd('WinClosed', { + pattern = tostring(vim.api.nvim_get_current_win()), + callback = cleanup, + }) + ) + table.insert( + ids, + vim.api.nvim_create_autocmd('WinClosed', { + pattern = tostring(prev_w), + callback = cleanup, + }) + ) + + vim.api.nvim_set_current_win(prev_w) +end -- }}} + --- Returns pairs of extmarks and tags associate with said extmarks. The --- returned tags/extmarks are sorted smallest (innermost) to largest --- (outermost). --- --- @private (private for now) --- @param pos0 [number; number] ---- @return { extmark: RendererExtmark; tag: u.renderer.Tag; }[] -function Renderer:get_pos_infos(pos0) -- {{{ +--- @param mode string? +--- @return { extmark: u.renderer.RendererExtmark; tag: u.renderer.Tag; }[] +function Renderer:get_tags_at(pos0, mode) -- {{{ local cursor_line0, cursor_col0 = pos0[1], pos0[2] + if not mode then mode = vim.api.nvim_get_mode().mode end -- The cursor (block) occupies **two** extmark spaces: one for it's left -- edge, and one for it's right. We need to do our own intersection test, -- because the NeoVim API is over-inclusive in what it returns: - --- @type RendererExtmark[] - local intersecting_extmarks = vim + --- @type u.renderer.RendererExtmark[] + local mapped_extmarks = vim .iter( vim.api.nvim_buf_get_extmarks( self.bufnr, @@ -357,7 +501,7 @@ function Renderer:get_pos_infos(pos0) -- {{{ { details = true, overlap = true } ) ) - --- @return RendererExtmark + --- @return u.renderer.RendererExtmark :map(function(ext) --- @type number, number, number, { end_row?: number; end_col?: number }|nil local id, line0, col0, details = unpack(ext) @@ -368,13 +512,43 @@ function Renderer:get_pos_infos(pos0) -- {{{ end return { id = id, start = start, stop = stop, opts = details } end) - --- @param ext RendererExtmark + :totable() + + local intersecting_extmarks = vim + .iter(mapped_extmarks) + --- @param ext u.renderer.RendererExtmark :filter(function(ext) if ext.stop[1] ~= nil and ext.stop[2] ~= nil then - return cursor_line0 >= ext.start[1] - and cursor_col0 >= ext.start[2] - and cursor_line0 <= ext.stop[1] - and cursor_col0 < ext.stop[2] + -- If we've "ciw" and "collapsed" an extmark onto the cursor, + -- the cursor pos will equal the exmark's start AND end. In this + -- case, we want to include the extmark. + if + cursor_line0 == ext.start[1] + and cursor_col0 == ext.start[2] + and cursor_line0 == ext.stop[1] + and cursor_col0 == ext.stop[2] + then + return true + end + + return + -- START: line check + cursor_line0 >= ext.start[1] + -- START: column check + and (cursor_line0 ~= ext.start[1] or cursor_col0 >= ext.start[2]) + -- STOP: line check + and cursor_line0 <= ext.stop[1] + -- STOP: column check + and ( + cursor_line0 ~= ext.stop[1] + or ( + mode == 'i' + -- In insert mode, the cursor is "thin", so <= to compensate: + and cursor_col0 <= ext.stop[2] + -- In normal mode, the cursor is "wide", so < to compensate: + or cursor_col0 < ext.stop[2] + ) + ) else return true end @@ -384,8 +558,8 @@ function Renderer:get_pos_infos(pos0) -- {{{ -- Sort the tags into smallest (inner) to largest (outer): table.sort( intersecting_extmarks, - --- @param x1 RendererExtmark - --- @param x2 RendererExtmark + --- @param x1 u.renderer.RendererExtmark + --- @param x2 u.renderer.RendererExtmark function(x1, x2) if x1.start[1] == x2.start[1] @@ -407,10 +581,10 @@ function Renderer:get_pos_infos(pos0) -- {{{ -- created extmarks in self.curr.extmarks, which also has which tag each -- extmark is associated with. Cross-reference with that list to get a list -- of tags that we need to fire events for: - --- @type { extmark: RendererExtmark; tag: u.renderer.Tag }[] + --- @type { extmark: u.renderer.RendererExtmark; tag: u.renderer.Tag }[] local matching_tags = vim .iter(intersecting_extmarks) - --- @param ext RendererExtmark + --- @param ext u.renderer.RendererExtmark :map(function(ext) for _, extmark_cache in ipairs(self.curr.extmarks) do if extmark_cache.id == ext.id then return { extmark = ext, tag = extmark_cache.tag } end @@ -420,10 +594,23 @@ function Renderer:get_pos_infos(pos0) -- {{{ return matching_tags end -- }}} + +--- @private +--- @param tag_or_id string | u.renderer.Tag +--- @return { start: [number, number]; stop: [number, number] } | nil +function Renderer:get_tag_bounds(tag_or_id) -- {{{ + for _, x in ipairs(self.curr.extmarks) do + local pos = { start = x.start, stop = x.stop } + local does_tag_match = type(tag_or_id) == 'string' and x.tag.attributes.id == tag_or_id + or x.tag == tag_or_id + if does_tag_match then return pos end + end +end -- }}} + -- }}} -- TreeBuilder {{{ ---- @class u.TreeBuilder +--- @class u.renderer.TreeBuilder --- @field private nodes u.renderer.Node[] local TreeBuilder = {} TreeBuilder.__index = TreeBuilder @@ -435,7 +622,7 @@ function TreeBuilder.new() end --- @param nodes u.renderer.Tree ---- @return u.TreeBuilder +--- @return u.renderer.TreeBuilder function TreeBuilder:put(nodes) table.insert(self.nodes, nodes) return self @@ -444,15 +631,15 @@ end --- @param name string --- @param attributes? table --- @param children? u.renderer.Node | u.renderer.Node[] ---- @return u.TreeBuilder +--- @return u.renderer.TreeBuilder function TreeBuilder:put_h(name, attributes, children) local tag = M.h(name, attributes, children) table.insert(self.nodes, tag) return self end ---- @param fn fun(TreeBuilder): any ---- @return u.TreeBuilder +--- @param fn fun(tb: u.renderer.TreeBuilder): any +--- @return u.renderer.TreeBuilder function TreeBuilder:nest(fn) local nested_writer = TreeBuilder.new() fn(nested_writer) @@ -460,19 +647,40 @@ function TreeBuilder:nest(fn) return self end +--- @generic T +--- @param arr T[] +--- @param f fun(tb: u.renderer.TreeBuilder, item: T, idx: number): any +function TreeBuilder:ipairs(arr, f) + return self:nest(function(tb) + for idx, item in ipairs(arr) do + f(tb, item, idx) + end + end) +end + +--- @param tab table +--- @param f fun(tb: u.renderer.TreeBuilder, key: any, value: any): any +function TreeBuilder:pairs(tab, f) + return self:nest(function(tb) + for k, v in pairs(tab) do + f(tb, k, v) + end + end) +end + --- @return u.renderer.Tree function TreeBuilder:tree() return self.nodes end -- }}} -- Levenshtein utility {{{ -- luacheck: ignore ---- @alias LevenshteinChange ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; }) +--- @alias u.renderer.LevenshteinChange ({ kind: 'add'; item: T; index: number; } | { kind: 'delete'; item: T; index: number; } | { kind: 'change'; from: T; to: T; index: number; }) --- @private --- @generic T --- @param x `T`[] --- @param y T[] --- @param cost? { of_delete?: fun(x: T): number; of_add?: fun(x: T): number; of_change?: fun(x: T, y: T): number; } ---- @return LevenshteinChange[] The changes, from last (greatest index) to first (smallest index). +--- @return u.renderer.LevenshteinChange[] The changes, from last (greatest index) to first (smallest index). function H.levenshtein(x, y, cost) -- At the moment, this whole `cost` plumbing is not used. Deletes have the -- same cost as Adds or Changes. I can imagine a future, however, where @@ -523,7 +731,7 @@ function H.levenshtein(x, y, cost) -- Backtrack to find the changes local i = m local j = n - --- @type LevenshteinChange[] + --- @type u.renderer.LevenshteinChange[] local changes = {} while i > 0 or j > 0 do @@ -540,7 +748,7 @@ function H.levenshtein(x, y, cost) if is_first_min(cost_of_change, cost_of_add, cost_of_delete) then -- potential change if x[i] ~= y[j] then - --- @type LevenshteinChange + --- @type u.renderer.LevenshteinChange local change = { kind = 'change', from = x[i], index = i, to = y[j] } table.insert(changes, change) end @@ -548,13 +756,13 @@ function H.levenshtein(x, y, cost) j = j - 1 elseif is_first_min(cost_of_add, cost_of_change, cost_of_delete) then -- addition - --- @type LevenshteinChange + --- @type u.renderer.LevenshteinChange local change = { kind = 'add', item = y[j], index = i + 1 } table.insert(changes, change) j = j - 1 elseif is_first_min(cost_of_delete, cost_of_change, cost_of_add) then -- deletion - --- @type LevenshteinChange + --- @type u.renderer.LevenshteinChange local change = { kind = 'delete', item = x[i], index = i } table.insert(changes, change) i = i - 1 diff --git a/lua/u/utils.lua b/lua/u/utils.lua index d0d7d90..9427dc4 100644 --- a/lua/u/utils.lua +++ b/lua/u/utils.lua @@ -4,15 +4,63 @@ local M = {} -- Types -- ---- @alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string } ---- @alias KeyMaps table } --- luacheck: ignore ---- @alias CmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any; info: u.Range|nil } +--- @class u.utils.QfItem +--- @field col number +--- @field filename string +--- @field kind string +--- @field lnum number +--- @field text string +--- @class u.utils.RawCmdArgs +--- @field args string +--- @field bang boolean +--- @field count number +--- @field fargs string[] +--- @field line1 number +--- @field line2 number +--- @field mods string +--- @field name string +--- @field range 0|1|2 +--- @field reg string +--- @field smods any + +--- @class u.utils.CmdArgs: u.utils.RawCmdArgs +--- @field info u.Range|nil + +--- @class u.utils.UcmdArgs +--- @field nargs? 0|1|'*'|'?'|'+' +--- @field range? boolean|'%'|number +--- @field count? boolean|number +--- @field addr? string +--- @field completion? string +--- @field force? boolean +--- @field preview? fun(opts: u.utils.UcmdArgs, ns: integer, buf: integer):0|1|2 + +-- +-- Functions +-- + +--- Debug utility that prints a value and returns it unchanged. +--- Useful for debugging in the middle of expressions or function chains. +--- --- @generic T ---- @param x `T` ---- @param message? string ---- @return T +--- @param x `T` The value to debug print +--- @param message? string Optional message to print alongside the value +--- @return T The original value, unchanged +--- +--- @usage +--- ```lua +--- -- Debug a value in the middle of a chain: +--- local result = some_function() +--- :map(utils.dbg) -- prints the intermediate value +--- :filter(predicate) +--- +--- -- Debug with a custom message: +--- local config = utils.dbg(get_config(), "Current config:") +--- +--- -- Debug return values: +--- return utils.dbg(calculate_result(), "Final result") +--- ``` function M.dbg(x, message) local t = {} if message ~= nil then table.insert(t, message) end @@ -21,22 +69,37 @@ function M.dbg(x, message) return x end ---- A utility for creating user commands that also pre-computes useful information ---- and attaches it to the arguments. +--- Creates a user command with enhanced argument processing. +--- Automatically computes range information and attaches it as `args.info`. --- +--- @param name string The command name (without the leading colon) +--- @param cmd string | fun(args: u.utils.CmdArgs): any Command implementation +--- @param opts? u.utils.UcmdArgs Command options (nargs, range, etc.) +--- +--- @usage --- ```lua ---- -- Example: ---- ucmd('MyCmd', function(args) ---- -- print the visually selected text: +--- -- Create a command that works with visual selections: +--- utils.ucmd('MyCmd', function(args) +--- -- Print the visually selected text: --- vim.print(args.info:text()) ---- -- or get the vtext as an array of lines: +--- -- Or get the selection as an array of lines: --- vim.print(args.info:lines()) --- end, { nargs = '*', range = true }) +--- +--- -- Create a command that processes the current line: +--- utils.ucmd('ProcessLine', function(args) +--- local line_text = args.info:text() +--- -- Process the line... +--- end, { range = '%' }) +--- +--- -- Create a command with arguments: +--- utils.ucmd('SearchReplace', function(args) +--- local pattern, replacement = args.fargs[1], args.fargs[2] +--- local text = args.info:text() +--- -- Perform search and replace... +--- end, { nargs = 2, range = true }) --- ``` ---- @param name string ---- @param cmd string | fun(args: CmdArgs): any -- luacheck: ignore ---- @param opts? { nargs?: 0|1|'*'|'?'|'+'; range?: boolean|'%'|number; count?: boolean|number, addr?: string; completion?: string } function M.ucmd(name, cmd, opts) local Range = require 'u.range' @@ -48,9 +111,81 @@ function M.ucmd(name, cmd, opts) return cmd(args) end end - vim.api.nvim_create_user_command(name, cmd2, opts or {}) + vim.api.nvim_create_user_command(name, cmd2, opts or {} --[[@as any]]) end +--- Creates command arguments for delegating from one command to another. +--- Preserves all relevant context (range, modifiers, bang, etc.) when +--- implementing a derived command in terms of a base command. +--- +--- @param current_args vim.api.keyset.create_user_command.command_args|u.utils.RawCmdArgs The arguments from the current command +--- @return vim.api.keyset.cmd Arguments suitable for vim.cmd() calls +--- +--- @usage +--- ```lua +--- -- Implement :MyEdit in terms of :edit, preserving all context: +--- utils.ucmd('MyEdit', function(args) +--- local delegated_args = utils.create_delegated_cmd_args(args) +--- -- Add custom logic here... +--- vim.cmd.edit(delegated_args) +--- end, { nargs = '*', range = true, bang = true }) +--- +--- -- Implement :MySubstitute that delegates to :substitute: +--- utils.ucmd('MySubstitute', function(args) +--- -- Pre-process arguments +--- local pattern = preprocess_pattern(args.fargs[1]) +--- +--- local delegated_args = utils.create_delegated_cmd_args(args) +--- delegated_args.args = { pattern, args.fargs[2] } +--- +--- vim.cmd.substitute(delegated_args) +--- end, { nargs = 2, range = true, bang = true }) +--- ``` +function M.create_delegated_cmd_args(current_args) + --- @type vim.api.keyset.cmd + local args = { + range = current_args.range == 1 and { current_args.line1 } + or current_args.range == 2 and { current_args.line1, current_args.line2 } + or nil, + count = (current_args.count ~= -1 and current_args.range == 0) and current_args.count or nil, + reg = current_args.reg ~= '' and current_args.reg or nil, + bang = current_args.bang or nil, + args = #current_args.fargs > 0 and current_args.fargs or nil, + mods = current_args.smods, + } + return args +end + +--- Gets the current editor dimensions. +--- Useful for positioning floating windows or calculating layout sizes. +--- +--- @return { width: number, height: number } The editor dimensions in columns and lines +--- +--- @usage +--- ```lua +--- -- Center a floating window: +--- local dims = utils.get_editor_dimensions() +--- local win_width = 80 +--- local win_height = 20 +--- local col = math.floor((dims.width - win_width) / 2) +--- local row = math.floor((dims.height - win_height) / 2) +--- +--- vim.api.nvim_open_win(bufnr, true, { +--- relative = 'editor', +--- width = win_width, +--- height = win_height, +--- col = col, +--- row = row, +--- }) +--- +--- -- Check if editor is wide enough for side-by-side layout: +--- local dims = utils.get_editor_dimensions() +--- if dims.width >= 160 then +--- -- Use side-by-side layout +--- else +--- -- Use stacked layout +--- end +--- ``` function M.get_editor_dimensions() return { width = vim.go.columns, height = vim.go.lines } end return M diff --git a/shell.nix b/shell.nix index aecdea1..d172772 100644 --- a/shell.nix +++ b/shell.nix @@ -1,10 +1,10 @@ { pkgs ? import - # nixpkgs-unstable (neovim@0.11.2): + # nixos-25.05 (neovim@0.11.2): (fetchTarball { - url = "https://github.com/nixos/nixpkgs/archive/e4b09e47ace7d87de083786b404bf232eb6c89d8.tar.gz"; - sha256 = "1a2qvp2yz8j1jcggl1yvqmdxicbdqq58nv7hihmw3bzg9cjyqm26"; + url = "https://github.com/nixos/nixpkgs/archive/32a4e87942101f1c9f9865e04dc3ddb175f5f32e.tar.gz"; + sha256 = "1jvflnbrxa8gjxkwjq6kdpdzgwp0hs59h9l3xjasksv0v7xlwykz"; }) { }, }: @@ -19,5 +19,6 @@ pkgs.mkShell { pkgs.lua51Packages.nlua pkgs.neovim pkgs.stylua + pkgs.watchexec ]; } diff --git a/spec/range_spec.lua b/spec/range_spec.lua index f30dd40..3c3079b 100644 --- a/spec/range_spec.lua +++ b/spec/range_spec.lua @@ -238,6 +238,128 @@ describe('Range', function() 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() withbuf({ 'this is a {', @@ -318,18 +440,89 @@ describe('Range', function() end) end) - it('from_cmd_args', function() - local args = { range = 1 } + it('from_cmd_args: range=0', function() + 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() 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 "'<" 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.stop, b) 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) @@ -617,4 +810,86 @@ describe('Range', function() }, vim.api.nvim_buf_get_lines(b, 0, -1, false)) 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) diff --git a/spec/renderer_spec.lua b/spec/renderer_spec.lua index 8e38a2d..a4d5833 100644 --- a/spec/renderer_spec.lua +++ b/spec/renderer_spec.lua @@ -83,13 +83,13 @@ describe('Renderer', function() end) -- - -- get_pos_infos + -- get_tags_at -- it('should return no extmarks for an empty buffer', function() withbuf({}, function() 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, {}) end) end) @@ -102,12 +102,28 @@ describe('Renderer', function() 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].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, 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) @@ -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[1].tag.attributes.hl, 'HighlightGroup2') assert.are.same(pos_infos[2].tag.attributes.hl, 'HighlightGroup1') 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)