From 233ff31df1cab5d5fcd921149920eadf0b07479f Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Thu, 26 Feb 2026 15:33:24 -0500 Subject: [PATCH] feat: text objects and motions for the pending buffer Problem: the pending buffer has action-button mappings but no Vim grammar. You cannot dat to delete a task, cit to change a description, or ]] to jump to the next category header. Solution: add textobj.lua with at/it (a task / inner task), aC/iC (a category / inner category), ]]/[[ (next/prev header), and ]t/[t (next/prev task). All text objects work in operator-pending and visual modes; motions work in normal, visual, and operator-pending. Mappings are configurable via the keymaps table and exposed as mappings. --- lua/pending/config.lua | 16 ++ lua/pending/init.lua | 60 +++++++ lua/pending/textobj.lua | 360 ++++++++++++++++++++++++++++++++++++++++ plugin/pending.lua | 32 ++++ spec/textobj_spec.lua | 193 +++++++++++++++++++++ 5 files changed, 661 insertions(+) create mode 100644 lua/pending/textobj.lua create mode 100644 spec/textobj_spec.lua diff --git a/lua/pending/config.lua b/lua/pending/config.lua index ec89cb2..a8feca6 100644 --- a/lua/pending/config.lua +++ b/lua/pending/config.lua @@ -11,6 +11,14 @@ ---@field undo? string|false ---@field open_line? string|false ---@field open_line_above? string|false +---@field a_task? string|false +---@field i_task? string|false +---@field a_category? string|false +---@field i_category? string|false +---@field next_header? string|false +---@field prev_header? string|false +---@field next_task? string|false +---@field prev_task? string|false ---@class pending.Config ---@field data_path string @@ -47,6 +55,14 @@ local defaults = { undo = 'U', open_line = 'o', open_line_above = 'O', + a_task = 'at', + i_task = 'it', + a_category = 'aC', + i_category = 'iC', + next_header = ']]', + prev_header = '[[', + next_task = ']t', + prev_task = '[t', }, } diff --git a/lua/pending/init.lua b/lua/pending/init.lua index 631c0e3..87f89f2 100644 --- a/lua/pending/init.lua +++ b/lua/pending/init.lua @@ -88,6 +88,66 @@ function M._setup_buf_mappings(bufnr) vim.keymap.set('n', key --[[@as string]], fn, opts) end end + + local textobj = require('pending.textobj') + + ---@type table + local textobjs = { + a_task = { + modes = { 'o', 'x' }, + fn = textobj.a_task, + visual_fn = textobj.a_task_visual, + }, + i_task = { + modes = { 'o', 'x' }, + fn = textobj.i_task, + visual_fn = textobj.i_task_visual, + }, + a_category = { + modes = { 'o', 'x' }, + fn = textobj.a_category, + visual_fn = textobj.a_category_visual, + }, + i_category = { + modes = { 'o', 'x' }, + fn = textobj.i_category, + visual_fn = textobj.i_category_visual, + }, + } + + for name, spec in pairs(textobjs) do + local key = km[name] + if key and key ~= false then + for _, mode in ipairs(spec.modes) do + if mode == 'x' and spec.visual_fn then + vim.keymap.set(mode, key --[[@as string]], function() + spec.visual_fn(vim.v.count1) + end, opts) + else + vim.keymap.set(mode, key --[[@as string]], function() + spec.fn(vim.v.count1) + end, opts) + end + end + end + end + + ---@type table + local motions = { + next_header = textobj.next_header, + prev_header = textobj.prev_header, + next_task = textobj.next_task, + prev_task = textobj.prev_task, + } + + for name, fn in pairs(motions) do + local key = km[name] + if key and key ~= false then + vim.keymap.set({ 'n', 'x', 'o' }, key --[[@as string]], function() + fn(vim.v.count1) + end, opts) + end + end end ---@param bufnr integer diff --git a/lua/pending/textobj.lua b/lua/pending/textobj.lua new file mode 100644 index 0000000..3f33197 --- /dev/null +++ b/lua/pending/textobj.lua @@ -0,0 +1,360 @@ +local buffer = require('pending.buffer') +local config = require('pending.config') + +---@class pending.textobj +local M = {} + +---@param lnum integer +---@param meta pending.LineMeta[] +---@return string +local function get_line_from_buf(lnum, meta) + local _ = meta + local bufnr = buffer.bufnr() + if not bufnr then + return '' + end + local lines = vim.api.nvim_buf_get_lines(bufnr, lnum - 1, lnum, false) + return lines[1] or '' +end + +---@param line string +---@return integer start_col +---@return integer end_col +function M.inner_task_range(line) + local prefix_end = line:find('/') and select(2, line:find('^/%d+/- %[.%] ')) + if not prefix_end then + prefix_end = select(2, line:find('^- %[.%] ')) or 0 + end + local start_col = prefix_end + 1 + + local dk = config.get().date_syntax or 'due' + local rk = config.get().recur_syntax or 'rec' + local dk_esc = vim.pesc(dk) + local rk_esc = vim.pesc(rk) + + local desc_end = #line + local tokens = {} + for token in line:gmatch('%S+') do + table.insert(tokens, token) + end + + local i = #tokens + while i >= 1 do + local token = tokens[i] + if token:match('^' .. dk_esc .. ':%S+$') + or token:match('^cat:%S+$') + or token:match('^' .. rk_esc .. ':%S+$') + then + i = i - 1 + else + break + end + end + + if i < #tokens then + local rebuilt = {} + for j = 1, i do + table.insert(rebuilt, tokens[j]) + end + local desc_text = table.concat(rebuilt, ' ') + local search_start = prefix_end + local found = line:find(desc_text, search_start + 1, true) + if found then + desc_end = found + #desc_text - 1 + end + end + + return start_col, desc_end +end + +---@param row integer +---@param meta pending.LineMeta[] +---@return integer? header_row +---@return integer? last_row +function M.category_bounds(row, meta) + if not meta or #meta == 0 then + return nil, nil + end + + local header_row = nil + local m = meta[row] + if not m then + return nil, nil + end + + if m.type == 'header' then + header_row = row + else + for r = row, 1, -1 do + if meta[r] and meta[r].type == 'header' then + header_row = r + break + end + end + end + + if not header_row then + return nil, nil + end + + local last_row = header_row + local total = #meta + for r = header_row + 1, total do + if meta[r].type == 'header' then + break + end + last_row = r + end + + return header_row, last_row +end + +---@param count integer +---@return nil +function M.a_task(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local m = meta[row] + if not m or m.type ~= 'task' then + return + end + + local start_row = row + local end_row = row + count = math.max(1, count) + for _ = 2, count do + local next_row = end_row + 1 + if next_row > #meta then + break + end + if meta[next_row] and meta[next_row].type == 'task' then + end_row = next_row + else + break + end + end + + vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G') +end + +---@param count integer +---@return nil +function M.a_task_visual(count) + vim.cmd('normal! \27') + M.a_task(count) +end + +---@param count integer +---@return nil +function M.i_task(count) + local _ = count + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local m = meta[row] + if not m or m.type ~= 'task' then + return + end + + local line = get_line_from_buf(row, meta) + local start_col, end_col = M.inner_task_range(line) + if start_col > end_col then + return + end + + vim.api.nvim_win_set_cursor(0, { row, start_col - 1 }) + vim.cmd('normal! v') + vim.api.nvim_win_set_cursor(0, { row, end_col - 1 }) +end + +---@param count integer +---@return nil +function M.i_task_visual(count) + vim.cmd('normal! \27') + M.i_task(count) +end + +---@param count integer +---@return nil +function M.a_category(count) + local _ = count + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + local header_row, last_row = M.category_bounds(row, meta) + if not header_row or not last_row then + return + end + + local start_row = header_row + if header_row > 1 and meta[header_row - 1] and meta[header_row - 1].type == 'blank' then + start_row = header_row - 1 + end + local end_row = last_row + if last_row < #meta and meta[last_row + 1] and meta[last_row + 1].type == 'blank' then + end_row = last_row + 1 + end + + vim.cmd('normal! ' .. start_row .. 'GV' .. end_row .. 'G') +end + +---@param count integer +---@return nil +function M.a_category_visual(count) + vim.cmd('normal! \27') + M.a_category(count) +end + +---@param count integer +---@return nil +function M.i_category(count) + local _ = count + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + local header_row, last_row = M.category_bounds(row, meta) + if not header_row or not last_row then + return + end + + local first_task = nil + local last_task = nil + for r = header_row + 1, last_row do + if meta[r] and meta[r].type == 'task' then + if not first_task then + first_task = r + end + last_task = r + end + end + + if not first_task or not last_task then + return + end + + vim.cmd('normal! ' .. first_task .. 'GV' .. last_task .. 'G') +end + +---@param count integer +---@return nil +function M.i_category_visual(count) + vim.cmd('normal! \27') + M.i_category(count) +end + +---@param count integer +---@return nil +function M.next_header(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + local found = 0 + count = math.max(1, count) + for r = row + 1, #meta do + if meta[r] and meta[r].type == 'header' then + found = found + 1 + if found == count then + vim.api.nvim_win_set_cursor(0, { r, 0 }) + return + end + end + end +end + +---@param count integer +---@return nil +function M.prev_header(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + local view = buffer.current_view_name() + if view == 'priority' then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + local found = 0 + count = math.max(1, count) + for r = row - 1, 1, -1 do + if meta[r] and meta[r].type == 'header' then + found = found + 1 + if found == count then + vim.api.nvim_win_set_cursor(0, { r, 0 }) + return + end + end + end +end + +---@param count integer +---@return nil +function M.next_task(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + local found = 0 + count = math.max(1, count) + for r = row + 1, #meta do + if meta[r] and meta[r].type == 'task' then + found = found + 1 + if found == count then + vim.api.nvim_win_set_cursor(0, { r, 0 }) + return + end + end + end +end + +---@param count integer +---@return nil +function M.prev_task(count) + local meta = buffer.meta() + if not meta or #meta == 0 then + return + end + + local row = vim.api.nvim_win_get_cursor(0)[1] + local found = 0 + count = math.max(1, count) + for r = row - 1, 1, -1 do + if meta[r] and meta[r].type == 'task' then + found = found + 1 + if found == count then + vim.api.nvim_win_set_cursor(0, { r, 0 }) + return + end + end + end +end + +return M diff --git a/plugin/pending.lua b/plugin/pending.lua index bfacfec..a239c7a 100644 --- a/plugin/pending.lua +++ b/plugin/pending.lua @@ -54,3 +54,35 @@ end) vim.keymap.set('n', '(pending-open-line-above)', function() require('pending.buffer').open_line(true) end) + +vim.keymap.set({ 'o', 'x' }, '(pending-a-task)', function() + require('pending.textobj').a_task(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-i-task)', function() + require('pending.textobj').i_task(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-a-category)', function() + require('pending.textobj').a_category(vim.v.count1) +end) + +vim.keymap.set({ 'o', 'x' }, '(pending-i-category)', function() + require('pending.textobj').i_category(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-next-header)', function() + require('pending.textobj').next_header(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-header)', function() + require('pending.textobj').prev_header(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-next-task)', function() + require('pending.textobj').next_task(vim.v.count1) +end) + +vim.keymap.set({ 'n', 'x', 'o' }, '(pending-prev-task)', function() + require('pending.textobj').prev_task(vim.v.count1) +end) diff --git a/spec/textobj_spec.lua b/spec/textobj_spec.lua new file mode 100644 index 0000000..0d05c47 --- /dev/null +++ b/spec/textobj_spec.lua @@ -0,0 +1,193 @@ +require('spec.helpers') + +local config = require('pending.config') + +describe('textobj', function() + local textobj = require('pending.textobj') + + before_each(function() + vim.g.pending = nil + config.reset() + end) + + after_each(function() + vim.g.pending = nil + config.reset() + end) + + describe('inner_task_range', function() + it('returns description range for task with id prefix', function() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries') + assert.are.equal(11, s) + assert.are.equal(23, e) + end) + + it('returns description range for task without id prefix', function() + local s, e = textobj.inner_task_range('- [ ] Buy groceries') + assert.are.equal(7, s) + assert.are.equal(19, e) + end) + + it('excludes trailing due: token', function() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries due:2026-03-15') + assert.are.equal(11, s) + assert.are.equal(23, e) + end) + + it('excludes trailing cat: token', function() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries cat:Errands') + assert.are.equal(11, s) + assert.are.equal(23, e) + end) + + it('excludes trailing rec: token', function() + local s, e = textobj.inner_task_range('/1/- [ ] Take out trash rec:weekly') + assert.are.equal(11, s) + assert.are.equal(25, e) + end) + + it('excludes multiple trailing metadata tokens', function() + local s, e = textobj.inner_task_range('/1/- [ ] Buy milk due:2026-03-15 cat:Errands rec:weekly') + assert.are.equal(11, s) + assert.are.equal(18, e) + end) + + it('handles priority checkbox', function() + local s, e = textobj.inner_task_range('/1/- [!] Important task') + assert.are.equal(11, s) + assert.are.equal(24, e) + end) + + it('handles done checkbox', function() + local s, e = textobj.inner_task_range('/1/- [x] Finished task') + assert.are.equal(11, s) + assert.are.equal(23, e) + end) + + it('handles multi-digit task ids', function() + local s, e = textobj.inner_task_range('/123/- [ ] Some task') + assert.are.equal(13, s) + assert.are.equal(21, e) + end) + + it('does not strip non-metadata tokens', function() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner') + assert.are.equal(11, s) + assert.are.equal(34, e) + end) + + it('stops stripping at first non-metadata token from right', function() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner due:2026-03-15') + assert.are.equal(11, s) + assert.are.equal(34, e) + end) + + it('respects custom date_syntax', function() + vim.g.pending = { date_syntax = 'by' } + config.reset() + local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries by:2026-03-15') + assert.are.equal(11, s) + assert.are.equal(23, e) + end) + + it('respects custom recur_syntax', function() + vim.g.pending = { recur_syntax = 'repeat' } + config.reset() + local s, e = textobj.inner_task_range('/1/- [ ] Take trash repeat:weekly') + assert.are.equal(11, s) + assert.are.equal(20, e) + end) + + it('handles task with only metadata after description', function() + local s, e = textobj.inner_task_range('/1/- [ ] X due:tomorrow') + assert.are.equal(11, s) + assert.are.equal(11, e) + end) + end) + + describe('category_bounds', function() + it('returns header and last row for single category', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + { type = 'task', id = 2 }, + } + local h, l = textobj.category_bounds(2, meta) + assert.are.equal(1, h) + assert.are.equal(3, l) + end) + + it('returns bounds for first category with trailing blank', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + { type = 'blank' }, + { type = 'header', category = 'Personal' }, + { type = 'task', id = 2 }, + } + local h, l = textobj.category_bounds(2, meta) + assert.are.equal(1, h) + assert.are.equal(2, l) + end) + + it('returns bounds for second category', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + { type = 'blank' }, + { type = 'header', category = 'Personal' }, + { type = 'task', id = 2 }, + { type = 'task', id = 3 }, + } + local h, l = textobj.category_bounds(5, meta) + assert.are.equal(4, h) + assert.are.equal(6, l) + end) + + it('returns bounds when cursor is on header', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + } + local h, l = textobj.category_bounds(1, meta) + assert.are.equal(1, h) + assert.are.equal(2, l) + end) + + it('returns nil for blank line with no preceding header', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'blank' }, + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + } + local h, l = textobj.category_bounds(1, meta) + assert.is_nil(h) + assert.is_nil(l) + end) + + it('returns nil for empty meta', function() + local h, l = textobj.category_bounds(1, {}) + assert.is_nil(h) + assert.is_nil(l) + end) + + it('includes blank between header and next header in bounds', function() + ---@type pending.LineMeta[] + local meta = { + { type = 'header', category = 'Work' }, + { type = 'task', id = 1 }, + { type = 'blank' }, + { type = 'header', category = 'Home' }, + { type = 'task', id = 2 }, + } + local h, l = textobj.category_bounds(1, meta) + assert.are.equal(1, h) + assert.are.equal(3, l) + end) + end) +end)