pending.nvim/lua/pending/parse.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

653 lines
16 KiB
Lua

local config = require('pending.config')
---@class pending.parse
local M = {}
---@param s string
---@return boolean
local function is_valid_date(s)
local y, m, d = s:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
if not y then
return false
end
local yn = tonumber(y) --[[@as integer]]
local mn = tonumber(m) --[[@as integer]]
local dn = tonumber(d) --[[@as integer]]
if mn < 1 or mn > 12 then
return false
end
if dn < 1 or dn > 31 then
return false
end
local t = os.time({ year = yn, month = mn, day = dn })
local check = os.date('*t', t) --[[@as osdate]]
return check.year == yn and check.month == mn and check.day == dn
end
---@param s string
---@return boolean
local function is_valid_time(s)
local h, m = s:match('^(%d%d):(%d%d)$')
if not h then
return false
end
local hn = tonumber(h) --[[@as integer]]
local mn = tonumber(m) --[[@as integer]]
return hn >= 0 and hn <= 23 and mn >= 0 and mn <= 59
end
---@param s string
---@return string|nil
local function normalize_time(s)
local h, m, period
h, m, period = s:match('^(%d+):(%d%d)([ap]m)$')
if not h then
h, period = s:match('^(%d+)([ap]m)$')
if h then
m = '00'
end
end
if not h then
h, m = s:match('^(%d%d):(%d%d)$')
end
if not h then
h, m = s:match('^(%d):(%d%d)$')
end
if not h then
h = s:match('^(%d+)$')
if h then
m = '00'
end
end
if not h then
return nil
end
local hn = tonumber(h) --[[@as integer]]
local mn = tonumber(m) --[[@as integer]]
if period then
if hn < 1 or hn > 12 then
return nil
end
if period == 'am' then
hn = hn == 12 and 0 or hn
else
hn = hn == 12 and 12 or hn + 12
end
else
if hn < 0 or hn > 23 then
return nil
end
end
if mn < 0 or mn > 59 then
return nil
end
return string.format('%02d:%02d', hn, mn)
end
---@param s string
---@return boolean
local function is_valid_datetime(s)
local date_part, time_part = s:match('^(.+)T(.+)$')
if not date_part then
return is_valid_date(s)
end
return is_valid_date(date_part) and is_valid_time(time_part)
end
---@return string
local function date_key()
return config.get().date_syntax or 'due'
end
---@return string
local function recur_key()
return config.get().recur_syntax or 'rec'
end
local weekday_map = {
sun = 1,
mon = 2,
tue = 3,
wed = 4,
thu = 5,
fri = 6,
sat = 7,
}
local month_map = {
jan = 1,
feb = 2,
mar = 3,
apr = 4,
may = 5,
jun = 6,
jul = 7,
aug = 8,
sep = 9,
oct = 10,
nov = 11,
dec = 12,
}
---@param today osdate
---@return string
local function today_str(today)
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
end
---@param date_part string
---@param time_suffix? string
---@return string
local function append_time(date_part, time_suffix)
if time_suffix then
return date_part .. 'T' .. time_suffix
end
return date_part
end
---@param name string
---@return integer?
local function month_name_to_num(name)
return month_map[name:lower():sub(1, 3)]
end
---@param fmt string
---@return string, string[]
local function input_format_to_pattern(fmt)
local fields = {}
local parts = {}
local i = 1
while i <= #fmt do
local c = fmt:sub(i, i)
if c == '%' and i < #fmt then
local spec = fmt:sub(i + 1, i + 1)
if spec == '%' then
parts[#parts + 1] = '%%'
i = i + 2
elseif spec == 'Y' then
fields[#fields + 1] = 'year'
parts[#parts + 1] = '(%d%d%d%d)'
i = i + 2
elseif spec == 'y' then
fields[#fields + 1] = 'year2'
parts[#parts + 1] = '(%d%d)'
i = i + 2
elseif spec == 'm' then
fields[#fields + 1] = 'month_num'
parts[#parts + 1] = '(%d%d?)'
i = i + 2
elseif spec == 'd' or spec == 'e' then
fields[#fields + 1] = 'day'
parts[#parts + 1] = '(%d%d?)'
i = i + 2
elseif spec == 'b' or spec == 'B' then
fields[#fields + 1] = 'month_name'
parts[#parts + 1] = '(%a+)'
i = i + 2
else
parts[#parts + 1] = vim.pesc(c)
i = i + 1
end
else
parts[#parts + 1] = vim.pesc(c)
i = i + 1
end
end
return '^' .. table.concat(parts) .. '$', fields
end
---@param date_input string
---@param time_suffix? string
---@return string?
local function try_input_date_formats(date_input, time_suffix)
local fmts = config.get().input_date_formats
if not fmts or #fmts == 0 then
return nil
end
local today = os.date('*t') --[[@as osdate]]
for _, fmt in ipairs(fmts) do
local pat, fields = input_format_to_pattern(fmt)
local caps = { date_input:match(pat) }
if caps[1] ~= nil then
local year, month, day
for j = 1, #fields do
local field = fields[j]
local val = caps[j]
if field == 'year' then
year = tonumber(val)
elseif field == 'year2' then
local y = tonumber(val) --[[@as integer]]
year = y + (y >= 70 and 1900 or 2000)
elseif field == 'month_num' then
month = tonumber(val)
elseif field == 'day' then
day = tonumber(val)
elseif field == 'month_name' then
month = month_name_to_num(val)
end
end
if month and day then
if not year then
year = today.year
if month < today.month or (month == today.month and day < today.day) then
year = year + 1
end
end
local t = os.time({ year = year, month = month, day = day })
local check = os.date('*t', t) --[[@as osdate]]
if check.year == year and check.month == month and check.day == day then
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end
end
end
end
return nil
end
---@param text string
---@return string|nil
function M.resolve_date(text)
local date_input, time_suffix = text:match('^(.+)@(.+)$')
if time_suffix then
time_suffix = normalize_time(time_suffix)
if not time_suffix then
return nil
end
else
date_input = text
end
local dt = date_input:match('^(%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d)$')
if dt then
local dp, tp = dt:match('^(.+)T(.+)$')
if is_valid_date(dp) and is_valid_time(tp) then
return dt
end
return nil
end
if is_valid_date(date_input) then
return append_time(date_input, time_suffix)
end
local lower = date_input:lower()
local today = os.date('*t') --[[@as osdate]]
if lower == 'today' or lower == 'eod' then
return append_time(today_str(today), time_suffix)
end
if lower == 'yesterday' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day - 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'tomorrow' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'sow' then
local delta = -((today.wday - 2) % 7)
return append_time(
os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]],
time_suffix
)
end
if lower == 'eow' then
local delta = (1 - today.wday) % 7
return append_time(
os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]],
time_suffix
)
end
if lower == 'som' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'eom' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]],
time_suffix
)
end
if lower == 'soq' then
local q = math.ceil(today.month / 3)
local first_month = (q - 1) * 3 + 1
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'eoq' then
local q = math.ceil(today.month / 3)
local last_month = q * 3
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]],
time_suffix
)
end
if lower == 'soy' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]],
time_suffix
)
end
if lower == 'eoy' then
return append_time(
os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]],
time_suffix
)
end
if lower == 'later' or lower == 'someday' then
return append_time(config.get().someday_date, time_suffix)
end
local n = lower:match('^%+(%d+)d$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day + (
tonumber(n) --[[@as integer]]
),
})
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%+(%d+)w$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day + (
tonumber(n) --[[@as integer]]
) * 7,
})
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%+(%d+)m$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month + (
tonumber(n) --[[@as integer]]
),
day = today.day,
})
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%-(%d+)d$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day - (
tonumber(n) --[[@as integer]]
),
})
) --[[@as string]],
time_suffix
)
end
n = lower:match('^%-(%d+)w$')
if n then
return append_time(
os.date(
'%Y-%m-%d',
os.time({
year = today.year,
month = today.month,
day = today.day - (
tonumber(n) --[[@as integer]]
) * 7,
})
) --[[@as string]],
time_suffix
)
end
local ord = lower:match('^(%d+)[snrt][tdh]$')
if ord then
local day_num = tonumber(ord) --[[@as integer]]
if day_num >= 1 and day_num <= 31 then
local m, y = today.month, today.year
if today.day >= day_num then
m = m + 1
if m > 12 then
m = 1
y = y + 1
end
end
local t = os.time({ year = y, month = m, day = day_num })
local check = os.date('*t', t) --[[@as osdate]]
if check.day == day_num then
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end
m = m + 1
if m > 12 then
m = 1
y = y + 1
end
t = os.time({ year = y, month = m, day = day_num })
check = os.date('*t', t) --[[@as osdate]]
if check.day == day_num then
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
end
return nil
end
end
local target_month = month_map[lower]
if target_month then
local y = today.year
if today.month >= target_month then
y = y + 1
end
return append_time(
os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]],
time_suffix
)
end
local target_wday = weekday_map[lower]
if target_wday then
local current_wday = today.wday
local delta = (target_wday - current_wday) % 7
return append_time(
os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + delta })
) --[[@as string]],
time_suffix
)
end
return try_input_date_formats(date_input, time_suffix)
end
---@param text string
---@return string description
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
function M.body(text)
local tokens = {}
for token in text:gmatch('%S+') do
table.insert(tokens, token)
end
local metadata = {}
local i = #tokens
local dk = date_key()
local rk = recur_key()
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$'
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
while i >= 1 do
local token = tokens[i]
local due_val = token:match(date_pattern_strict)
if due_val then
if metadata.due then
break
end
if not is_valid_datetime(due_val) then
break
end
metadata.due = due_val
i = i - 1
else
local raw_val = token:match(date_pattern_any)
if raw_val then
if metadata.due then
break
end
local resolved = M.resolve_date(raw_val)
if not resolved then
break
end
metadata.due = resolved
i = i - 1
else
local cat_val = token:match('^cat:(%S+)$')
if cat_val then
if metadata.cat then
break
end
metadata.cat = cat_val
i = i - 1
else
local rec_val = token:match(rec_pattern)
if rec_val then
if metadata.rec then
break
end
local recur = require('pending.recur')
local raw_spec = rec_val
if raw_spec:sub(1, 1) == '!' then
metadata.rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
break
end
metadata.rec = raw_spec
i = i - 1
else
break
end
end
end
end
end
local desc_tokens = {}
for j = 1, i do
table.insert(desc_tokens, tokens[j])
end
local description = table.concat(desc_tokens, ' ')
return description, metadata
end
---@param text string
---@return string description
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
function M.command_add(text)
local cat_prefix = text:match('^(%S.-):%s')
if cat_prefix then
local first_char = cat_prefix:sub(1, 1)
if first_char == first_char:upper() and first_char ~= first_char:lower() then
local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$')
if rest then
local desc, meta = M.body(rest)
meta.cat = meta.cat or cat_prefix
return desc, meta
end
end
end
return M.body(text)
end
---@param due string
---@return boolean
function M.is_overdue(due)
local now = os.date('*t') --[[@as osdate]]
local today = os.date('%Y-%m-%d') --[[@as string]]
local date_part, time_part = due:match('^(.+)T(.+)$')
if not date_part then
return due < today
end
if date_part < today then
return true
end
if date_part > today then
return false
end
local current_time = string.format('%02d:%02d', now.hour, now.min)
return time_part < current_time
end
---@param due string
---@return boolean
function M.is_today(due)
local now = os.date('*t') --[[@as osdate]]
local today = os.date('%Y-%m-%d') --[[@as string]]
local date_part, time_part = due:match('^(.+)T(.+)$')
if not date_part then
return due == today
end
if date_part ~= today then
return false
end
local current_time = string.format('%02d:%02d', now.hour, now.min)
return time_part >= current_time
end
return M