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.
This commit is contained in:
Barrett Ruth 2026-03-11 12:55:36 -04:00 committed by Barrett Ruth
parent 46b5d52b60
commit 939251f629
Signed by: barrett
GPG key ID: A6C96C9349D2FC81
16 changed files with 144 additions and 80 deletions

View file

@ -136,9 +136,11 @@ function M.omnifunc(findstart, base)
local dk = date_key()
local rk = recur_key()
local ck = config.get().category_syntax or 'cat'
local checks = {
{ vim.pesc(dk) .. ':([%S]*)$', dk },
{ 'cat:([%S]*)$', 'cat' },
{ vim.pesc(ck) .. ':([%S]*)$', ck },
{ vim.pesc(rk) .. ':([%S]*)$', rk },
}
for _, b in ipairs(forge.backends()) do
@ -172,10 +174,10 @@ function M.omnifunc(findstart, base)
table.insert(matches, { word = c.word, menu = '[' .. source .. ']', info = c.info })
end
end
elseif source == 'cat' then
elseif source == (config.get().category_syntax or 'cat') then
for _, c in ipairs(get_categories()) do
if base == '' or c:sub(1, #base) == base then
table.insert(matches, { word = c, menu = '[cat]' })
table.insert(matches, { word = c, menu = '[' .. source .. ']' })
end
end
elseif source == rk then
@ -190,7 +192,7 @@ function M.omnifunc(findstart, base)
local seen = {}
for _, task in ipairs(s:tasks()) do
if task._extra and task._extra._forge_ref then
local ref = task._extra._forge_ref
local ref = task._extra._forge_ref --[[@as pending.ForgeRef]]
local key = ref.owner .. '/' .. ref.repo
if not seen[key] then
seen[key] = true

View file

@ -43,6 +43,9 @@
---@field close? boolean
---@field validate? boolean
---@field warn_missing_cli? boolean
---@field github? pending.ForgeInstanceConfig
---@field gitlab? pending.ForgeInstanceConfig
---@field codeberg? pending.ForgeInstanceConfig
---@field [string] pending.ForgeInstanceConfig
---@class pending.SyncConfig
@ -92,6 +95,7 @@
---@field data_path string
---@field default_category string
---@field date_format string
---@field category_syntax string
---@field date_syntax string
---@field recur_syntax string
---@field someday_date string
@ -113,6 +117,7 @@ local defaults = {
data_path = vim.fn.stdpath('data') .. '/pending/tasks.json',
default_category = 'Todo',
date_format = '%b %d',
category_syntax = 'cat',
date_syntax = 'due',
recur_syntax = 'rec',
someday_date = '9999-12-30',

View file

@ -7,11 +7,11 @@ local parse = require('pending.parse')
---@field id? integer
---@field description? string
---@field priority? integer
---@field status? string
---@field status? pending.TaskStatus
---@field category? string
---@field due? string
---@field rec? string
---@field rec_mode? string
---@field recur? string
---@field recur_mode? pending.RecurMode
---@field forge_ref? pending.ForgeRef
---@field lnum integer
@ -65,10 +65,10 @@ function M.parse_buffer(lines)
description = description,
priority = priority,
status = status,
category = metadata.cat or current_category or config.get().default_category,
category = metadata.category or current_category or config.get().default_category,
due = metadata.due,
rec = metadata.rec,
rec_mode = metadata.rec_mode,
recur = metadata.recur,
recur_mode = metadata.recur_mode,
forge_ref = forge_ref,
lnum = i,
})
@ -126,8 +126,8 @@ function M.apply(lines, s, hidden_ids)
category = entry.category,
priority = entry.priority,
due = entry.due,
recur = entry.rec,
recur_mode = entry.rec_mode,
recur = entry.recur,
recur_mode = entry.recur_mode,
order = order_counter,
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
})
@ -157,13 +157,13 @@ function M.apply(lines, s, hidden_ids)
task.due = entry.due
changed = true
end
if entry.rec ~= nil then
if task.recur ~= entry.rec then
task.recur = entry.rec
if entry.recur ~= nil then
if task.recur ~= entry.recur then
task.recur = entry.recur
changed = true
end
if task.recur_mode ~= entry.rec_mode then
task.recur_mode = entry.rec_mode
if task.recur_mode ~= entry.recur_mode then
task.recur_mode = entry.recur_mode
changed = true
end
end
@ -201,8 +201,8 @@ function M.apply(lines, s, hidden_ids)
category = entry.category,
priority = entry.priority,
due = entry.due,
recur = entry.rec,
recur_mode = entry.rec_mode,
recur = entry.recur,
recur_mode = entry.recur_mode,
order = order_counter,
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
})

View file

@ -1,17 +1,21 @@
local config = require('pending.config')
local log = require('pending.log')
---@alias pending.ForgeType 'issue'|'pull_request'|'merge_request'|'repo'
---@alias pending.ForgeState 'open'|'closed'|'merged'
---@alias pending.ForgeAuthStatus 'unknown'|'ok'|'failed'
---@class pending.ForgeRef
---@field forge string
---@field owner string
---@field repo string
---@field type 'issue'|'pull_request'|'merge_request'|'repo'
---@field type pending.ForgeType
---@field number? integer
---@field url string
---@class pending.ForgeCache
---@field title? string
---@field state 'open'|'closed'|'merged'
---@field state pending.ForgeState
---@field labels? string[]
---@field fetched_at string
@ -27,10 +31,10 @@ local log = require('pending.log')
---@field auth_status_args string[]
---@field default_icon string
---@field default_issue_format string
---@field _auth? 'unknown'|'ok'|'failed'
---@field _auth? pending.ForgeAuthStatus
---@field parse_url fun(self: pending.ForgeBackend, url: string): pending.ForgeRef?
---@field api_args fun(self: pending.ForgeBackend, ref: pending.ForgeRef): string[]
---@field parse_state fun(self: pending.ForgeBackend, decoded: table): 'open'|'closed'|'merged'
---@field parse_state fun(self: pending.ForgeBackend, decoded: table): pending.ForgeState
---@class pending.forge
local M = {}

View file

@ -984,10 +984,10 @@ function M.add(text)
end
s:add({
description = description,
category = metadata.cat,
category = metadata.category,
due = metadata.due,
recur = metadata.rec,
recur_mode = metadata.rec_mode,
recur = metadata.recur,
recur_mode = metadata.recur_mode,
priority = metadata.priority,
})
_save_and_notify()
@ -998,6 +998,14 @@ function M.add(text)
log.info('Task added: ' .. description)
end
---@class pending.SyncBackend
---@field name string
---@field auth fun(): nil
---@field push? fun(): nil
---@field pull? fun(): nil
---@field sync? fun(): nil
---@field health? fun(): nil
---@type string[]?
local _sync_backends = nil
@ -1186,6 +1194,7 @@ end
local function parse_edit_token(token)
local recur = require('pending.recur')
local cfg = require('pending.config').get()
local ck = cfg.category_syntax or 'cat'
local dk = cfg.date_syntax or 'due'
local rk = cfg.recur_syntax or 'rec'
@ -1201,7 +1210,7 @@ local function parse_edit_token(token)
if token == '-due' or token == '-' .. dk then
return 'due', vim.NIL, nil
end
if token == '-cat' then
if token == '-' .. ck then
return 'category', vim.NIL, nil
end
if token == '-rec' or token == '-' .. rk then
@ -1223,7 +1232,7 @@ local function parse_edit_token(token)
'Invalid date: ' .. due_val .. '. Use YYYY-MM-DD, today, tomorrow, +Nd, weekday names, etc.'
end
local cat_val = token:match('^cat:(.+)$')
local cat_val = token:match('^' .. vim.pesc(ck) .. ':(.+)$')
if cat_val then
return 'category', cat_val, nil
end
@ -1248,11 +1257,15 @@ local function parse_edit_token(token)
.. token
.. '. Valid: '
.. dk
.. ':<date>, cat:<name>, '
.. ':<date>, '
.. ck
.. ':<name>, '
.. rk
.. ':<pattern>, +!, -!, -'
.. dk
.. ', -cat, -'
.. ', -'
.. ck
.. ', -'
.. rk
end

View file

@ -2,9 +2,9 @@ local config = require('pending.config')
---@class pending.Metadata
---@field due? string
---@field cat? string
---@field rec? string
---@field rec_mode? 'scheduled'|'completion'
---@field category? string
---@field recur? string
---@field recur_mode? pending.RecurMode
---@field priority? integer
---@class pending.parse
@ -107,6 +107,11 @@ local function is_valid_datetime(s)
return is_valid_date(date_part) and is_valid_time(time_part)
end
---@return string
local function category_key()
return config.get().category_syntax or 'cat'
end
---@return string
local function date_key()
return config.get().date_syntax or 'due'
@ -531,8 +536,10 @@ function M.body(text)
local metadata = {}
local i = #tokens
local ck = category_key()
local dk = date_key()
local rk = recur_key()
local cat_pattern = '^' .. vim.pesc(ck) .. ':(%S+)$'
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+)$'
@ -562,12 +569,12 @@ function M.body(text)
metadata.due = resolved
i = i - 1
else
local cat_val = token:match('^cat:(%S+)$')
local cat_val = token:match(cat_pattern)
if cat_val then
if metadata.cat then
if metadata.category then
break
end
metadata.cat = cat_val
metadata.category = cat_val
i = i - 1
else
local pri_bangs = token:match('^%+(!+)$')
@ -581,19 +588,19 @@ function M.body(text)
else
local rec_val = token:match(rec_pattern)
if rec_val then
if metadata.rec 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.rec_mode = 'completion'
metadata.recur_mode = 'completion'
raw_spec = raw_spec:sub(2)
end
if not recur.validate(raw_spec) then
break
end
metadata.rec = raw_spec
metadata.recur = raw_spec
i = i - 1
else
break
@ -624,7 +631,7 @@ function M.command_add(text)
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
meta.category = meta.category or cat_prefix
return desc, meta
end
end

View file

@ -2,7 +2,7 @@
---@field freq 'daily'|'weekly'|'monthly'|'yearly'
---@field interval integer
---@field byday? string[]
---@field from_completion boolean
---@field mode pending.RecurMode
---@field _raw? string
---@class pending.recur
@ -10,29 +10,29 @@ local M = {}
---@type table<string, pending.RecurSpec>
local named = {
daily = { freq = 'daily', interval = 1, from_completion = false },
daily = { freq = 'daily', interval = 1, mode = 'scheduled' },
weekdays = {
freq = 'weekly',
interval = 1,
byday = { 'MO', 'TU', 'WE', 'TH', 'FR' },
from_completion = false,
mode = 'scheduled',
},
weekly = { freq = 'weekly', interval = 1, from_completion = false },
biweekly = { freq = 'weekly', interval = 2, from_completion = false },
monthly = { freq = 'monthly', interval = 1, from_completion = false },
quarterly = { freq = 'monthly', interval = 3, from_completion = false },
yearly = { freq = 'yearly', interval = 1, from_completion = false },
annual = { freq = 'yearly', interval = 1, from_completion = false },
weekly = { freq = 'weekly', interval = 1, mode = 'scheduled' },
biweekly = { freq = 'weekly', interval = 2, mode = 'scheduled' },
monthly = { freq = 'monthly', interval = 1, mode = 'scheduled' },
quarterly = { freq = 'monthly', interval = 3, mode = 'scheduled' },
yearly = { freq = 'yearly', interval = 1, mode = 'scheduled' },
annual = { freq = 'yearly', interval = 1, mode = 'scheduled' },
}
---@param spec string
---@return pending.RecurSpec?
function M.parse(spec)
local from_completion = false
local mode = 'scheduled' ---@type pending.RecurMode
local s = spec
if s:sub(1, 1) == '!' then
from_completion = true
mode = 'completion'
s = s:sub(2)
end
@ -44,7 +44,7 @@ function M.parse(spec)
freq = base.freq,
interval = base.interval,
byday = base.byday,
from_completion = from_completion,
mode = mode,
}
end
@ -58,7 +58,7 @@ function M.parse(spec)
return {
freq = freq_map[unit],
interval = num,
from_completion = from_completion,
mode = mode,
}
end
@ -66,7 +66,7 @@ function M.parse(spec)
return {
freq = 'daily',
interval = 1,
from_completion = from_completion,
mode = mode,
_raw = s,
}
end
@ -134,7 +134,7 @@ end
---@param base_date string
---@param spec string
---@param mode 'scheduled'|'completion'
---@param mode pending.RecurMode
---@return string
function M.next_due(base_date, spec, mode)
local parsed = M.parse(spec)

View file

@ -1,19 +1,31 @@
local config = require('pending.config')
---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked'
---@alias pending.RecurMode 'scheduled'|'completion'
---@class pending.TaskExtra
---@field _forge_ref? pending.ForgeRef
---@field _forge_cache? pending.ForgeCache
---@field _gtasks_task_id? string
---@field _gtasks_list_id? string
---@field _gcal_event_id? string
---@field _gcal_calendar_id? string
---@field [string] any
---@class pending.Task
---@field id integer
---@field description string
---@field status 'pending'|'done'|'deleted'|'wip'|'blocked'
---@field status pending.TaskStatus
---@field category? string
---@field priority integer
---@field due? string
---@field recur? string
---@field recur_mode? 'scheduled'|'completion'
---@field recur_mode? pending.RecurMode
---@field entry string
---@field modified string
---@field end? string
---@field order integer
---@field _extra? table<string, any>
---@field _extra? pending.TaskExtra
---@class pending.Data
---@field version integer
@ -24,14 +36,14 @@ local config = require('pending.config')
---@class pending.TaskFields
---@field description string
---@field status? string
---@field status? pending.TaskStatus
---@field category? string
---@field priority? integer
---@field due? string
---@field recur? string
---@field recur_mode? string
---@field recur_mode? pending.RecurMode
---@field order? integer
---@field _extra? table
---@field _extra? pending.TaskExtra
---@class pending.Store
---@field path string

View file

@ -24,6 +24,15 @@ local BUNDLED_CLIENT_SECRET = 'PLACEHOLDER'
---@field config_key string
---@class pending.OAuthClient : pending.OAuthClientOpts
---@field token_path fun(self: pending.OAuthClient): string
---@field resolve_credentials fun(self: pending.OAuthClient): pending.OAuthCredentials
---@field load_tokens fun(self: pending.OAuthClient): pending.OAuthTokens?
---@field save_tokens fun(self: pending.OAuthClient, tokens: pending.OAuthTokens): boolean
---@field refresh_access_token fun(self: pending.OAuthClient, creds: pending.OAuthCredentials, tokens: pending.OAuthTokens): pending.OAuthTokens?
---@field get_access_token fun(self: pending.OAuthClient): string?
---@field setup fun(self: pending.OAuthClient): nil
---@field auth fun(self: pending.OAuthClient, on_complete?: fun(ok: boolean): nil): nil
---@field clear_tokens fun(self: pending.OAuthClient): nil
local OAuthClient = {}
OAuthClient.__index = OAuthClient

View file

@ -13,7 +13,7 @@ local parse = require('pending.parse')
---@field id? integer
---@field due? string
---@field raw_due? string
---@field status? string
---@field status? pending.TaskStatus
---@field category? string
---@field overdue? boolean
---@field show_category? boolean