Compare commits

..

No commits in common. "e816e6fb7e145e12fa22942fecf52db48d8ba176" and "0b0b64fc3d3f02d8e9ca1d72b83999f3f64f8b88" have entirely different histories.

12 changed files with 90 additions and 1088 deletions

View file

@ -140,9 +140,9 @@ COMMANDS *pending-commands*
:Pending add Work: standup due:tomorrow rec:weekdays
:Pending add Buy milk due:fri +!!
<
`+!`, `+!!`, or `+!!!` tokens anywhere in the text set the priority
level (capped at `max_priority`). If the buffer is currently open it
is re-rendered after the add.
Trailing `+!`, `+!!`, or `+!!!` tokens set the priority level (capped
at `max_priority`). If the buffer is currently open it is re-rendered
after the add.
*:Pending-archive*
:Pending archive [{duration}]
@ -348,7 +348,6 @@ Default buffer-local keys: ~
`gw` Toggle work-in-progress status (`wip`)
`gb` Toggle blocked status (`blocked`)
`g/` Toggle cancelled status (`cancelled`)
`ge` Open markdown detail buffer for task notes (`edit_notes`)
`gf` Prompt for filter predicates (`filter`)
`<Tab>` Switch between category / queue view (`view`)
`gz` Undo the last `:w` save (`undo`)
@ -488,12 +487,6 @@ old keys to `false`: >lua
Decrement the priority level for the task under the cursor, clamped
at 0. Default key: `<C-x>`.
*<Plug>(pending-edit-notes)*
<Plug>(pending-edit-notes)
Open the markdown detail buffer for the task under the cursor.
Shows a read-only metadata header and editable notes below a `---`
separator. Press `q` to return to the task list. Default key: `ge`.
*<Plug>(pending-open-line)*
<Plug>(pending-open-line)
Insert a correctly-formatted blank task line below the cursor.
@ -564,29 +557,6 @@ Queue view: ~ *pending-view-queue*
virtual text so tasks remain identifiable across categories. The
buffer is named `pending://queue`.
==============================================================================
DETAIL BUFFER *pending-detail-buffer*
Press `ge` (or `keymaps.edit_notes`) on a task to open a markdown detail
buffer named `pending://task/<id>`. The buffer replaces the task list in
the same split.
Layout: ~
Line 1: `# <description>` (task description as heading)
Lines 2-3: Read-only metadata (status, priority, category, due,
recurrence) rendered as virtual text overlays
Line 4: `---` separator
Line 5+: Free-form markdown notes (editable)
The metadata header is not editable — it is rendered via extmarks on
empty buffer lines. To change metadata, return to the task list and use
the normal keymaps or `:Pending edit`.
Write (`:w`) saves the notes content (everything below the `---`
separator) to the `notes` field in the task store. Press `q` to return
to the task list.
==============================================================================
FILTERS *pending-filters*
@ -638,8 +608,8 @@ task data.
==============================================================================
INLINE METADATA *pending-metadata*
Metadata tokens may appear anywhere in a task line. On save, tokens are
extracted from any position and the remaining words form the description.
Metadata tokens may be appended to any task line before saving. Tokens are
parsed from the right and consumed until a non-metadata token is reached.
Supported tokens: ~
@ -663,8 +633,9 @@ On `:w`, the description becomes `Buy milk`, the due date is stored as
`2026-03-15` and rendered as right-aligned virtual text, and the task is
placed under the `Errands` category header.
Only the first occurrence of each metadata type is consumed — duplicate
tokens are silently dropped.
Parsing stops at the first token that is not a recognised metadata token.
Repeated tokens of the same type also stop parsing — only one `due:`, one
`cat:`, and one `rec:` per task line are consumed.
Omnifunc completion is available for `due:`, `cat:`, and `rec:` token types.
In insert mode, type the token prefix and press `<C-x><C-o>` to see
@ -818,7 +789,6 @@ loads: >lua
wip = 'gw',
blocked = 'gb',
cancelled = 'g/',
edit_notes = 'ge',
},
sync = {
gcal = {},
@ -1681,7 +1651,6 @@ Task fields: ~
{entry} (string) ISO 8601 UTC timestamp of creation.
{modified} (string) ISO 8601 UTC timestamp of last modification.
{end} (string) ISO 8601 UTC timestamp of completion or deletion.
{notes} (string) Free-form markdown notes (from detail buffer).
{order} (integer) Relative ordering within a category.
Any field not in the list above is preserved in `_extra` and written back on

View file

@ -584,7 +584,6 @@ local function setup_highlights()
vim.api.nvim_set_hl(0, 'PendingFilter', { link = 'DiagnosticWarn', default = true })
vim.api.nvim_set_hl(0, 'PendingForge', { link = 'DiagnosticInfo', default = true })
vim.api.nvim_set_hl(0, 'PendingForgeClosed', { link = 'Comment', default = true })
vim.api.nvim_set_hl(0, 'PendingDetailMeta', { link = 'Comment', default = true })
end
---@return string
@ -806,329 +805,4 @@ function M.open()
return task_bufnr
end
local ns_detail = vim.api.nvim_create_namespace('pending_detail')
local DETAIL_SEPARATOR = '---'
---@type integer?
local _detail_bufnr = nil
---@type integer?
local _detail_task_id = nil
---@return integer?
function M.detail_bufnr()
return _detail_bufnr
end
---@return integer?
function M.detail_task_id()
return _detail_task_id
end
local VALID_STATUSES = {
pending = true,
done = true,
wip = true,
blocked = true,
cancelled = true,
}
---@param task pending.Task
---@return string[]
local function build_detail_frontmatter(task)
local lines = {}
table.insert(lines, 'Status: ' .. (task.status or 'pending'))
table.insert(lines, 'Priority: ' .. (task.priority or 0))
if task.category then
table.insert(lines, 'Category: ' .. task.category)
end
if task.due then
table.insert(lines, 'Due: ' .. task.due)
end
if task.recur then
local recur_val = task.recur
if task.recur_mode == 'completion' then
recur_val = '!' .. recur_val
end
table.insert(lines, 'Recur: ' .. recur_val)
end
return lines
end
---@param bufnr integer
---@param sep_row integer
---@return nil
local function apply_detail_extmarks(bufnr, sep_row)
vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1)
for i = 1, sep_row - 1 do
vim.api.nvim_buf_set_extmark(bufnr, ns_detail, i, 0, {
end_row = i,
end_col = #(vim.api.nvim_buf_get_lines(bufnr, i, i + 1, false)[1] or ''),
hl_group = 'PendingDetailMeta',
})
end
end
---@param task_id integer
---@return integer? bufnr
function M.open_detail(task_id)
if not _store then
return nil
end
if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then
if _detail_task_id == task_id then
return _detail_bufnr
end
vim.api.nvim_buf_delete(_detail_bufnr, { force = true })
_detail_bufnr = nil
_detail_task_id = nil
end
local task = _store:get(task_id)
if not task then
log.warn('task not found: ' .. task_id)
return nil
end
setup_highlights()
local bufnr = vim.api.nvim_create_buf(true, false)
vim.api.nvim_buf_set_name(bufnr, 'pending://task/' .. task_id)
vim.bo[bufnr].buftype = 'acwrite'
vim.bo[bufnr].filetype = 'markdown'
vim.bo[bufnr].swapfile = false
local lines = { '# ' .. task.description }
local fm = build_detail_frontmatter(task)
for _, fl in ipairs(fm) do
table.insert(lines, fl)
end
table.insert(lines, DETAIL_SEPARATOR)
local notes = task.notes or ''
if notes ~= '' then
for note_line in (notes .. '\n'):gmatch('(.-)\n') do
table.insert(lines, note_line)
end
else
table.insert(lines, '')
end
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines)
vim.bo[bufnr].modified = false
local sep_row = #fm + 1
apply_detail_extmarks(bufnr, sep_row)
local winid = task_winid
if winid and vim.api.nvim_win_is_valid(winid) then
vim.api.nvim_win_set_buf(winid, bufnr)
end
vim.wo[winid].conceallevel = 0
vim.wo[winid].foldmethod = 'manual'
vim.wo[winid].foldenable = false
_detail_bufnr = bufnr
_detail_task_id = task_id
local cursor_row = sep_row + 2
local total = vim.api.nvim_buf_line_count(bufnr)
if cursor_row > total then
cursor_row = total
end
pcall(vim.api.nvim_win_set_cursor, winid, { cursor_row, 0 })
return bufnr
end
---@return nil
function M.close_detail()
if _detail_bufnr and vim.api.nvim_buf_is_valid(_detail_bufnr) then
vim.api.nvim_buf_delete(_detail_bufnr, { force = true })
end
_detail_bufnr = nil
_detail_task_id = nil
if task_winid and vim.api.nvim_win_is_valid(task_winid) then
if task_bufnr and vim.api.nvim_buf_is_valid(task_bufnr) then
vim.api.nvim_win_set_buf(task_winid, task_bufnr)
set_win_options(task_winid)
M.render(task_bufnr)
end
end
end
---@param lines string[]
---@return integer? sep_row
---@return pending.DetailFields? fields
---@return string? err
local function parse_detail_frontmatter(lines)
local parse = require('pending.parse')
local recur = require('pending.recur')
local cfg = config.get()
local sep_row = nil
for i, line in ipairs(lines) do
if line == DETAIL_SEPARATOR then
sep_row = i
break
end
end
if not sep_row then
return nil, nil, 'missing separator (---)'
end
local desc = lines[1] and lines[1]:match('^# (.+)$')
if not desc or desc:match('^%s*$') then
return nil, nil, 'missing or empty title (first line must be # <title>)'
end
---@class pending.DetailFields
---@field description string
---@field status pending.TaskStatus
---@field priority integer
---@field category? string|userdata
---@field due? string|userdata
---@field recur? string|userdata
---@field recur_mode? pending.RecurMode|userdata
local fields = {
description = desc,
status = 'pending',
priority = 0,
category = vim.NIL,
due = vim.NIL,
recur = vim.NIL,
recur_mode = vim.NIL,
}
local seen = {} ---@type table<string, boolean>
for i = 2, sep_row - 1 do
local line = lines[i]
if line:match('^%s*$') then
goto continue
end
local key, val = line:match('^(%S+):%s*(.*)$')
if not key then
return nil, nil, 'invalid frontmatter line: ' .. line
end
key = key:lower()
if seen[key] then
return nil, nil, 'duplicate field: ' .. key
end
seen[key] = true
if key == 'status' then
val = val:lower()
if not VALID_STATUSES[val] then
return nil, nil, 'invalid status: ' .. val
end
fields.status = val --[[@as pending.TaskStatus]]
elseif key == 'priority' then
local n = tonumber(val)
if not n or n ~= math.floor(n) or n < 0 then
return nil, nil, 'invalid priority: ' .. val .. ' (must be integer >= 0)'
end
local max = cfg.max_priority or 3
if n > max then
return nil, nil, 'invalid priority: ' .. val .. ' (max is ' .. max .. ')'
end
fields.priority = n --[[@as integer]]
elseif key == 'category' then
if val == '' then
return nil, nil, 'empty category value'
end
fields.category = val
elseif key == 'due' then
if val == '' then
return nil, nil, 'empty due value (remove the line to clear)'
end
local resolved = parse.resolve_date(val)
if resolved then
fields.due = resolved
elseif
val:match('^%d%d%d%d%-%d%d%-%d%d$') or val:match('^%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d$')
then
fields.due = val
else
return nil, nil, 'invalid due date: ' .. val
end
elseif key == 'recur' then
if val == '' then
return nil, nil, 'empty recur value (remove the line to clear)'
end
local raw_spec = val
local rec_mode = nil
if raw_spec:sub(1, 1) == '!' then
rec_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
return nil, nil, 'invalid recurrence: ' .. val
end
fields.recur = raw_spec
fields.recur_mode = rec_mode or vim.NIL
else
return nil, nil, 'unknown field: ' .. key
end
::continue::
end
return sep_row, fields, nil
end
---@return nil
function M.save_detail()
if not _detail_bufnr or not _detail_task_id or not _store then
return
end
local task = _store:get(_detail_task_id)
if not task then
log.warn('task was deleted')
M.close_detail()
return
end
local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false)
local sep_row, fields, err = parse_detail_frontmatter(lines)
if err then
log.error(err)
return
end
---@cast sep_row integer
---@cast fields pending.DetailFields
local notes_text = ''
if sep_row < #lines then
local note_lines = {}
for i = sep_row + 1, #lines do
table.insert(note_lines, lines[i])
end
notes_text = table.concat(note_lines, '\n')
notes_text = notes_text:gsub('%s+$', '')
end
local update = {
description = fields.description,
status = fields.status,
priority = fields.priority,
category = fields.category,
due = fields.due,
recur = fields.recur,
recur_mode = fields.recur_mode,
}
if notes_text == '' then
update.notes = vim.NIL
else
update.notes = notes_text
end
_store:update(_detail_task_id, update)
_store:save()
vim.bo[_detail_bufnr].modified = false
apply_detail_extmarks(_detail_bufnr, sep_row - 1)
end
M._parse_detail_frontmatter = parse_detail_frontmatter
M._build_detail_frontmatter = build_detail_frontmatter
return M

View file

@ -82,7 +82,6 @@
---@field priority_up_visual? string|false
---@field priority_down_visual? string|false
---@field cancelled? string|false
---@field edit_notes? string|false
---@class pending.CategoryViewConfig
---@field order? string[]
@ -164,7 +163,6 @@ local defaults = {
wip = 'gw',
blocked = 'gb',
cancelled = 'g/',
edit_notes = 'ge',
priority_up = '<C-a>',
priority_down = '<C-x>',
priority_up_visual = 'g<C-a>',

View file

@ -59,7 +59,7 @@ function M.check()
end
local sync_paths = vim.fn.globpath(vim.o.runtimepath, 'lua/pending/sync/*.lua', false, true)
if #sync_paths == 0 and vim.tbl_isempty(require('pending').registered_backends()) then
if #sync_paths == 0 then
vim.health.info('No sync backends found')
else
for _, path in ipairs(sync_paths) do
@ -70,12 +70,6 @@ function M.check()
backend.health()
end
end
for rname, rbackend in pairs(require('pending').registered_backends()) do
if type(rbackend.health) == 'function' then
vim.health.start('pending.nvim: sync/' .. rname)
rbackend.health()
end
end
end
end

View file

@ -401,9 +401,6 @@ function M._setup_buf_mappings(bufnr)
open_line_above = function()
buffer.open_line(true)
end,
edit_notes = function()
M.open_detail()
end,
}
for name, fn in pairs(actions) do
@ -891,46 +888,6 @@ function M.toggle_status(target_status)
end
end
---@return nil
function M.open_detail()
local bufnr = buffer.bufnr()
if not bufnr then
return
end
if not require_saved() then
return
end
local row = vim.api.nvim_win_get_cursor(0)[1]
local meta = buffer.meta()
if not meta[row] or meta[row].type ~= 'task' then
return
end
local id = meta[row].id
if not id then
return
end
local detail_bufnr = buffer.open_detail(id)
if not detail_bufnr then
return
end
local group = vim.api.nvim_create_augroup('PendingDetail', { clear = true })
vim.api.nvim_create_autocmd('BufWriteCmd', {
group = group,
buffer = detail_bufnr,
callback = function()
buffer.save_detail()
end,
})
local km = require('pending.config').get().keymaps
vim.keymap.set('n', km.close or 'q', function()
vim.api.nvim_del_augroup_by_name('PendingDetail')
buffer.close_detail()
end, { buffer = detail_bufnr })
end
---@param direction 'up'|'down'
---@return nil
function M.move_task(direction)
@ -1141,60 +1098,18 @@ end
---@class pending.SyncBackend
---@field name string
---@field auth? fun(sub_action?: string): nil
---@field auth fun(): nil
---@field push? fun(): nil
---@field pull? fun(): nil
---@field sync? fun(): nil
---@field health? fun(): nil
---@type table<string, pending.SyncBackend>
local _registered_backends = {}
---@type string[]?
local _sync_backends = nil
---@type table<string, true>?
local _sync_backend_set = nil
---@param name string
---@return pending.SyncBackend?
function M.resolve_backend(name)
if _registered_backends[name] then
return _registered_backends[name]
end
local ok, mod = pcall(require, 'pending.sync.' .. name)
if ok and type(mod) == 'table' and mod.name then
return mod
end
return nil
end
---@param backend pending.SyncBackend
---@return nil
function M.register_backend(backend)
if type(backend) ~= 'table' or type(backend.name) ~= 'string' or backend.name == '' then
log.error('register_backend: backend must have a non-empty `name` field')
return
end
local builtin_ok, builtin = pcall(require, 'pending.sync.' .. backend.name)
if builtin_ok and type(builtin) == 'table' and builtin.name then
log.error('register_backend: backend `' .. backend.name .. '` already exists as a built-in')
return
end
if _registered_backends[backend.name] then
log.error('register_backend: backend `' .. backend.name .. '` is already registered')
return
end
_registered_backends[backend.name] = backend
_sync_backends = nil
_sync_backend_set = nil
end
---@return table<string, pending.SyncBackend>
function M.registered_backends()
return _registered_backends
end
---@return string[], table<string, true>
local function discover_backends()
if _sync_backends then
@ -1211,12 +1126,6 @@ local function discover_backends()
_sync_backend_set[mod.name] = true
end
end
for name, _ in pairs(_registered_backends) do
if not _sync_backend_set[name] then
table.insert(_sync_backends, name)
_sync_backend_set[name] = true
end
end
table.sort(_sync_backends)
return _sync_backends, _sync_backend_set
end
@ -1225,8 +1134,8 @@ end
---@param action? string
---@return nil
local function run_sync(backend_name, action)
local backend = M.resolve_backend(backend_name)
if not backend then
local ok, backend = pcall(require, 'pending.sync.' .. backend_name)
if not ok then
log.error('Unknown sync backend: ' .. backend_name)
return
end
@ -1591,8 +1500,8 @@ function M.auth(args)
local backends_list = discover_backends()
local auth_backends = {}
for _, name in ipairs(backends_list) do
local mod = M.resolve_backend(name)
if mod and type(mod.auth) == 'function' then
local ok, mod = pcall(require, 'pending.sync.' .. name)
if ok and type(mod.auth) == 'function' then
table.insert(auth_backends, { name = name, mod = mod })
end
end

View file

@ -536,6 +536,7 @@ function M.body(text)
end
local metadata = {}
local i = #tokens
local ck = category_key()
local dk = date_key()
local rk = recur_key()
@ -543,82 +544,84 @@ function M.body(text)
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+)$'
local desc_tokens = {}
local forge_tokens = {}
for _, token in ipairs(tokens) do
local consumed = false
local forge_indices = {}
while i >= 1 do
local token = tokens[i]
local due_val = token:match(date_pattern_strict)
if due_val and is_valid_datetime(due_val) then
if not metadata.due then
metadata.due = due_val
if due_val then
if metadata.due then
break
end
consumed = true
end
if not consumed then
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 resolved then
if not metadata.due then
metadata.due = resolved
end
consumed = true
if not resolved then
break
end
end
end
if not consumed then
local cat_val = token:match(cat_pattern)
if cat_val then
if not metadata.category then
metadata.category = cat_val
end
consumed = true
end
end
if not consumed then
local pri_bangs = token:match('^%+(!+)$')
if pri_bangs then
if not metadata.priority then
local max = config.get().max_priority or 3
metadata.priority = math.min(#pri_bangs, max)
end
consumed = true
end
end
if not consumed then
local rec_val = token:match(rec_pattern)
if rec_val then
local recur = require('pending.recur')
local raw_spec = rec_val
if raw_spec:sub(1, 1) == '!' then
raw_spec = raw_spec:sub(2)
end
if recur.validate(raw_spec) then
if not metadata.recur then
metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil
metadata.recur = raw_spec
end
consumed = true
end
end
end
if not consumed then
if forge.parse_ref(token) then
table.insert(forge_tokens, token)
metadata.due = resolved
i = i - 1
else
table.insert(desc_tokens, token)
local cat_val = token:match(cat_pattern)
if cat_val then
if metadata.category then
break
end
metadata.category = cat_val
i = i - 1
else
local pri_bangs = token:match('^%+(!+)$')
if pri_bangs then
if metadata.priority then
break
end
local max = config.get().max_priority or 3
metadata.priority = math.min(#pri_bangs, max)
i = i - 1
else
local rec_val = token:match(rec_pattern)
if rec_val then
if metadata.recur then
break
end
local recur = require('pending.recur')
local raw_spec = rec_val
if raw_spec:sub(1, 1) == '!' then
metadata.recur_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
break
end
metadata.recur = raw_spec
i = i - 1
elseif forge.parse_ref(token) then
table.insert(forge_indices, i)
i = i - 1
else
break
end
end
end
end
end
end
for _, ft in ipairs(forge_tokens) do
table.insert(desc_tokens, ft)
local desc_tokens = {}
for j = 1, i do
table.insert(desc_tokens, tokens[j])
end
for fi = #forge_indices, 1, -1 do
table.insert(desc_tokens, tokens[forge_indices[fi]])
end
local description = table.concat(desc_tokens, ' ')

View file

@ -24,7 +24,6 @@ local config = require('pending.config')
---@field entry string
---@field modified string
---@field end? string
---@field notes? string
---@field order integer
---@field _extra? pending.TaskExtra
@ -94,7 +93,6 @@ local known_fields = {
entry = true,
modified = true,
['end'] = true,
notes = true,
order = true,
}
@ -126,9 +124,6 @@ local function task_to_table(task)
if task['end'] then
t['end'] = task['end']
end
if task.notes then
t.notes = task.notes
end
if task.order and task.order ~= 0 then
t.order = task.order
end
@ -155,7 +150,6 @@ local function table_to_task(t)
entry = t.entry,
modified = t.modified,
['end'] = t['end'],
notes = t.notes,
order = t.order or 0,
_extra = {},
}

View file

@ -341,7 +341,6 @@ function M.priority_view(tasks)
forge_ref = task._extra and task._extra._forge_ref or nil,
forge_cache = task._extra and task._extra._forge_cache or nil,
forge_spans = compute_forge_spans(task, prefix_len),
has_notes = task.notes ~= nil and task.notes ~= '',
})
end

View file

@ -304,8 +304,8 @@ end, {
if #parts == 0 or (#parts == 1 and not trailing) then
local auth_names = {}
for _, b in ipairs(pending.sync_backends()) do
local mod = pending.resolve_backend(b)
if mod and type(mod.auth) == 'function' then
local ok, mod = pcall(require, 'pending.sync.' .. b)
if ok and type(mod.auth) == 'function' then
table.insert(auth_names, b)
end
end
@ -313,8 +313,8 @@ end, {
end
local backend_name = parts[1]
if #parts == 1 or (#parts == 2 and not trailing) then
local mod = pending.resolve_backend(backend_name)
if mod and type(mod.auth_complete) == 'function' then
local ok, mod = pcall(require, 'pending.sync.' .. backend_name)
if ok and type(mod.auth_complete) == 'function' then
return filter_candidates(arg_lead, mod.auth_complete())
end
return {}
@ -328,8 +328,8 @@ end, {
if not after_backend then
return {}
end
local mod = pending.resolve_backend(matched_backend)
if not mod then
local ok, mod = pcall(require, 'pending.sync.' .. matched_backend)
if not ok then
return {}
end
local actions = {}
@ -407,10 +407,6 @@ vim.keymap.set('n', '<Plug>(pending-cancelled)', function()
require('pending').toggle_status('cancelled')
end)
vim.keymap.set('n', '<Plug>(pending-edit-notes)', function()
require('pending').open_detail()
end)
vim.keymap.set('n', '<Plug>(pending-priority-up)', function()
require('pending').increment_priority()
end)

View file

@ -1,402 +0,0 @@
require('spec.helpers')
local config = require('pending.config')
describe('detail frontmatter', function()
local buffer
local tmpdir
before_each(function()
tmpdir = vim.fn.tempname()
vim.fn.mkdir(tmpdir, 'p')
vim.g.pending = { data_path = tmpdir .. '/tasks.json' }
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
buffer = require('pending.buffer')
end)
after_each(function()
vim.fn.delete(tmpdir, 'rf')
vim.g.pending = nil
config.reset()
package.loaded['pending'] = nil
package.loaded['pending.buffer'] = nil
end)
describe('build_detail_frontmatter', function()
it('renders status and priority for minimal task', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'pending',
priority = 0,
entry = '',
modified = '',
order = 0,
})
assert.are.equal(2, #lines)
assert.are.equal('Status: pending', lines[1])
assert.are.equal('Priority: 0', lines[2])
end)
it('renders all fields', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'wip',
priority = 2,
category = 'Work',
due = '2026-03-15',
recur = 'weekly',
entry = '',
modified = '',
order = 0,
})
assert.are.equal(5, #lines)
assert.are.equal('Status: wip', lines[1])
assert.are.equal('Priority: 2', lines[2])
assert.are.equal('Category: Work', lines[3])
assert.are.equal('Due: 2026-03-15', lines[4])
assert.are.equal('Recur: weekly', lines[5])
end)
it('prefixes recur with ! for completion mode', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'pending',
priority = 0,
recur = 'daily',
recur_mode = 'completion',
entry = '',
modified = '',
order = 0,
})
assert.are.equal('Recur: !daily', lines[3])
end)
it('omits optional fields when absent', function()
local lines = buffer._build_detail_frontmatter({
id = 1,
description = 'Test',
status = 'done',
priority = 1,
entry = '',
modified = '',
order = 0,
})
assert.are.equal(2, #lines)
assert.are.equal('Status: done', lines[1])
assert.are.equal('Priority: 1', lines[2])
end)
end)
describe('parse_detail_frontmatter', function()
it('parses minimal frontmatter', function()
local lines = {
'# My task',
'Status: pending',
'Priority: 0',
'---',
'some notes',
}
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(4, sep)
assert.are.equal('My task', fields.description)
assert.are.equal('pending', fields.status)
assert.are.equal(0, fields.priority)
end)
it('parses all fields', function()
local lines = {
'# Fix the bug',
'Status: wip',
'Priority: 2',
'Category: Work',
'Due: 2026-03-15',
'Recur: weekly',
'---',
}
local sep, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(7, sep)
assert.are.equal('Fix the bug', fields.description)
assert.are.equal('wip', fields.status)
assert.are.equal(2, fields.priority)
assert.are.equal('Work', fields.category)
assert.are.equal('2026-03-15', fields.due)
assert.are.equal('weekly', fields.recur)
end)
it('resolves due date keywords', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Due: tomorrow',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
local today = os.date('*t') --[[@as osdate]]
local expected = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 1 })
)
assert.are.equal(expected, fields.due)
end)
it('parses completion-mode recurrence', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Recur: !daily',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('daily', fields.recur)
assert.are.equal('completion', fields.recur_mode)
end)
it('clears optional fields when lines removed', function()
local lines = {
'# Task',
'Status: done',
'Priority: 1',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(vim.NIL, fields.category)
assert.are.equal(vim.NIL, fields.due)
assert.are.equal(vim.NIL, fields.recur)
end)
it('skips blank lines in frontmatter', function()
local lines = {
'# Task',
'Status: pending',
'',
'Priority: 0',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('pending', fields.status)
assert.are.equal(0, fields.priority)
end)
it('errors on missing separator', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing separator'))
end)
it('errors on missing title', function()
local lines = {
'',
'Status: pending',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing or empty title'))
end)
it('errors on empty title', function()
local lines = {
'# ',
'Status: pending',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('missing or empty title'))
end)
it('errors on invalid status', function()
local lines = {
'# Task',
'Status: bogus',
'Priority: 0',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid status'))
end)
it('errors on negative priority', function()
local lines = {
'# Task',
'Status: pending',
'Priority: -1',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid priority'))
end)
it('errors on non-integer priority', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 1.5',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid priority'))
end)
it('errors on priority exceeding max', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 4',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('max is 3'))
end)
it('errors on invalid due date', function()
local lines = {
'# Task',
'Status: pending',
'Priority: 0',
'Due: notadate',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid due date'))
end)
it('errors on empty due value', function()
local lines = {
'# Task',
'Due: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty due value'))
end)
it('errors on invalid recurrence', function()
local lines = {
'# Task',
'Recur: nope',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid recurrence'))
end)
it('errors on empty recur value', function()
local lines = {
'# Task',
'Recur: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty recur value'))
end)
it('errors on empty category value', function()
local lines = {
'# Task',
'Category: ',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('empty category'))
end)
it('errors on unknown field', function()
local lines = {
'# Task',
'Status: pending',
'Foo: bar',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('unknown field: foo'))
end)
it('errors on duplicate field', function()
local lines = {
'# Task',
'Status: pending',
'Status: done',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('duplicate field'))
end)
it('errors on malformed frontmatter line', function()
local lines = {
'# Task',
'not a key value pair',
'---',
}
local _, _, err = buffer._parse_detail_frontmatter(lines)
assert.truthy(err:find('invalid frontmatter line'))
end)
it('is case-insensitive for field keys', function()
local lines = {
'# Task',
'STATUS: wip',
'PRIORITY: 1',
'CATEGORY: Work',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('wip', fields.status)
assert.are.equal(1, fields.priority)
assert.are.equal('Work', fields.category)
end)
it('accepts datetime due format', function()
local lines = {
'# Task',
'Due: 2026-03-15T14:00',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('2026-03-15T14:00', fields.due)
end)
it('respects custom max_priority', function()
vim.g.pending = { data_path = tmpdir .. '/tasks.json', max_priority = 5 }
config.reset()
local lines = {
'# Task',
'Priority: 5',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal(5, fields.priority)
end)
it('updates description from title line', function()
local lines = {
'# Updated title',
'Status: pending',
'Priority: 0',
'---',
}
local _, fields, err = buffer._parse_detail_frontmatter(lines)
assert.is_nil(err)
assert.are.equal('Updated title', fields.description)
end)
end)
end)

View file

@ -48,16 +48,10 @@ describe('parse', function()
assert.are.equal('Errands', meta.category)
end)
it('first occurrence wins for duplicate keys', function()
it('stops at duplicate key', function()
local desc, meta = parse.body('Buy milk due:2026-03-15 due:2026-04-01')
assert.are.equal('Buy milk', desc)
assert.are.equal('2026-03-15', meta.due)
end)
it('drops identical duplicate metadata tokens', function()
local desc, meta = parse.body('Buy milk due:tomorrow due:tomorrow')
assert.are.equal('Buy milk', desc)
assert.are.equal(os.date('%Y-%m-%d', os.time() + 86400), meta.due)
assert.are.equal('Buy milk due:2026-03-15', desc)
assert.are.equal('2026-04-01', meta.due)
end)
it('stops at non-meta token', function()
@ -144,38 +138,6 @@ describe('parse', function()
assert.are.equal('Work', meta.category)
assert.truthy(desc:find('gl:a/b#12', 1, true))
end)
it('extracts leading metadata', function()
local desc, meta = parse.body('due:2026-03-15 Fix the bug')
assert.are.equal('Fix the bug', desc)
assert.are.equal('2026-03-15', meta.due)
end)
it('extracts metadata from the middle', function()
local desc, meta = parse.body('Fix due:2026-03-15 the bug')
assert.are.equal('Fix the bug', desc)
assert.are.equal('2026-03-15', meta.due)
end)
it('extracts multiple metadata from any position', function()
local desc, meta = parse.body('cat:Work Fix due:2026-03-15 the bug')
assert.are.equal('Fix the bug', desc)
assert.are.equal('2026-03-15', meta.due)
assert.are.equal('Work', meta.category)
end)
it('extracts all metadata types from mixed positions', function()
local today = os.date('*t') --[[@as osdate]]
local tomorrow = os.date(
'%Y-%m-%d',
os.time({ year = today.year, month = today.month, day = today.day + 1 })
)
local desc, meta = parse.body('due:tomorrow cat:Work Fix the bug +!')
assert.are.equal('Fix the bug', desc)
assert.are.equal(tomorrow, meta.due)
assert.are.equal('Work', meta.category)
assert.are.equal(1, meta.priority)
end)
end)
describe('parse.resolve_date', function()

View file

@ -124,100 +124,6 @@ describe('sync', function()
end)
end)
describe('register_backend', function()
it('registers a custom backend', function()
pending.register_backend({ name = 'custom', pull = function() end })
local set = pending.sync_backend_set()
assert.is_true(set['custom'] == true)
assert.is_true(vim.tbl_contains(pending.sync_backends(), 'custom'))
end)
it('rejects backend without name', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({})
vim.notify = orig
assert.truthy(msg and msg:find('non%-empty'))
end)
it('rejects backend with empty name', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({ name = '' })
vim.notify = orig
assert.truthy(msg and msg:find('non%-empty'))
end)
it('rejects duplicate of built-in backend', function()
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({ name = 'gcal' })
vim.notify = orig
assert.truthy(msg and msg:find('already exists'))
end)
it('rejects duplicate registered backend', function()
pending.register_backend({ name = 'dup_test', pull = function() end })
local msg
local orig = vim.notify
vim.notify = function(m, level)
if level == vim.log.levels.ERROR then
msg = m
end
end
pending.register_backend({ name = 'dup_test' })
vim.notify = orig
assert.truthy(msg and msg:find('already registered'))
end)
end)
describe('resolve_backend', function()
it('resolves built-in backend', function()
local mod = pending.resolve_backend('gcal')
assert.is_not_nil(mod)
assert.are.equal('gcal', mod.name)
end)
it('resolves registered backend', function()
local custom = { name = 'resolve_test', pull = function() end }
pending.register_backend(custom)
local mod = pending.resolve_backend('resolve_test')
assert.is_not_nil(mod)
assert.are.equal('resolve_test', mod.name)
end)
it('returns nil for unknown backend', function()
assert.is_nil(pending.resolve_backend('nonexistent_xyz'))
end)
it('dispatches command to registered backend', function()
local called = false
pending.register_backend({
name = 'cmd_test',
pull = function()
called = true
end,
})
pending.command('cmd_test pull')
assert.is_true(called)
end)
end)
describe('auto-discovery', function()
it('discovers gcal and gtasks backends', function()
local backends = pending.sync_backends()