pending.nvim/spec/file_spec.lua
Barrett Ruth 1748e5caa1
feat(file-token): file: inline metadata token with gf navigation (#45)
* feat(file-token): add file: inline metadata token with gf navigation

Problem: there was no way to link a task to a specific location in a
source file, or to quickly jump from a task to the relevant code.

Solution: add a file:<path>:<line> inline token that stores a relative
file reference in task._extra.file. Virtual text renders basename:line
in a new PendingFile highlight group. A buffer-local gf mapping
(configurable via keymaps.goto_file) opens the file at the given line.
M.add_here() lets users attach the current cursor position to any task
via vim.ui.select(). M.edit() gains -file support to clear the
reference. <Plug>(pending-goto-file) and <Plug>(pending-add-here) are
exposed for custom mappings.

* test(file-token): add parse, diff, views, edit, and navigation tests

Problem: the file: token implementation had no test coverage.

Solution: add spec/file_spec.lua covering parse.body extraction,
malformed token handling, duplicate token stop-parsing, diff
reconciliation (store/update/clear/round-trip), LineMeta population
in both views, :Pending edit -file, and goto_file notify paths for
no-file and unreadable-file cases. All 292 tests pass.

* style: apply stylua formatting

* fix(types): remove empty elseif block, fix file? annotation nullability
2026-02-26 19:12:48 -05:00

351 lines
11 KiB
Lua

require('spec.helpers')
local config = require('pending.config')
local diff = require('pending.diff')
local parse = require('pending.parse')
local store = require('pending.store')
local views = require('pending.views')
describe('file token', function()
local tmpdir
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()
store.unload()
end)
describe('parse.body', function()
it('extracts file token with path and line number', function()
local desc, meta = parse.body('Fix the bug file:src/auth.lua:42')
assert.are.equal('Fix the bug', desc)
assert.are.equal('src/auth.lua:42', meta.file)
end)
it('extracts file token with nested path', function()
local desc, meta = parse.body('Do something file:lua/pending/init.lua:100')
assert.are.equal('Do something', desc)
assert.are.equal('lua/pending/init.lua:100', meta.file)
end)
it('strips file token from description', function()
local desc, meta = parse.body('Task description file:foo.lua:1')
assert.are.equal('Task description', desc)
assert.are.equal('foo.lua:1', meta.file)
end)
it('stops parsing on duplicate file token', function()
local desc, meta = parse.body('Task file:b.lua:2 file:a.lua:1')
assert.are.equal('Task file:b.lua:2', desc)
assert.are.equal('a.lua:1', meta.file)
end)
it('treats malformed file token (no line number) as non-metadata', function()
local desc, meta = parse.body('Task file:nolineno')
assert.are.equal('Task file:nolineno', desc)
assert.is_nil(meta.file)
end)
it('treats file: prefix with no path as non-metadata', function()
local desc, meta = parse.body('Task file:')
assert.are.equal('Task file:', desc)
assert.is_nil(meta.file)
end)
it('handles file token alongside other metadata tokens', function()
local desc, meta = parse.body('Task cat:Work file:src/main.lua:10')
assert.are.equal('Task', desc)
assert.are.equal('Work', meta.cat)
assert.are.equal('src/main.lua:10', meta.file)
end)
it('does not extract file token when line number is not numeric', function()
local desc, meta = parse.body('Task file:src/foo.lua:abc')
assert.are.equal('Task file:src/foo.lua:abc', desc)
assert.is_nil(meta.file)
end)
end)
describe('diff reconciliation', function()
it('stores file field in _extra on write', function()
local t = store.add({ description = 'Task one' })
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42',
}
diff.apply(lines)
local updated = store.get(t.id)
assert.is_not_nil(updated._extra)
assert.are.equal('src/auth.lua:42', updated._extra.file)
end)
it('updates file field when token changes', function()
local t = store.add({ description = 'Task one', _extra = { file = 'old.lua:1' } })
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one file:new.lua:99',
}
diff.apply(lines)
local updated = store.get(t.id)
assert.are.equal('new.lua:99', updated._extra.file)
end)
it('clears file field when token is removed from line', function()
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one',
}
diff.apply(lines)
local updated = store.get(t.id)
assert.is_nil(updated._extra)
end)
it('preserves other _extra fields when file is cleared', function()
local t = store.add({
description = 'Task one',
_extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc123' },
})
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one',
}
diff.apply(lines)
local updated = store.get(t.id)
assert.is_not_nil(updated._extra)
assert.is_nil(updated._extra.file)
assert.are.equal('abc123', updated._extra._gcal_event_id)
end)
it('round-trips file field through JSON', function()
local t = store.add({ description = 'Task one' })
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one file:src/auth.lua:42',
}
diff.apply(lines)
store.unload()
store.load()
local loaded = store.get(t.id)
assert.is_not_nil(loaded._extra)
assert.are.equal('src/auth.lua:42', loaded._extra.file)
end)
it('accepts optional hidden_ids parameter without error', function()
local t = store.add({ description = 'Task one' })
store.save()
local lines = {
'/' .. t.id .. '/- [ ] Task one',
}
assert.has_no_error(function()
diff.apply(lines, {})
end)
end)
end)
describe('LineMeta', function()
it('category_view populates file field in LineMeta', function()
local t = store.add({
description = 'Task one',
_extra = { file = 'src/auth.lua:42' },
})
store.save()
local tasks = store.active_tasks()
local _, meta = views.category_view(tasks)
local task_meta = nil
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
break
end
end
assert.is_not_nil(task_meta)
assert.are.equal('src/auth.lua:42', task_meta.file)
end)
it('priority_view populates file field in LineMeta', function()
local t = store.add({
description = 'Task one',
_extra = { file = 'src/auth.lua:42' },
})
store.save()
local tasks = store.active_tasks()
local _, meta = views.priority_view(tasks)
local task_meta = nil
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
break
end
end
assert.is_not_nil(task_meta)
assert.are.equal('src/auth.lua:42', task_meta.file)
end)
it('file field is nil in LineMeta when task has no file', function()
local t = store.add({ description = 'Task one' })
store.save()
local tasks = store.active_tasks()
local _, meta = views.category_view(tasks)
local task_meta = nil
for _, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_meta = m
break
end
end
assert.is_not_nil(task_meta)
assert.is_nil(task_meta.file)
end)
end)
describe(':Pending edit -file', function()
it('clears file reference from task', function()
local pending = require('pending')
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
store.save()
pending.edit(tostring(t.id), '-file')
local updated = store.get(t.id)
assert.is_nil(updated._extra)
end)
it('shows feedback when file reference is removed', function()
local pending = require('pending')
local t = store.add({ description = 'Task one', _extra = { file = 'src/auth.lua:42' } })
store.save()
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.edit(tostring(t.id), '-file')
vim.notify = orig_notify
assert.are.equal(1, #messages)
assert.truthy(messages[1].msg:find('file reference removed'))
end)
it('does not error when task has no file', function()
local pending = require('pending')
local t = store.add({ description = 'Task one' })
store.save()
assert.has_no_error(function()
pending.edit(tostring(t.id), '-file')
end)
end)
it('preserves other _extra fields when -file is used', function()
local pending = require('pending')
local t = store.add({
description = 'Task one',
_extra = { file = 'src/auth.lua:42', _gcal_event_id = 'abc' },
})
store.save()
pending.edit(tostring(t.id), '-file')
local updated = store.get(t.id)
assert.is_not_nil(updated._extra)
assert.is_nil(updated._extra.file)
assert.are.equal('abc', updated._extra._gcal_event_id)
end)
end)
describe('goto_file', function()
it('notifies warn when task has no file attached', function()
local pending = require('pending')
local buffer = require('pending.buffer')
local t = store.add({ description = 'Task one' })
store.save()
local bufnr = buffer.open()
vim.bo[bufnr].filetype = 'pending'
vim.api.nvim_set_current_buf(bufnr)
local meta = buffer.meta()
local task_lnum = nil
for lnum, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_lnum = lnum
break
end
end
assert.is_not_nil(task_lnum)
vim.api.nvim_win_set_cursor(0, { task_lnum, 0 })
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.goto_file()
vim.notify = orig_notify
local warned = false
for _, m in ipairs(messages) do
if m.level == vim.log.levels.WARN and m.msg:find('No file attached') then
warned = true
end
end
assert.is_true(warned)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
it('notifies error when file spec is unreadable', function()
local pending = require('pending')
local buffer = require('pending.buffer')
local t = store.add({
description = 'Task one',
_extra = { file = 'nonexistent/path.lua:1' },
})
store.save()
local bufnr = buffer.open()
vim.bo[bufnr].filetype = 'pending'
vim.api.nvim_set_current_buf(bufnr)
local meta = buffer.meta()
local task_lnum = nil
for lnum, m in ipairs(meta) do
if m.type == 'task' and m.id == t.id then
task_lnum = lnum
break
end
end
assert.is_not_nil(task_lnum)
vim.api.nvim_win_set_cursor(0, { task_lnum, 0 })
local messages = {}
local orig_notify = vim.notify
vim.notify = function(msg, level)
table.insert(messages, { msg = msg, level = level })
end
pending.goto_file()
vim.notify = orig_notify
local errored = false
for _, m in ipairs(messages) do
if m.level == vim.log.levels.ERROR and m.msg:find('File not found') then
errored = true
end
end
assert.is_true(errored)
vim.api.nvim_buf_delete(bufnr, { force = true })
end)
end)
end)