diff --git a/lua/pending/buffer.lua b/lua/pending/buffer.lua index f65ebaa..c98ebf9 100644 --- a/lua/pending/buffer.lua +++ b/lua/pending/buffer.lua @@ -824,59 +824,46 @@ function M.detail_task_id() return _detail_task_id end ----@param bufnr integer +local VALID_STATUSES = { + pending = true, + done = true, + wip = true, + blocked = true, + cancelled = true, +} + ---@param task pending.Task ----@return nil -local function apply_detail_extmarks(bufnr, task) - vim.api.nvim_buf_clear_namespace(bufnr, ns_detail, 0, -1) - local icons = config.get().icons - local parts = {} - local status_label = task.status or 'pending' - local icon_char = icons[status_label] or icons.pending - table.insert(parts, { 'Status: [' .. icon_char .. '] ' .. status_label, 'PendingDetailMeta' }) - if task.priority and task.priority > 0 then - table.insert(parts, { ' ', 'Normal' }) - table.insert( - parts, - { 'Priority: ' .. string.rep(icons.priority, task.priority), 'PendingDetailMeta' } - ) - end - local line2 = {} +---@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(line2, { 'Category: ' .. task.category, 'PendingDetailMeta' }) + table.insert(lines, 'Category: ' .. task.category) end if task.due then - if #line2 > 0 then - table.insert(line2, { ' ', 'Normal' }) - end - local due_label = task.due - local y, mo, d = task.due:match('^(%d%d%d%d)%-(%d%d)%-(%d%d)') - if y then - local t = os.time({ - year = tonumber(y) --[[@as integer]], - month = tonumber(mo) --[[@as integer]], - day = tonumber(d) --[[@as integer]], - }) - due_label = os.date(config.get().date_format, t) --[[@as string]] - end - table.insert(line2, { 'Due: ' .. due_label, 'PendingDetailMeta' }) + table.insert(lines, 'Due: ' .. task.due) end if task.recur then - if #line2 > 0 then - table.insert(line2, { ' ', 'Normal' }) + local recur_val = task.recur + if task.recur_mode == 'completion' then + recur_val = '!' .. recur_val end - table.insert(line2, { 'Recur: ' .. task.recur, 'PendingDetailMeta' }) + table.insert(lines, 'Recur: ' .. recur_val) end - if #parts > 0 then - vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 1, 0, { - virt_text = parts, - virt_text_pos = 'overlay', - }) - end - if #line2 > 0 then - vim.api.nvim_buf_set_extmark(bufnr, ns_detail, 2, 0, { - virt_text = line2, - virt_text_pos = 'overlay', + 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 @@ -909,12 +896,12 @@ function M.open_detail(task_id) vim.bo[bufnr].filetype = 'markdown' vim.bo[bufnr].swapfile = false - local lines = { - '# ' .. task.description, - '', - '', - DETAIL_SEPARATOR, - } + 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 @@ -927,7 +914,8 @@ function M.open_detail(task_id) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, lines) vim.bo[bufnr].modified = false - apply_detail_extmarks(bufnr, task) + 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 @@ -941,8 +929,7 @@ function M.open_detail(task_id) _detail_bufnr = bufnr _detail_task_id = task_id - local separator_row = 3 - local cursor_row = separator_row + 2 + local cursor_row = sep_row + 2 local total = vim.api.nvim_buf_line_count(bufnr) if cursor_row > total then cursor_row = total @@ -969,6 +956,124 @@ function M.close_detail() 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 # )' + 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 @@ -983,16 +1088,16 @@ function M.save_detail() local lines = vim.api.nvim_buf_get_lines(_detail_bufnr, 0, -1, false) - local sep_row = nil - for i, line in ipairs(lines) do - if line == DETAIL_SEPARATOR then - sep_row = i - break - end + 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 and sep_row < #lines then + if sep_row < #lines then local note_lines = {} for i = sep_row + 1, #lines do table.insert(note_lines, lines[i]) @@ -1001,18 +1106,29 @@ function M.save_detail() 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 - _store:update(_detail_task_id, { notes = vim.NIL }) + update.notes = vim.NIL else - _store:update(_detail_task_id, { notes = notes_text }) + update.notes = notes_text end + + _store:update(_detail_task_id, update) _store:save() vim.bo[_detail_bufnr].modified = false - local updated = _store:get(_detail_task_id) - if updated then - apply_detail_extmarks(_detail_bufnr, updated) - end + 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 diff --git a/spec/detail_spec.lua b/spec/detail_spec.lua new file mode 100644 index 0000000..50f7ae7 --- /dev/null +++ b/spec/detail_spec.lua @@ -0,0 +1,402 @@ +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)