diff --git a/lua/pending/complete.lua b/lua/pending/complete.lua new file mode 100644 index 0000000..f4b98fb --- /dev/null +++ b/lua/pending/complete.lua @@ -0,0 +1,118 @@ +local config = require('pending.config') + +---@class pending.complete +local M = {} + +---@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 + +---@return string[] +local function get_categories() + local store = require('pending.store') + local seen = {} + local result = {} + for _, task in ipairs(store.active_tasks()) do + local cat = task.category + if cat and not seen[cat] then + seen[cat] = true + table.insert(result, cat) + end + end + table.sort(result) + return result +end + +---@return string[] +local function date_completions() + return { + 'today', 'tomorrow', 'yesterday', + '+1d', '+2d', '+3d', '+1w', '+2w', '+1m', + 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun', + 'eod', 'eow', 'eom', 'eoq', 'eoy', + 'sow', 'som', 'soq', 'soy', + 'later', + } +end + +---@return string[] +local function recur_completions() + local recur = require('pending.recur') + local list = recur.shorthand_list() + local result = {} + for _, s in ipairs(list) do + table.insert(result, s) + end + for _, s in ipairs(list) do + table.insert(result, '!' .. s) + end + return result +end + +---@type string? +local _complete_source = nil + +---@param findstart integer +---@param base string +---@return integer|table[] +function M.omnifunc(findstart, base) + if findstart == 1 then + local line = vim.api.nvim_get_current_line() + local col = vim.api.nvim_win_get_cursor(0)[2] + local before = line:sub(1, col) + + local dk = date_key() + local rk = recur_key() + + local checks = { + { vim.pesc(dk) .. ':([%S]*)$', dk }, + { 'cat:([%S]*)$', 'cat' }, + { vim.pesc(rk) .. ':([%S]*)$', rk }, + } + + for _, check in ipairs(checks) do + local start = before:find(check[1]) + if start then + local colon_pos = before:find(':', start, true) + if colon_pos then + _complete_source = check[2] + return colon_pos + end + end + end + + _complete_source = nil + return -1 + end + + local candidates = {} + local source = _complete_source or '' + + local dk = date_key() + local rk = recur_key() + + if source == dk then + candidates = date_completions() + elseif source == 'cat' then + candidates = get_categories() + elseif source == rk then + candidates = recur_completions() + end + + local matches = {} + for _, c in ipairs(candidates) do + if base == '' or c:sub(1, #base) == base then + table.insert(matches, { word = c, menu = '[' .. source .. ']' }) + end + end + + return matches +end + +return M diff --git a/spec/complete_spec.lua b/spec/complete_spec.lua new file mode 100644 index 0000000..7b45e5b --- /dev/null +++ b/spec/complete_spec.lua @@ -0,0 +1,171 @@ +require('spec.helpers') + +local config = require('pending.config') +local store = require('pending.store') + +describe('complete', function() + local tmpdir + local complete = require('pending.complete') + + before_each(function() + tmpdir = vim.fn.tempname() + vim.fn.mkdir(tmpdir, 'p') + vim.g.pending = { data_path = tmpdir .. '/tasks.json' } + config.reset() + store.unload() + store.load() + end) + + after_each(function() + vim.fn.delete(tmpdir, 'rf') + vim.g.pending = nil + config.reset() + end) + + describe('findstart', function() + it('returns column after colon for cat: prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:Wo' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + local result = complete.omnifunc(1, '') + assert.are.equal(15, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns column after colon for due: prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + local result = complete.omnifunc(1, '') + assert.are.equal(15, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns column after colon for rec: prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + local result = complete.omnifunc(1, '') + assert.are.equal(15, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns -1 for non-token position', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] some task ' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 14 }) + local result = complete.omnifunc(1, '') + assert.are.equal(-1, result) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + end) + + describe('completions', function() + it('returns existing categories for cat:', function() + store.add({ description = 'A', category = 'Work' }) + store.add({ description = 'B', category = 'Home' }) + store.add({ description = 'C', category = 'Work' }) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat: x' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, '') + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'Work')) + assert.is_true(vim.tbl_contains(words, 'Home')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('filters categories by base', function() + store.add({ description = 'A', category = 'Work' }) + store.add({ description = 'B', category = 'Home' }) + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task cat:W' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, 'W') + assert.are.equal(1, #result) + assert.are.equal('Work', result[1].word) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns named dates for due:', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due: x' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, '') + assert.is_true(#result > 0) + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'today')) + assert.is_true(vim.tbl_contains(words, 'tomorrow')) + assert.is_true(vim.tbl_contains(words, 'eom')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('filters dates by base prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task due:to' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, 'to') + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'today')) + assert.is_true(vim.tbl_contains(words, 'tomorrow')) + assert.is_false(vim.tbl_contains(words, 'eom')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('returns recurrence shorthands for rec:', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec: x' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 15 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, '') + assert.is_true(#result > 0) + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'daily')) + assert.is_true(vim.tbl_contains(words, 'weekly')) + assert.is_true(vim.tbl_contains(words, '!weekly')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + + it('filters recurrence by base prefix', function() + local bufnr = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] task rec:we' }) + vim.api.nvim_set_current_buf(bufnr) + vim.api.nvim_win_set_cursor(0, { 1, 16 }) + complete.omnifunc(1, '') + local result = complete.omnifunc(0, 'we') + local words = {} + for _, item in ipairs(result) do + table.insert(words, item.word) + end + assert.is_true(vim.tbl_contains(words, 'weekly')) + assert.is_true(vim.tbl_contains(words, 'weekdays')) + assert.is_false(vim.tbl_contains(words, 'daily')) + vim.api.nvim_buf_delete(bufnr, { force = true }) + end) + end) +end)