pending.nvim/lua/pending/textobj.lua
Barrett Ruth 710cf562c9 fix(diff): preserve due/rec when absent from buffer line (#68)
* fix(diff): preserve due/rec when absent from buffer line

Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil`
whenever those fields aren't present as inline tokens in the buffer line.
Because metadata is rendered as virtual text (never in the line text),
every description edit silently clears due dates and recurrence rules.

Solution: Only update `due`, `recur`, and `recur_mode` in the existing-
task branch when the parsed entry actually contains them (non-nil). Users
can still set/change these inline by typing `due:<date>` or `rec:<rule>`;
clearing them requires `:Pending edit <id> -due`.

* refactor: remove project-local store discovery

Problem: `store.resolve_path()` searched upward for `.pending.json`,
silently splitting task data across multiple files depending on CWD.

Solution: `resolve_path()` now always returns `config.get().data_path`.
Remove `M.init()` and the `:Pending init` command and tab-completion
entry. Remove the project-local health message.

* refactor: extract log.lua, standardise [pending.nvim]: prefix

Problem: Notifications were scattered across files using bare
`vim.notify` with inconsistent `pending.nvim: ` prefixes, and the
`debug` guard in `textobj.lua` and `init.lua` was duplicated inline.

Solution: Add `lua/pending/log.lua` with `info`, `warn`, `error`, and
`debug` functions (prefix `[pending.nvim]: `). `log.debug` only fires
when `config.debug = true` or the optional `override` param is `true`.
Replace all `vim.notify` callsites and remove inline debug guards.

* feat(parse): configurable input date formats

Problem: `due:` only accepted ISO `YYYY-MM-DD` and built-in keywords;
users expecting locale-style dates like `03/15/2026` or `15-Mar-2026`
had no way to configure alternative input formats.

Solution: Add `input_date_formats` config field (string[]). Each entry
is a strftime-like format string supporting `%Y`, `%y`, `%m`, `%d`,
`%e`, `%b`, `%B`. Formats are tried in order after built-in keywords
fail. When no year specifier is present the current or next year is
inferred. Update vimdoc and add 8 parse_spec tests.
2026-03-05 12:46:54 -05:00

383 lines
8.4 KiB
Lua

local buffer = require('pending.buffer')
local config = require('pending.config')
local log = require('pending.log')
---@class pending.textobj
local M = {}
---@param ... any
---@return nil
local function dbg(...)
log.debug(string.format(...))
end
---@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_pat = '^' .. vim.pesc(dk) .. ':%S+$'
local rk_pat = '^' .. vim.pesc(rk) .. ':%S+$'
local rest = line:sub(start_col)
local words = {}
for word in rest:gmatch('%S+') do
table.insert(words, word)
end
local i = #words
while i >= 1 do
local word = words[i]
if word:match(dk_pat) or word:match('^cat:%S+$') or word:match(rk_pat) then
i = i - 1
else
break
end
end
if i < 1 then
return start_col, start_col
end
local desc = table.concat(words, ' ', 1, i)
local end_col = start_col + #desc - 1
return start_col, end_col
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]
dbg('next_header: cursor=%d, meta_len=%d, view=%s', row, #meta, view or 'nil')
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
dbg(
'next_header: found header at row=%d, cat=%s, found=%d/%d',
r,
meta[r].category or '?',
found,
count
)
if found == count then
vim.api.nvim_win_set_cursor(0, { r, 0 })
dbg('next_header: cursor set to row=%d, actual=%d', r, vim.api.nvim_win_get_cursor(0)[1])
return
end
else
dbg('next_header: row=%d type=%s', r, meta[r] and meta[r].type or 'nil')
end
end
dbg('next_header: no header found after row=%d', row)
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]
dbg('prev_header: cursor=%d, meta_len=%d', row, #meta)
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
dbg(
'prev_header: found header at row=%d, cat=%s, found=%d/%d',
r,
meta[r].category or '?',
found,
count
)
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]
dbg('next_task: cursor=%d, meta_len=%d', row, #meta)
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
dbg('next_task: jumping to row=%d', r)
vim.api.nvim_win_set_cursor(0, { r, 0 })
return
end
end
end
dbg('next_task: no task found after row=%d', row)
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]
dbg('prev_task: cursor=%d, meta_len=%d', row, #meta)
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
dbg('prev_task: jumping to row=%d', r)
vim.api.nvim_win_set_cursor(0, { r, 0 })
return
end
end
end
dbg('prev_task: no task found before row=%d', row)
end
return M