Skip to content

feat(todos): support file-specific todo definitions #956

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 5 commits into from
Apr 4, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions lua/orgmode/files/file.lua
Original file line number Diff line number Diff line change
Expand Up @@ -269,6 +269,22 @@ function OrgFile:find_headline_by_title(title)
end)
end

memoize('get_todo_keywords')
function OrgFile:get_todo_keywords()
local todo_directive = self:_get_directive('todo')
if not todo_directive then
return config:get_todo_keywords()
end

local keywords = vim.split(vim.trim(todo_directive), '%s+')
local todo_keywords = require('orgmode.objects.todo_keywords'):new({
org_todo_keywords = keywords,
org_todo_keyword_faces = config.org_todo_keyword_faces,
})

return todo_keywords
end

---@return OrgHeadline[]
function OrgFile:get_unfinished_todo_entries()
if self:is_archive_file() then
Expand Down
2 changes: 1 addition & 1 deletion lua/orgmode/files/headline.lua
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ function Headline:get_todo()
return nil, nil, nil
end

local todo_keywords = config:get_todo_keywords()
local todo_keywords = self.file:get_todo_keywords()

local text = self.file:get_node_text(todo_node)
local keyword_by_value = todo_keywords:find(text)
Expand Down
4 changes: 2 additions & 2 deletions lua/orgmode/objects/todo_state.lua
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,11 @@ local TodoKeyword = require('orgmode.objects.todo_keywords.todo_keyword')
---@field todos OrgTodoKeywords
local TodoState = {}

---@param data { current_state: string | nil }
---@param data { current_state: string | nil, todos: table | nil }
---@return OrgTodoState
function TodoState:new(data)
local opts = {}
opts.todos = config:get_todo_keywords()
opts.todos = data.todos or config:get_todo_keywords()
opts.current_state = data.current_state and opts.todos:find(data.current_state) or TodoKeyword:empty()
setmetatable(opts, self)
self.__index = self
Expand Down
11 changes: 7 additions & 4 deletions lua/orgmode/org/mappings.lua
Original file line number Diff line number Diff line change
Expand Up @@ -379,7 +379,7 @@ function OrgMappings:toggle_heading()
line = line:gsub('^(%s*)', '')
if line:match('^[%*-]%s') then -- handle lists
line = line:gsub('^[%*-]%s', '') -- strip bullet
local todo_keywords = config:get_todo_keywords()
local todo_keywords = self.files:get_current_file():get_todo_keywords()
line = line:gsub('^%[([X%s])%]%s', function(checkbox_state)
if checkbox_state == 'X' then
return todo_keywords:first_by_type('DONE').value .. ' '
Expand Down Expand Up @@ -731,12 +731,14 @@ function OrgMappings:insert_heading_respect_content(suffix)
end

function OrgMappings:insert_todo_heading_respect_content()
return self:insert_heading_respect_content(config:get_todo_keywords():first_by_type('TODO').value .. ' ')
local todo_keywords = self.files:get_current_file():get_todo_keywords()
return self:insert_heading_respect_content(todo_keywords:first_by_type('TODO').value .. ' ')
end

function OrgMappings:insert_todo_heading()
local item = self.files:get_closest_headline_or_nil()
local first_todo_keyword = config:get_todo_keywords():first_by_type('TODO')
local todo_keywords = self.files:get_current_file():get_todo_keywords()
local first_todo_keyword = todo_keywords:first_by_type('TODO')
if not item then
self:_insert_heading_from_plain_line(first_todo_keyword.value .. ' ')
return vim.cmd([[startinsert!]])
Expand Down Expand Up @@ -1048,7 +1050,8 @@ end
function OrgMappings:_change_todo_state(direction, use_fast_access)
local headline = self.files:get_closest_headline()
local current_keyword = headline:get_todo()
local todo_state = TodoState:new({ current_state = current_keyword })
local todos = headline.file:get_todo_keywords()
local todo_state = TodoState:new({ current_state = current_keyword, todos = todos })
local next_state = nil
if use_fast_access and todo_state:has_fast_access() then
next_state = todo_state:open_fast_access()
Expand Down
48 changes: 48 additions & 0 deletions tests/plenary/files/file_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -830,4 +830,52 @@ describe('OrgFile', function()
assert.are.same('somevalue', file:get_directive('somedirective'))
end)
end)

describe('get_todos', function()
local has_correct_type = function(todos)
assert.are.same('TODO', todos.todo_keywords[1].type)
assert.are.same('TODO', todos.todo_keywords[2].type)
assert.are.same('DONE', todos.todo_keywords[3].type)
assert.are.same('DONE', todos.todo_keywords[4].type)
end

local has_correct_values = function(todos)
assert.are.same('OPEN', todos.todo_keywords[1].value)
assert.are.same('DOING', todos.todo_keywords[2].value)
assert.are.same('FINISHED', todos.todo_keywords[3].value)
assert.are.same('ABORTED', todos.todo_keywords[4].value)
end
it('should get todo keywords from config by default', function()
config:extend({
org_todo_keywords = { 'TODO', 'DOING', '|', 'DONE', 'CANCELED' },
})
local file = load_file_sync({
'* TODO Headline 1',
})
local todos = file:get_todo_keywords()
assert.are.same({ 'TODO', 'DOING', '|', 'DONE', 'CANCELED' }, todos.org_todo_keywords)
end)

it('should parse custom todo keywords from file directive', function()
local file = load_file_sync({
'#+TODO: OPEN DOING | FINISHED ABORTED',
'* OPEN Headline 1',
})
local todos = file:get_todo_keywords()
has_correct_type(todos)
has_correct_values(todos)
assert.are.same({ 'OPEN', 'DOING', '|', 'FINISHED', 'ABORTED' }, todos.org_todo_keywords)
end)

it('should handle todo keywords with shortcut keys', function()
local file = load_file_sync({
'#+TODO: OPEN(o) DOING(d) | FINISHED(f) ABORTED(a)',
'* OPEN Headline 1',
})
local todos = file:get_todo_keywords()
has_correct_type(todos)
has_correct_values(todos)
assert.are.same({ 'OPEN(o)', 'DOING(d)', '|', 'FINISHED(f)', 'ABORTED(a)' }, todos.org_todo_keywords)
end)
end)
end)
138 changes: 138 additions & 0 deletions tests/plenary/ui/mappings/todo_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -451,4 +451,142 @@ describe('Todo mappings', function()
'** Non-todo item',
}, vim.api.nvim_buf_get_lines(0, 0, 6, false))
end)

it('should respect file-local todo keywords', function()
helpers.create_file({
'#+TODO: OPEN DOING | FINISHED ABORTED',
'* OPEN Test with file-local todo keywords',
'** DOING Subtask',
})

vim.fn.cursor(2, 1)
vim.cmd([[norm cit]])
assert.are.same({
'#+TODO: OPEN DOING | FINISHED ABORTED',
'* DOING Test with file-local todo keywords',
'** DOING Subtask',
}, vim.api.nvim_buf_get_lines(0, 0, 3, false))

vim.cmd([[norm cit]])
local lines = vim.api.nvim_buf_get_lines(0, 0, 4, false)
assert.are.same('#+TODO: OPEN DOING | FINISHED ABORTED', lines[1])
assert.are.same('* FINISHED Test with file-local todo keywords', lines[2])
assert.is_true(lines[3]:match('^%s+CLOSED: %[%d%d%d%d%-%d%d%-%d%d') ~= nil)
assert.are.same('** DOING Subtask', lines[4])

vim.cmd([[norm cit]])
lines = vim.api.nvim_buf_get_lines(0, 0, 4, false)
assert.are.same('#+TODO: OPEN DOING | FINISHED ABORTED', lines[1])
assert.are.same('* ABORTED Test with file-local todo keywords', lines[2])
assert.is_true(lines[3]:match('^%s+CLOSED: %[%d%d%d%d%-%d%d%-%d%d') ~= nil)
assert.are.same('** DOING Subtask', lines[4])

vim.cmd([[norm cit]])
assert.are.same({
'#+TODO: OPEN DOING | FINISHED ABORTED',
'* Test with file-local todo keywords',
'** DOING Subtask',
}, vim.api.nvim_buf_get_lines(0, 0, 3, false))

vim.cmd([[norm cit]])
assert.are.same({
'#+TODO: OPEN DOING | FINISHED ABORTED',
'* OPEN Test with file-local todo keywords',
'** DOING Subtask',
}, vim.api.nvim_buf_get_lines(0, 0, 3, false))
end)
it('should consider locally defined permutation of globally defined todo keywords', function()
local local_todo_definition = '#+TODO: DONE OPEN | DOING'
config:extend({
org_todo_keywords = { 'OPEN', 'DOING', '|', 'DONE' },
org_log_into_drawer = 'LOGBOOK',
org_todo_repeat_to_state = 'MEET',
})
helpers.create_file({
local_todo_definition,
'* Test with file-local todo keywords',
'** DOING Subtask',
})

vim.fn.cursor(2, 1)
vim.cmd([[norm cit]])
assert.are.same({
local_todo_definition,
'* DONE Test with file-local todo keywords',
'** DOING Subtask',
}, vim.api.nvim_buf_get_lines(0, 0, 3, false))

vim.cmd([[norm cit]])
assert.are.same({
local_todo_definition,
'* OPEN Test with file-local todo keywords',
'** DOING Subtask',
}, vim.api.nvim_buf_get_lines(0, 0, 3, false))

vim.cmd([[norm cit]])
local lines = vim.api.nvim_buf_get_lines(0, 0, 4, false)
assert.are.same(local_todo_definition, lines[1])
assert.are.same('* DOING Test with file-local todo keywords', lines[2])
assert.is_true(lines[3]:match('^%s+CLOSED: %[%d%d%d%d%-%d%d%-%d%d') ~= nil)
assert.are.same('** DOING Subtask', lines[4])

vim.cmd([[norm cit]])
assert.are.same({
local_todo_definition,
'* Test with file-local todo keywords',
'** DOING Subtask',
}, vim.api.nvim_buf_get_lines(0, 0, 3, false))

vim.cmd([[norm cit]])
assert.are.same({
local_todo_definition,
'* DONE Test with file-local todo keywords',
'** DOING Subtask',
}, vim.api.nvim_buf_get_lines(0, 0, 3, false))
end)

local todos_with_shortcuts = '#+TODO: OPEN(o) DOING(d) | FINISHED(f) ABORTED(a)'
it('should respect file-local todo keywords with shortcut keys', function()
helpers.create_file({
todos_with_shortcuts,
'* OPEN Test with file-local todo keywords',
'** DOING Subtask',
})

vim.fn.cursor(2, 1)
vim.cmd([[norm citd]])
assert.are.same({
todos_with_shortcuts,
'* DOING Test with file-local todo keywords',
'** DOING Subtask',
}, vim.api.nvim_buf_get_lines(0, 0, 3, false))

vim.cmd([[norm citf]])
local lines = vim.api.nvim_buf_get_lines(0, 0, 4, false)
assert.are.same(todos_with_shortcuts, lines[1])
assert.are.same('* FINISHED Test with file-local todo keywords', lines[2])
assert.is_true(lines[3]:match('^%s+CLOSED: %[%d%d%d%d%-%d%d%-%d%d') ~= nil)
assert.are.same('** DOING Subtask', lines[4])

vim.cmd([[norm cita]])
lines = vim.api.nvim_buf_get_lines(0, 0, 4, false)
assert.are.same(todos_with_shortcuts, lines[1])
assert.are.same('* ABORTED Test with file-local todo keywords', lines[2])
assert.is_true(lines[3]:match('^%s+CLOSED: %[%d%d%d%d%-%d%d%-%d%d') ~= nil)
assert.are.same('** DOING Subtask', lines[4])

vim.cmd([[exe "norm cit\<Space>"]])
assert.are.same({
todos_with_shortcuts,
'* Test with file-local todo keywords',
'** DOING Subtask',
}, vim.api.nvim_buf_get_lines(0, 0, 3, false))

vim.cmd([[norm cito]])
assert.are.same({
todos_with_shortcuts,
'* OPEN Test with file-local todo keywords',
'** DOING Subtask',
}, vim.api.nvim_buf_get_lines(0, 0, 3, false))
end)
end)