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/lua/u/buffer.lua b/lua/u/buffer.lua index 0897a4f..410d1d3 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 diff --git a/lua/u/range.lua b/lua/u/range.lua index 69406dd..5a96b51 100644 --- a/lua/u/range.lua +++ b/lua/u/range.lua @@ -91,14 +91,12 @@ function Range.from_marks(lpos, rpos) end --- @param bufnr number ---- @param id number -function Range.from_extmark(bufnr, id) - local mode = 'v' - +--- @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(vim.api.nvim_buf_get_extmark_by_id(bufnr, NS, id, { details = true })) + local start_row0, start_col0, details = unpack(extmark) + local mode = 'v' 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) @@ -651,7 +649,14 @@ end function ExtmarkRange.new(bufnr, id) return setmetatable({ bufnr = bufnr, id = id }, ExtmarkRange) end -function ExtmarkRange:range() return Range.from_extmark(self.bufnr, self.id) 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 diff --git a/lua/u/renderer.lua b/lua/u/renderer.lua index 1873ecc..8696be8 100644 --- a/lua/u/renderer.lua +++ b/lua/u/renderer.lua @@ -1,12 +1,23 @@ +local Signal = require('u.tracker').Signal + 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 } + +--- @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 { @@ -25,14 +36,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 @@ -64,6 +80,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 -- }}} @@ -199,7 +221,7 @@ function Renderer:render(tree) -- {{{ self.changedtick = changedtick end - --- @type RendererExtmark[] + --- @type u.renderer.RendererExtmark[] local extmarks = {} --- @type string[] @@ -214,31 +236,28 @@ 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', + mode, lhs, - function() return self:_expr_map_callback('n', lhs) end, + function() return self:_expr_map_callback(mode, lhs) 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, -- }}} } @@ -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,20 +353,54 @@ function Renderer:_expr_map_callback(mode, lhs) -- {{{ return cancel and '' or lhs end -- }}} +function Renderer:_on_text_changed() + --- @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_pos_infos { l, c } + for _, pos_info in ipairs(pos_infos) do + local extmark_inf = pos_info.extmark + local tag = pos_info.tag + if tag.attributes.signal and getmetatable(tag.attributes.signal) == Signal then + --- @type u.Signal + local signal = tag.attributes.signal + + 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 + + if start_row0 == end_row0 and start_col0 == end_col0 then + -- Invalid extmark + else + local lines = vim.fn.getregion( + { self.bufnr, start_row0 + 1, start_col0 + 1 }, + { self.bufnr, end_row0 + 1, end_col0 }, + { type = 'v' } + ) + local text = table.concat(lines, '\n') + if text ~= signal:get() then signal:schedule_set(text) end + end + end + end +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; }[] +--- @return { extmark: u.renderer.RendererExtmark; tag: u.renderer.Tag; }[] function Renderer:get_pos_infos(pos0) -- {{{ local cursor_line0, cursor_col0 = pos0[1], pos0[2] -- 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[] + --- @type u.renderer.RendererExtmark[] local intersecting_extmarks = vim .iter( vim.api.nvim_buf_get_extmarks( @@ -357,7 +411,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,7 +422,7 @@ function Renderer:get_pos_infos(pos0) -- {{{ end return { id = id, start = start, stop = stop, opts = details } end) - --- @param ext RendererExtmark + --- @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] @@ -384,8 +438,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 +461,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 @@ -423,7 +477,7 @@ end -- }}} -- }}} -- TreeBuilder {{{ ---- @class u.TreeBuilder +--- @class u.renderer.TreeBuilder --- @field private nodes u.renderer.Node[] local TreeBuilder = {} TreeBuilder.__index = TreeBuilder @@ -435,7 +489,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,7 +498,7 @@ 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) @@ -452,7 +506,7 @@ function TreeBuilder:put_h(name, attributes, children) end --- @param fn fun(TreeBuilder): any ---- @return u.TreeBuilder +--- @return u.renderer.TreeBuilder function TreeBuilder:nest(fn) local nested_writer = TreeBuilder.new() fn(nested_writer) diff --git a/lua/u/utils.lua b/lua/u/utils.lua index e60933b..b301d40 100644 --- a/lua/u/utils.lua +++ b/lua/u/utils.lua @@ -4,17 +4,63 @@ local M = {} -- Types -- ---- @alias QfItem { col: number, filename: string, kind: string, lnum: number, text: string } ---- @alias KeyMaps table } --- luacheck: ignore ---- @alias RawCmdArgs { args: string; bang: boolean; count: number; fargs: string[]; line1: number; line2: number; mods: string; name: string; range: 0|1|2; reg: string; smods: any } --- 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 @@ -23,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' @@ -50,20 +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 ---- @param current_args RawCmdArgs -function M.create_forward_cmd_args(current_args) - local args = { args = current_args.fargs } - if current_args.range == 1 then - args.range = { current_args.line1 } - elseif current_args.range == 2 then - args.range = { current_args.line1, current_args.line2 } - 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.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 ccf3e7f..e47a675 100644 --- a/shell.nix +++ b/shell.nix @@ -19,5 +19,6 @@ pkgs.mkShell { pkgs.lua51Packages.nlua pkgs.neovim pkgs.stylua + pkgs.watchexec ]; }