pending.nvim/lua/pending/diff.lua
Barrett Ruth b131d6d391
refactor: tighten LuaCATS annotations and canonicalize metadata fields (#141)
* refactor: tighten LuaCATS annotations across modules

Problem: type annotations repeated inline unions with no aliases,
used `table<string, any>` where structured types exist, and had
loose `string` where union types should be used.

Solution: add `pending.TaskStatus`, `pending.RecurMode`,
`pending.TaskExtra`, `pending.ForgeType`, `pending.ForgeState`,
`pending.ForgeAuthStatus` aliases and `pending.SyncBackend`
interface. Replace inline unions and loose types with the new
aliases in `store.lua`, `forge.lua`, `config.lua`, `diff.lua`,
`views.lua`, `parse.lua`, `init.lua`, and `oauth.lua`.

* refactor: canonicalize internal metadata field names

Problem: `pending.Metadata` used shorthand field names (`cat`, `rec`,
`rec_mode`) matching user-facing token syntax, coupling internal
representation to config. `RecurSpec.from_completion` used a boolean
where a `pending.RecurMode` alias exists. `category_syntax` was
hardcoded to `'cat'` with no config option.

Solution: rename `Metadata` fields to `category`/`recur`/`recur_mode`,
add `category_syntax` config option (default `'cat'`), rename
`ParsedEntry` fields to match, replace `RecurSpec.from_completion`
with `mode: pending.RecurMode`, and restore `[string]` indexer on
`pending.ForgeConfig` alongside explicit fields.
2026-03-11 12:55:36 -04:00

229 lines
6.4 KiB
Lua

local config = require('pending.config')
local forge = require('pending.forge')
local parse = require('pending.parse')
---@class pending.ParsedEntry
---@field type 'task'|'header'|'blank'
---@field id? integer
---@field description? string
---@field priority? integer
---@field status? pending.TaskStatus
---@field category? string
---@field due? string
---@field recur? string
---@field recur_mode? pending.RecurMode
---@field forge_ref? pending.ForgeRef
---@field lnum integer
---@class pending.diff
local M = {}
---@return string
local function timestamp()
return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
end
---@param lines string[]
---@return pending.ParsedEntry[]
function M.parse_buffer(lines)
local result = {}
local current_category = nil
local start = 1
if lines[1] and lines[1]:match('^FILTER:') then
start = 2
end
for i = start, #lines do
local line = lines[i]
local id, body = line:match('^/(%d+)/(- %[.?%] .*)$')
if not id then
body = line:match('^(- %[.?%] .*)$')
end
if line == '' then
table.insert(result, { type = 'blank', lnum = i })
elseif id or body then
local stripped = body:match('^- %[.?%] (.*)$') or body
local state_char = body:match('^- %[(.-)%]') or ' '
local priority = state_char == '!' and 1 or 0
local status
if state_char == 'x' then
status = 'done'
elseif state_char == '>' then
status = 'wip'
elseif state_char == '=' then
status = 'blocked'
else
status = 'pending'
end
local description, metadata = parse.body(stripped)
if description and description ~= '' then
local refs = forge.find_refs(description)
local forge_ref = refs[1] and refs[1].ref or nil
table.insert(result, {
type = 'task',
id = id and tonumber(id) or nil,
description = description,
priority = priority,
status = status,
category = metadata.category or current_category or config.get().default_category,
due = metadata.due,
recur = metadata.recur,
recur_mode = metadata.recur_mode,
forge_ref = forge_ref,
lnum = i,
})
end
elseif line:match('^# (.+)$') then
current_category = line:match('^# (.+)$')
table.insert(result, { type = 'header', category = current_category, lnum = i })
end
end
return result
end
---@param a? pending.ForgeRef
---@param b? pending.ForgeRef
---@return boolean
local function refs_equal(a, b)
if not a or not b then
return false
end
return a.forge == b.forge and a.owner == b.owner and a.repo == b.repo and a.number == b.number
end
---@param lines string[]
---@param s pending.Store
---@param hidden_ids? table<integer, true>
---@return pending.ForgeRef[]
function M.apply(lines, s, hidden_ids)
local parsed = M.parse_buffer(lines)
local now = timestamp()
local data = s:data()
local new_refs = {} ---@type pending.ForgeRef[]
local old_by_id = {}
for _, task in ipairs(data.tasks) do
if task.status ~= 'deleted' then
old_by_id[task.id] = task
end
end
local seen_ids = {}
local order_counter = 0
for _, entry in ipairs(parsed) do
if entry.type ~= 'task' then
goto continue
end
order_counter = order_counter + 1
if entry.id and old_by_id[entry.id] then
if seen_ids[entry.id] then
s:add({
description = entry.description,
category = entry.category,
priority = entry.priority,
due = entry.due,
recur = entry.recur,
recur_mode = entry.recur_mode,
order = order_counter,
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
})
if entry.forge_ref then
table.insert(new_refs, entry.forge_ref)
end
else
seen_ids[entry.id] = true
local task = old_by_id[entry.id]
local changed = false
if task.description ~= entry.description then
task.description = entry.description
changed = true
end
if task.category ~= entry.category then
task.category = entry.category
changed = true
end
if entry.priority == 0 and task.priority > 0 then
task.priority = 0
changed = true
elseif entry.priority > 0 and task.priority == 0 then
task.priority = entry.priority
changed = true
end
if entry.due ~= nil and task.due ~= entry.due then
task.due = entry.due
changed = true
end
if entry.recur ~= nil then
if task.recur ~= entry.recur then
task.recur = entry.recur
changed = true
end
if task.recur_mode ~= entry.recur_mode then
task.recur_mode = entry.recur_mode
changed = true
end
end
if entry.forge_ref ~= nil then
local old_ref = task._extra and task._extra._forge_ref or nil
if not refs_equal(old_ref, entry.forge_ref) then
table.insert(new_refs, entry.forge_ref)
end
if not task._extra then
task._extra = {}
end
task._extra._forge_ref = entry.forge_ref
changed = true
end
if entry.status and task.status ~= entry.status then
task.status = entry.status
if entry.status == 'done' then
task['end'] = now
else
task['end'] = nil
end
changed = true
end
if task.order ~= order_counter then
task.order = order_counter
changed = true
end
if changed then
task.modified = now
end
end
else
s:add({
description = entry.description,
category = entry.category,
priority = entry.priority,
due = entry.due,
recur = entry.recur,
recur_mode = entry.recur_mode,
order = order_counter,
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
})
if entry.forge_ref then
table.insert(new_refs, entry.forge_ref)
end
end
::continue::
end
for id, task in pairs(old_by_id) do
if not seen_ids[id] and not (hidden_ids and hidden_ids[id]) then
task.status = 'deleted'
task['end'] = now
task.modified = now
end
end
s:save()
return new_refs
end
return M