fix(buffer): conceal in all modes, forge EOL labels, remove dash prefix (#167)

* fix(buffer): keep conceal active in all modes and add `%l` EOL forge labels

Problem: `concealcursor` was missing `i` and `v`, so concealed text
(task IDs, forge tokens) leaked in insert and visual modes. Forge
labels only rendered for the first span when multiple refs existed.

Solution: set `concealcursor = 'nicv'` to keep conceal in all modes.
Add `%l` EOL format specifier that renders all forge spans with
independent highlights. Update default `eol_format` to include `%l`.

* refactor: remove `- ` prefix from task line rendering

Problem: task lines rendered as `- [ ] description` with a redundant
markdown list marker prefix that added visual noise.

Solution: render task lines as `[ ] description` instead. Update all
line generation in `views.lua`, parsing patterns in `buffer.lua`,
`diff.lua`, `textobj.lua`, syntax rules, and corresponding specs.
This commit is contained in:
Barrett Ruth 2026-03-15 13:22:01 -04:00 committed by GitHub
parent 1266eaedd8
commit 98e4abffc7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 156 additions and 141 deletions

View file

@ -901,12 +901,13 @@ Fields: ~
The view to use when the buffer is opened The view to use when the buffer is opened
for the first time in a session. for the first time in a session.
{eol_format} (string, default: '%c %r %d') {eol_format} (string, default: '%c %r %d %l')
Format string for end-of-line virtual text. Format string for end-of-line virtual text.
Specifiers: Specifiers:
`%c` category icon + name (`PendingHeader`) `%c` category icon + name (`PendingHeader`)
`%r` recurrence icon + pattern (`PendingRecur`) `%r` recurrence icon + pattern (`PendingRecur`)
`%d` due icon + date (`PendingDue`/`PendingOverdue`) `%d` due icon + date (`PendingDue`/`PendingOverdue`)
`%l` forge link label (`PendingForge`/`PendingForgeClosed`)
Literal text between specifiers acts as a Literal text between specifiers acts as a
separator. Absent fields and surrounding separator. Absent fields and surrounding
literals are collapsed automatically. `%c` literals are collapsed automatically. `%c`
@ -1572,10 +1573,9 @@ Example: >
< <
On `:w`, the forge reference stays in the description and is also stored in On `:w`, the forge reference stays in the description and is also stored in
the task's `_extra._forge_ref` field. The raw token is visually replaced the task's `_extra._forge_ref` field. The raw token is concealed in the
inline with a formatted label using overlay extmarks (same technique as buffer and a formatted label appears at the end of the line via the `%l`
checkbox icons). Multiple forge references in one line are each overlaid EOL format specifier.
independently.
Format string: ~ Format string: ~
*pending-forge-format* *pending-forge-format*

View file

@ -183,14 +183,10 @@ local function apply_inline_row(bufnr, row, m, icons)
invalidate = true, invalidate = true,
}) })
if m.forge_spans then if m.forge_spans then
local forge = require('pending.forge')
for _, span in ipairs(m.forge_spans) do for _, span in ipairs(m.forge_spans) do
local label_text, hl_group = forge.format_label(span.ref, span.cache)
vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, span.col_start, { vim.api.nvim_buf_set_extmark(bufnr, ns_inline, row, span.col_start, {
end_col = span.col_end, end_col = span.col_end,
conceal = '', conceal = '',
virt_text = { { label_text, hl_group } },
virt_text_pos = 'inline',
priority = 90, priority = 90,
invalidate = true, invalidate = true,
}) })
@ -215,7 +211,7 @@ end
---@param line string ---@param line string
---@return string? ---@return string?
local function infer_status(line) local function infer_status(line)
local ch = line:match('^/%d+/%- %[(.)%]') or line:match('^%- %[(.)%]') local ch = line:match('^/%d+/%[(.)%]') or line:match('^%[(.)%]')
if not ch then if not ch then
return nil return nil
end end
@ -399,7 +395,7 @@ end
---@param winid integer ---@param winid integer
local function set_win_options(winid) local function set_win_options(winid)
vim.wo[winid].conceallevel = 3 vim.wo[winid].conceallevel = 3
vim.wo[winid].concealcursor = 'nc' vim.wo[winid].concealcursor = 'nicv'
vim.wo[winid].winfixheight = true vim.wo[winid].winfixheight = true
end end
@ -411,7 +407,7 @@ local function setup_syntax(bufnr)
syntax match taskId /^\/\d\+\// conceal syntax match taskId /^\/\d\+\// conceal
syntax match taskHeader /^# .*$/ contains=taskId syntax match taskHeader /^# .*$/ contains=taskId
syntax match taskCheckbox /\[!\]/ contained containedin=taskLine syntax match taskCheckbox /\[!\]/ contained containedin=taskLine
syntax match taskLine /^\/\d\+\/- \[.\] .*$/ contains=taskId,taskCheckbox syntax match taskLine /^\/\d\+\/\[.\] .*$/ contains=taskId,taskCheckbox
]]) ]])
end) end)
end end
@ -429,7 +425,7 @@ function M.open_line(above)
_rendering = true _rendering = true
vim.bo[bufnr].modifiable = true vim.bo[bufnr].modifiable = true
vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '- [ ] ' }) vim.api.nvim_buf_set_lines(bufnr, insert_row, insert_row, false, { '[ ] ' })
_rendering = false _rendering = false
table.insert(_meta, meta_pos, { type = 'task' }) table.insert(_meta, meta_pos, { type = 'task' })
@ -444,7 +440,7 @@ function M.open_line(above)
end end
end end
vim.api.nvim_win_set_cursor(0, { insert_row + 1, 6 }) vim.api.nvim_win_set_cursor(0, { insert_row + 1, 4 })
vim.cmd('startinsert!') vim.cmd('startinsert!')
end end
@ -478,7 +474,7 @@ local function parse_eol_format(fmt)
while pos <= len do while pos <= len do
if fmt:sub(pos, pos) == '%' and pos + 1 <= len then if fmt:sub(pos, pos) == '%' and pos + 1 <= len then
local key = fmt:sub(pos + 1, pos + 1) local key = fmt:sub(pos + 1, pos + 1)
if key == 'c' or key == 'r' or key == 'd' then if key == 'c' or key == 'r' or key == 'd' or key == 'l' then
table.insert(segments, { type = 'specifier', key = key }) table.insert(segments, { type = 'specifier', key = key })
pos = pos + 2 pos = pos + 2
else else
@ -514,8 +510,21 @@ local function build_eol_virt(segments, m, icons)
elseif seg.key == 'd' and m.due then elseif seg.key == 'd' and m.due then
text = icons.due .. ' ' .. m.due text = icons.due .. ' ' .. m.due
hl = due_hl hl = due_hl
elseif seg.key == 'l' and m.forge_spans and #m.forge_spans > 0 then
local forge = require('pending.forge')
local parts = {}
for j, span in ipairs(m.forge_spans) do
local lt, lh = forge.format_label(span.ref, span.cache)
if j > 1 then
table.insert(parts, { text = ' ', hl = 'Normal' })
end
table.insert(parts, { text = lt, hl = lh })
end
resolved[i] = { multi = parts, present = true }
end
if not resolved[i] then
resolved[i] = text and { text = text, hl = hl, present = true } or { present = false }
end end
resolved[i] = text and { text = text, hl = hl, present = true } or { present = false }
else else
resolved[i] = { text = seg.text, hl = 'Normal', literal = true } resolved[i] = { text = seg.text, hl = 'Normal', literal = true }
end end
@ -533,7 +542,13 @@ local function build_eol_virt(segments, m, icons)
table.insert(virt_parts, pending_sep) table.insert(virt_parts, pending_sep)
pending_sep = nil pending_sep = nil
end end
table.insert(virt_parts, { r.text, r.hl }) if r.multi then
for _, part in ipairs(r.multi) do
table.insert(virt_parts, { part.text, part.hl })
end
else
table.insert(virt_parts, { r.text, r.hl })
end
else else
pending_sep = nil pending_sep = nil
end end

View file

@ -131,7 +131,7 @@ local defaults = {
max_priority = 3, max_priority = 3,
view = { view = {
default = 'category', default = 'category',
eol_format = '%c %r %d', eol_format = '%c %r %d %l',
category = { category = {
order = {}, order = {},
folding = true, folding = true,

View file

@ -35,16 +35,16 @@ function M.parse_buffer(lines)
for i = start, #lines do for i = start, #lines do
local line = lines[i] local line = lines[i]
local id, body = line:match('^/(%d+)/(- %[.?%] .*)$') local id, body = line:match('^/(%d+)/(%[.?%] .*)$')
if not id then if not id then
body = line:match('^(- %[.?%] .*)$') body = line:match('^(%[.?%] .*)$')
end end
if line == '' then if line == '' then
table.insert(result, { type = 'blank', lnum = i }) table.insert(result, { type = 'blank', lnum = i })
elseif id or body then elseif id or body then
local stripped = body:match('^- %[.?%] (.*)$') or body local stripped = body:match('^%[.?%] (.*)$') or body
local icons = config.get().icons local icons = config.get().icons
local state_char = body:match('^- %[(.-)%]') or icons.pending local state_char = body:match('^%[(.-)%]') or icons.pending
local priority = state_char == icons.priority and 1 or 0 local priority = state_char == icons.priority and 1 or 0
local status local status
if state_char == icons.done then if state_char == icons.done then

View file

@ -28,9 +28,9 @@ end
---@return integer start_col ---@return integer start_col
---@return integer end_col ---@return integer end_col
function M.inner_task_range(line) function M.inner_task_range(line)
local prefix_end = line:find('/') and select(2, line:find('^/%d+/%- %[.%] ')) local prefix_end = line:find('/') and select(2, line:find('^/%d+/%[.%] '))
if not prefix_end then if not prefix_end then
prefix_end = select(2, line:find('^%- %[.%] ')) or 0 prefix_end = select(2, line:find('^%[.%] ')) or 0
end end
local start_col = prefix_end + 1 local start_col = prefix_end + 1

View file

@ -263,8 +263,8 @@ function M.category_view(tasks)
for _, task in ipairs(all) do for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/' local prefix = '/' .. task.id .. '/'
local state = state_char(task) local state = state_char(task)
local line = prefix .. '- [' .. state .. '] ' .. task.description local line = prefix .. '[' .. state .. '] ' .. task.description
local prefix_len = #prefix + #('- [' .. state .. '] ') local prefix_len = #prefix + #('[' .. state .. '] ')
table.insert(lines, line) table.insert(lines, line)
table.insert(meta, { table.insert(meta, {
type = 'task', type = 'task',
@ -320,8 +320,8 @@ function M.priority_view(tasks)
for _, task in ipairs(all) do for _, task in ipairs(all) do
local prefix = '/' .. task.id .. '/' local prefix = '/' .. task.id .. '/'
local state = state_char(task) local state = state_char(task)
local line = prefix .. '- [' .. state .. '] ' .. task.description local line = prefix .. '[' .. state .. '] ' .. task.description
local prefix_len = #prefix + #('- [' .. state .. '] ') local prefix_len = #prefix + #('[' .. state .. '] ')
table.insert(lines, line) table.insert(lines, line)
table.insert(meta, { table.insert(meta, {
type = 'task', type = 'task',

View file

@ -27,39 +27,39 @@ describe('complete', function()
describe('findstart', function() describe('findstart', function()
it('returns column after colon for cat: prefix', function() it('returns column after colon for cat: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true) 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_buf_set_lines(bufnr, 0, -1, false, { '[ ] task cat:Wo' })
vim.api.nvim_set_current_buf(bufnr) vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 }) vim.api.nvim_win_set_cursor(0, { 1, 14 })
local result = complete.omnifunc(1, '') local result = complete.omnifunc(1, '')
assert.are.equal(15, result) assert.are.equal(13, result)
vim.api.nvim_buf_delete(bufnr, { force = true }) vim.api.nvim_buf_delete(bufnr, { force = true })
end) end)
it('returns column after colon for due: prefix', function() it('returns column after colon for due: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true) 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_buf_set_lines(bufnr, 0, -1, false, { '[ ] task due:to' })
vim.api.nvim_set_current_buf(bufnr) vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 }) vim.api.nvim_win_set_cursor(0, { 1, 14 })
local result = complete.omnifunc(1, '') local result = complete.omnifunc(1, '')
assert.are.equal(15, result) assert.are.equal(13, result)
vim.api.nvim_buf_delete(bufnr, { force = true }) vim.api.nvim_buf_delete(bufnr, { force = true })
end) end)
it('returns column after colon for rec: prefix', function() it('returns column after colon for rec: prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true) 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_buf_set_lines(bufnr, 0, -1, false, { '[ ] task rec:we' })
vim.api.nvim_set_current_buf(bufnr) vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 }) vim.api.nvim_win_set_cursor(0, { 1, 14 })
local result = complete.omnifunc(1, '') local result = complete.omnifunc(1, '')
assert.are.equal(15, result) assert.are.equal(13, result)
vim.api.nvim_buf_delete(bufnr, { force = true }) vim.api.nvim_buf_delete(bufnr, { force = true })
end) end)
it('returns -1 for non-token position', function() it('returns -1 for non-token position', function()
local bufnr = vim.api.nvim_create_buf(false, true) local bufnr = vim.api.nvim_create_buf(false, true)
vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '- [ ] some task ' }) vim.api.nvim_buf_set_lines(bufnr, 0, -1, false, { '[ ] some task ' })
vim.api.nvim_set_current_buf(bufnr) vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 14 }) vim.api.nvim_win_set_cursor(0, { 1, 12 })
local result = complete.omnifunc(1, '') local result = complete.omnifunc(1, '')
assert.are.equal(-1, result) assert.are.equal(-1, result)
vim.api.nvim_buf_delete(bufnr, { force = true }) vim.api.nvim_buf_delete(bufnr, { force = true })
@ -72,9 +72,9 @@ describe('complete', function()
s:add({ description = 'B', category = 'Home' }) s:add({ description = 'B', category = 'Home' })
s:add({ description = 'C', category = 'Work' }) s:add({ description = 'C', category = 'Work' })
local bufnr = vim.api.nvim_create_buf(false, true) 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_buf_set_lines(bufnr, 0, -1, false, { '[ ] task cat: x' })
vim.api.nvim_set_current_buf(bufnr) vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 }) vim.api.nvim_win_set_cursor(0, { 1, 13 })
complete.omnifunc(1, '') complete.omnifunc(1, '')
local result = complete.omnifunc(0, '') local result = complete.omnifunc(0, '')
local words = {} local words = {}
@ -90,9 +90,9 @@ describe('complete', function()
s:add({ description = 'A', category = 'Work' }) s:add({ description = 'A', category = 'Work' })
s:add({ description = 'B', category = 'Home' }) s:add({ description = 'B', category = 'Home' })
local bufnr = vim.api.nvim_create_buf(false, true) 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_buf_set_lines(bufnr, 0, -1, false, { '[ ] task cat:W' })
vim.api.nvim_set_current_buf(bufnr) vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 }) vim.api.nvim_win_set_cursor(0, { 1, 13 })
complete.omnifunc(1, '') complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'W') local result = complete.omnifunc(0, 'W')
assert.are.equal(1, #result) assert.are.equal(1, #result)
@ -102,9 +102,9 @@ describe('complete', function()
it('returns named dates for due:', function() it('returns named dates for due:', function()
local bufnr = vim.api.nvim_create_buf(false, true) 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_buf_set_lines(bufnr, 0, -1, false, { '[ ] task due: x' })
vim.api.nvim_set_current_buf(bufnr) vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 }) vim.api.nvim_win_set_cursor(0, { 1, 13 })
complete.omnifunc(1, '') complete.omnifunc(1, '')
local result = complete.omnifunc(0, '') local result = complete.omnifunc(0, '')
assert.is_true(#result > 0) assert.is_true(#result > 0)
@ -120,9 +120,9 @@ describe('complete', function()
it('filters dates by base prefix', function() it('filters dates by base prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true) 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_buf_set_lines(bufnr, 0, -1, false, { '[ ] task due:to' })
vim.api.nvim_set_current_buf(bufnr) vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 }) vim.api.nvim_win_set_cursor(0, { 1, 14 })
complete.omnifunc(1, '') complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'to') local result = complete.omnifunc(0, 'to')
local words = {} local words = {}
@ -137,9 +137,9 @@ describe('complete', function()
it('returns recurrence shorthands for rec:', function() it('returns recurrence shorthands for rec:', function()
local bufnr = vim.api.nvim_create_buf(false, true) 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_buf_set_lines(bufnr, 0, -1, false, { '[ ] task rec: x' })
vim.api.nvim_set_current_buf(bufnr) vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 15 }) vim.api.nvim_win_set_cursor(0, { 1, 13 })
complete.omnifunc(1, '') complete.omnifunc(1, '')
local result = complete.omnifunc(0, '') local result = complete.omnifunc(0, '')
assert.is_true(#result > 0) assert.is_true(#result > 0)
@ -155,9 +155,9 @@ describe('complete', function()
it('filters recurrence by base prefix', function() it('filters recurrence by base prefix', function()
local bufnr = vim.api.nvim_create_buf(false, true) 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_buf_set_lines(bufnr, 0, -1, false, { '[ ] task rec:we' })
vim.api.nvim_set_current_buf(bufnr) vim.api.nvim_set_current_buf(bufnr)
vim.api.nvim_win_set_cursor(0, { 1, 16 }) vim.api.nvim_win_set_cursor(0, { 1, 14 })
complete.omnifunc(1, '') complete.omnifunc(1, '')
local result = complete.omnifunc(0, 'we') local result = complete.omnifunc(0, 'we')
local words = {} local words = {}

View file

@ -22,11 +22,11 @@ describe('diff', function()
it('parses headers and tasks', function() it('parses headers and tasks', function()
local lines = { local lines = {
'# School', '# School',
'/1/- [ ] Do homework', '/1/[ ] Do homework',
'/2/- [!] Read chapter 5', '/2/[!] Read chapter 5',
'', '',
'# Errands', '# Errands',
'/3/- [ ] Buy groceries', '/3/[ ] Buy groceries',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
assert.are.equal(6, #result) assert.are.equal(6, #result)
@ -45,7 +45,7 @@ describe('diff', function()
it('handles new tasks without ids', function() it('handles new tasks without ids', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'- [ ] New task here', '[ ] New task here',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
assert.are.equal(2, #result) assert.are.equal(2, #result)
@ -57,7 +57,7 @@ describe('diff', function()
it('inline cat: token overrides header category', function() it('inline cat: token overrides header category', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Buy milk cat:Work', '/1/[ ] Buy milk cat:Work',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
assert.are.equal(2, #result) assert.are.equal(2, #result)
@ -68,7 +68,7 @@ describe('diff', function()
it('extracts rec: token from buffer line', function() it('extracts rec: token from buffer line', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Take trash out rec:weekly', '/1/[ ] Take trash out rec:weekly',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
assert.are.equal('weekly', result[2].recur) assert.are.equal('weekly', result[2].recur)
@ -77,7 +77,7 @@ describe('diff', function()
it('extracts rec: with completion mode', function() it('extracts rec: with completion mode', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Water plants rec:!daily', '/1/[ ] Water plants rec:!daily',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
assert.are.equal('daily', result[2].recur) assert.are.equal('daily', result[2].recur)
@ -87,7 +87,7 @@ describe('diff', function()
it('inline due: token is parsed', function() it('inline due: token is parsed', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Buy milk due:2026-03-15', '/1/[ ] Buy milk due:2026-03-15',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
assert.are.equal(2, #result) assert.are.equal(2, #result)
@ -100,8 +100,8 @@ describe('diff', function()
it('creates new tasks from buffer lines', function() it('creates new tasks from buffer lines', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'- [ ] First task', '[ ] First task',
'- [ ] Second task', '[ ] Second task',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -117,7 +117,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Keep me', '/1/[ ] Keep me',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -133,7 +133,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Renamed', '/1/[ ] Renamed',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -147,7 +147,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Renamed', '/1/[ ] Renamed',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -161,8 +161,8 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Original', '/1/[ ] Original',
'/1/- [ ] Copy of original', '/1/[ ] Copy of original',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -175,7 +175,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Work', '# Work',
'/1/- [ ] Moving task', '/1/[ ] Moving task',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -188,7 +188,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Stable task', '/1/[ ] Stable task',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -204,7 +204,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Pay bill', '/1/[ ] Pay bill',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -217,7 +217,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Pay bill due:2026-04-01', '/1/[ ] Pay bill due:2026-04-01',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -228,7 +228,7 @@ describe('diff', function()
it('stores recur field on new tasks from buffer', function() it('stores recur field on new tasks from buffer', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'- [ ] Take out trash rec:weekly', '[ ] Take out trash rec:weekly',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -242,7 +242,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Todo', '# Todo',
'/1/- [ ] Task rec:weekly', '/1/[ ] Task rec:weekly',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -255,7 +255,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Todo', '# Todo',
'/1/- [ ] Task', '/1/[ ] Task',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -266,7 +266,7 @@ describe('diff', function()
it('parses rec: with completion mode prefix', function() it('parses rec: with completion mode prefix', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'- [ ] Water plants rec:!weekly', '[ ] Water plants rec:!weekly',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -278,7 +278,7 @@ describe('diff', function()
it('returns forge refs for new tasks', function() it('returns forge refs for new tasks', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'- [ ] Fix bug gh:user/repo#42', '[ ] Fix bug gh:user/repo#42',
} }
local refs = diff.apply(lines, s) local refs = diff.apply(lines, s)
assert.are.equal(1, #refs) assert.are.equal(1, #refs)
@ -303,7 +303,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Todo', '# Todo',
'/1/- [ ] Fix bug gh:user/repo#99', '/1/[ ] Fix bug gh:user/repo#99',
} }
local refs = diff.apply(lines, s) local refs = diff.apply(lines, s)
assert.are.equal(1, #refs) assert.are.equal(1, #refs)
@ -327,7 +327,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Todo', '# Todo',
'/1/- [ ] Fix bug gh:user/repo#42', '/1/[ ] Fix bug gh:user/repo#42',
} }
local refs = diff.apply(lines, s) local refs = diff.apply(lines, s)
assert.are.equal(0, #refs) assert.are.equal(0, #refs)
@ -336,7 +336,7 @@ describe('diff', function()
it('returns empty for tasks without forge refs', function() it('returns empty for tasks without forge refs', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'- [ ] Plain task', '[ ] Plain task',
} }
local refs = diff.apply(lines, s) local refs = diff.apply(lines, s)
assert.are.equal(0, #refs) assert.are.equal(0, #refs)
@ -359,8 +359,8 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Todo', '# Todo',
'/1/- [ ] Fix bug gh:user/repo#42', '/1/[ ] Fix bug gh:user/repo#42',
'/1/- [ ] Fix bug gh:user/repo#42', '/1/[ ] Fix bug gh:user/repo#42',
} }
local refs = diff.apply(lines, s) local refs = diff.apply(lines, s)
assert.are.equal(1, #refs) assert.are.equal(1, #refs)
@ -372,7 +372,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [ ] Task name', '/1/[ ] Task name',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -383,7 +383,7 @@ describe('diff', function()
it('sets priority from +!! token', function() it('sets priority from +!! token', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'- [ ] Pay bills +!!', '[ ] Pay bills +!!',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -396,7 +396,7 @@ describe('diff', function()
s:save() s:save()
local lines = { local lines = {
'# Inbox', '# Inbox',
'/1/- [!] Task name', '/1/[!] Task name',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()
@ -407,7 +407,7 @@ describe('diff', function()
it('parses metadata with forge ref on same line', function() it('parses metadata with forge ref on same line', function()
local lines = { local lines = {
'# Inbox', '# Inbox',
'- [ ] Fix bug due:2026-03-15 gh:user/repo#42', '[ ] Fix bug due:2026-03-15 gh:user/repo#42',
} }
diff.apply(lines, s) diff.apply(lines, s)
s:load() s:load()

View file

@ -230,7 +230,7 @@ describe('filter', function()
end end
local hidden_ids = { [hidden_task.id] = true } local hidden_ids = { [hidden_task.id] = true }
local lines = { local lines = {
'/1/- [ ] Visible task', '/1/[ ] Visible task',
} }
diff.apply(lines, s, hidden_ids) diff.apply(lines, s, hidden_ids)
s:load() s:load()
@ -254,7 +254,7 @@ describe('filter', function()
end end
end end
local lines = { local lines = {
'/' .. keep_task.id .. '/- [ ] Keep task', '/' .. keep_task.id .. '/[ ] Keep task',
} }
diff.apply(lines, s, {}) diff.apply(lines, s, {})
s:load() s:load()
@ -270,7 +270,7 @@ describe('filter', function()
local task = tasks[1] local task = tasks[1]
local lines = { local lines = {
'FILTER: cat:Work', 'FILTER: cat:Work',
'/' .. task.id .. '/- [ ] My task', '/' .. task.id .. '/[ ] My task',
} }
diff.apply(lines, s, {}) diff.apply(lines, s, {})
s:load() s:load()
@ -281,7 +281,7 @@ describe('filter', function()
it('parse_buffer skips FILTER: header line', function() it('parse_buffer skips FILTER: header line', function()
local lines = { local lines = {
'FILTER: overdue', 'FILTER: overdue',
'/1/- [ ] A task', '/1/[ ] A task',
} }
local result = diff.parse_buffer(lines) local result = diff.parse_buffer(lines)
assert.are.equal(1, #result) assert.are.equal(1, #result)

View file

@ -590,7 +590,7 @@ describe('forge diff integration', function()
local tmp = os.tmpname() local tmp = os.tmpname()
local s = store.new(tmp) local s = store.new(tmp)
s:load() s:load()
diff.apply({ '- [ ] Fix bug gh:user/repo#42' }, s) diff.apply({ '[ ] Fix bug gh:user/repo#42' }, s)
local tasks = s:active_tasks() local tasks = s:active_tasks()
assert.equals(1, #tasks) assert.equals(1, #tasks)
assert.equals('Fix bug gh:user/repo#42', tasks[1].description) assert.equals('Fix bug gh:user/repo#42', tasks[1].description)
@ -607,7 +607,7 @@ describe('forge diff integration', function()
s:load() s:load()
local task = s:add({ description = 'Fix bug' }) local task = s:add({ description = 'Fix bug' })
s:save() s:save()
diff.apply({ '/' .. task.id .. '/- [ ] Fix bug gh:user/repo#10' }, s) diff.apply({ '/' .. task.id .. '/[ ] Fix bug gh:user/repo#10' }, s)
local updated = s:get(task.id) local updated = s:get(task.id)
assert.equals('Fix bug gh:user/repo#10', updated.description) assert.equals('Fix bug gh:user/repo#10', updated.description)
assert.is_not_nil(updated._extra) assert.is_not_nil(updated._extra)
@ -634,7 +634,7 @@ describe('forge diff integration', function()
}, },
}) })
s:save() s:save()
diff.apply({ '/' .. task.id .. '/- [ ] Fix bug' }, s) diff.apply({ '/' .. task.id .. '/[ ] Fix bug' }, s)
local updated = s:get(task.id) local updated = s:get(task.id)
assert.is_not_nil(updated._extra._forge_ref) assert.is_not_nil(updated._extra._forge_ref)
assert.equals(1, updated._extra._forge_ref.number) assert.equals(1, updated._extra._forge_ref.number)
@ -645,7 +645,7 @@ describe('forge diff integration', function()
local tmp = os.tmpname() local tmp = os.tmpname()
local s = store.new(tmp) local s = store.new(tmp)
s:load() s:load()
diff.apply({ '- [ ] Check out gh:user/repo' }, s) diff.apply({ '[ ] Check out gh:user/repo' }, s)
local tasks = s:active_tasks() local tasks = s:active_tasks()
assert.equals(1, #tasks) assert.equals(1, #tasks)
assert.is_not_nil(tasks[1]._extra) assert.is_not_nil(tasks[1]._extra)

View file

@ -17,92 +17,92 @@ describe('textobj', function()
describe('inner_task_range', function() describe('inner_task_range', function()
it('returns description range for task with id prefix', function() it('returns description range for task with id prefix', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries') local s, e = textobj.inner_task_range('/1/[ ] Buy groceries')
assert.are.equal(10, s) assert.are.equal(8, s)
assert.are.equal(22, e) assert.are.equal(20, e)
end) end)
it('returns description range for task without id prefix', function() it('returns description range for task without id prefix', function()
local s, e = textobj.inner_task_range('- [ ] Buy groceries') local s, e = textobj.inner_task_range('[ ] Buy groceries')
assert.are.equal(7, s) assert.are.equal(5, s)
assert.are.equal(19, e) assert.are.equal(17, e)
end) end)
it('excludes trailing due: token', function() it('excludes trailing due: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries due:2026-03-15') local s, e = textobj.inner_task_range('/1/[ ] Buy groceries due:2026-03-15')
assert.are.equal(10, s) assert.are.equal(8, s)
assert.are.equal(22, e) assert.are.equal(20, e)
end) end)
it('excludes trailing cat: token', function() it('excludes trailing cat: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries cat:Errands') local s, e = textobj.inner_task_range('/1/[ ] Buy groceries cat:Errands')
assert.are.equal(10, s) assert.are.equal(8, s)
assert.are.equal(22, e) assert.are.equal(20, e)
end) end)
it('excludes trailing rec: token', function() it('excludes trailing rec: token', function()
local s, e = textobj.inner_task_range('/1/- [ ] Take out trash rec:weekly') local s, e = textobj.inner_task_range('/1/[ ] Take out trash rec:weekly')
assert.are.equal(10, s) assert.are.equal(8, s)
assert.are.equal(23, e) assert.are.equal(21, e)
end) end)
it('excludes multiple trailing metadata tokens', function() it('excludes multiple trailing metadata tokens', function()
local s, e = local s, e =
textobj.inner_task_range('/1/- [ ] Buy milk due:2026-03-15 cat:Errands rec:weekly') textobj.inner_task_range('/1/[ ] Buy milk due:2026-03-15 cat:Errands rec:weekly')
assert.are.equal(10, s) assert.are.equal(8, s)
assert.are.equal(17, e) assert.are.equal(15, e)
end) end)
it('handles priority checkbox', function() it('handles priority checkbox', function()
local s, e = textobj.inner_task_range('/1/- [!] Important task') local s, e = textobj.inner_task_range('/1/[!] Important task')
assert.are.equal(10, s) assert.are.equal(8, s)
assert.are.equal(23, e) assert.are.equal(21, e)
end) end)
it('handles done checkbox', function() it('handles done checkbox', function()
local s, e = textobj.inner_task_range('/1/- [x] Finished task') local s, e = textobj.inner_task_range('/1/[x] Finished task')
assert.are.equal(10, s) assert.are.equal(8, s)
assert.are.equal(22, e)
end)
it('handles multi-digit task ids', function()
local s, e = textobj.inner_task_range('/123/- [ ] Some task')
assert.are.equal(12, s)
assert.are.equal(20, e) assert.are.equal(20, e)
end) end)
it('does not strip non-metadata tokens', function() it('handles multi-digit task ids', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner') local s, e = textobj.inner_task_range('/123/[ ] Some task')
assert.are.equal(10, s) assert.are.equal(10, s)
assert.are.equal(33, e) assert.are.equal(18, e)
end)
it('does not strip non-metadata tokens', function()
local s, e = textobj.inner_task_range('/1/[ ] Buy groceries for dinner')
assert.are.equal(8, s)
assert.are.equal(31, e)
end) end)
it('stops stripping at first non-metadata token from right', function() it('stops stripping at first non-metadata token from right', function()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries for dinner due:2026-03-15') local s, e = textobj.inner_task_range('/1/[ ] Buy groceries for dinner due:2026-03-15')
assert.are.equal(10, s) assert.are.equal(8, s)
assert.are.equal(33, e) assert.are.equal(31, e)
end) end)
it('respects custom date_syntax', function() it('respects custom date_syntax', function()
vim.g.pending = { date_syntax = 'by' } vim.g.pending = { date_syntax = 'by' }
config.reset() config.reset()
local s, e = textobj.inner_task_range('/1/- [ ] Buy groceries by:2026-03-15') local s, e = textobj.inner_task_range('/1/[ ] Buy groceries by:2026-03-15')
assert.are.equal(10, s) assert.are.equal(8, s)
assert.are.equal(22, e) assert.are.equal(20, e)
end) end)
it('respects custom recur_syntax', function() it('respects custom recur_syntax', function()
vim.g.pending = { recur_syntax = 'repeat' } vim.g.pending = { recur_syntax = 'repeat' }
config.reset() config.reset()
local s, e = textobj.inner_task_range('/1/- [ ] Take trash repeat:weekly') local s, e = textobj.inner_task_range('/1/[ ] Take trash repeat:weekly')
assert.are.equal(10, s) assert.are.equal(8, s)
assert.are.equal(19, e) assert.are.equal(17, e)
end) end)
it('handles task with only metadata after description', function() it('handles task with only metadata after description', function()
local s, e = textobj.inner_task_range('/1/- [ ] X due:tomorrow') local s, e = textobj.inner_task_range('/1/[ ] X due:tomorrow')
assert.are.equal(10, s) assert.are.equal(8, s)
assert.are.equal(10, e) assert.are.equal(8, e)
end) end)
end) end)

View file

@ -112,10 +112,10 @@ describe('views', function()
task_line = lines[i] task_line = lines[i]
end end
end end
assert.are.equal('/1/- [ ] My task', task_line) assert.are.equal('/1/[ ] My task', task_line)
end) end)
it('formats priority task lines as /ID/- [!] description', function() it('formats priority task lines as /ID/[!] description', function()
s:add({ description = 'Important', category = 'Inbox', priority = 1 }) s:add({ description = 'Important', category = 'Inbox', priority = 1 })
local lines, meta = views.category_view(s:active_tasks()) local lines, meta = views.category_view(s:active_tasks())
local task_line local task_line
@ -124,7 +124,7 @@ describe('views', function()
task_line = lines[i] task_line = lines[i]
end end
end end
assert.are.equal('/1/- [!] Important', task_line) assert.are.equal('/1/[!] Important', task_line)
end) end)
it('sets LineMeta type=header for header lines with correct category', function() it('sets LineMeta type=header for header lines with correct category', function()
@ -352,10 +352,10 @@ describe('views', function()
assert.is_true(earlier_row < later_row) assert.is_true(earlier_row < later_row)
end) end)
it('formats task lines as /ID/- [ ] description', function() it('formats task lines as /ID/[ ] description', function()
s:add({ description = 'My task', category = 'Inbox' }) s:add({ description = 'My task', category = 'Inbox' })
local lines, _ = views.priority_view(s:active_tasks()) local lines, _ = views.priority_view(s:active_tasks())
assert.are.equal('/1/- [ ] My task', lines[1]) assert.are.equal('/1/[ ] My task', lines[1])
end) end)
it('sets show_category=true for all task meta entries', function() it('sets show_category=true for all task meta entries', function()