From 04441f36b655cbc92fa9f6d85b0ce7a6dc542366 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Fl=C3=BCgge?= Date: Thu, 3 Apr 2025 02:07:01 +0200 Subject: [PATCH 1/5] feat(todos): support file-specific todo definitions Support to overwrite the globally defined todo keywords with a file specific one. While emacs orgmode actually supports multiple todo keyword sequences, this implementation explicitly focuses on one sequence, because the plugin currently also supports only one sequence to be defined in the configuration. To support the full emacs functionality this needs to be extended later. --- lua/orgmode/files/file.lua | 15 +++++ lua/orgmode/files/headline.lua | 2 +- lua/orgmode/objects/todo_state.lua | 4 +- lua/orgmode/org/mappings.lua | 3 +- tests/plenary/files/file_spec.lua | 48 ++++++++++++++ tests/plenary/ui/mappings/todo_spec.lua | 88 +++++++++++++++++++++++++ 6 files changed, 156 insertions(+), 4 deletions(-) diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 30046d3a6..4f2ee6745 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -269,6 +269,21 @@ function OrgFile:find_headline_by_title(title) end) end +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(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 diff --git a/lua/orgmode/files/headline.lua b/lua/orgmode/files/headline.lua index 45245f2cb..79ab092d7 100644 --- a/lua/orgmode/files/headline.lua +++ b/lua/orgmode/files/headline.lua @@ -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) diff --git a/lua/orgmode/objects/todo_state.lua b/lua/orgmode/objects/todo_state.lua index 7a7b6b086..41ef7e643 100644 --- a/lua/orgmode/objects/todo_state.lua +++ b/lua/orgmode/objects/todo_state.lua @@ -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 diff --git a/lua/orgmode/org/mappings.lua b/lua/orgmode/org/mappings.lua index df4fe0db1..614e28e9b 100644 --- a/lua/orgmode/org/mappings.lua +++ b/lua/orgmode/org/mappings.lua @@ -1048,7 +1048,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() diff --git a/tests/plenary/files/file_spec.lua b/tests/plenary/files/file_spec.lua index 61002edde..436eff168 100644 --- a/tests/plenary/files/file_spec.lua +++ b/tests/plenary/files/file_spec.lua @@ -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) diff --git a/tests/plenary/ui/mappings/todo_spec.lua b/tests/plenary/ui/mappings/todo_spec.lua index 0e025d5c8..6f2ff2f3a 100644 --- a/tests/plenary/ui/mappings/todo_spec.lua +++ b/tests/plenary/ui/mappings/todo_spec.lua @@ -451,4 +451,92 @@ 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) + 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\"]]) + 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) From b12d4af1b3118afca9a8562b4fea115d27d5a926 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Fl=C3=BCgge?= Date: Thu, 3 Apr 2025 16:47:53 +0200 Subject: [PATCH 2/5] fix: meomize parsed todo keywords Co-authored-by: Kristijan Husak --- lua/orgmode/files/file.lua | 1 + 1 file changed, 1 insertion(+) diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 4f2ee6745..717a91df7 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -269,6 +269,7 @@ 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 From ad26a062533559cd5bace5f4fd144dacc8135d8b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Fl=C3=BCgge?= Date: Thu, 3 Apr 2025 16:49:07 +0200 Subject: [PATCH 3/5] fix: trim spaces Co-authored-by: Kristijan Husak --- lua/orgmode/files/file.lua | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 717a91df7..98a01fc0e 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -276,7 +276,7 @@ function OrgFile:get_todo_keywords() return config:get_todo_keywords() end - local keywords = vim.split(todo_directive, '%s+') + 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, From ba8b7301d29553076515cc91953376c4c19abf7a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Fl=C3=BCgge?= Date: Thu, 3 Apr 2025 18:30:55 +0200 Subject: [PATCH 4/5] fix: support file specific todos in all todo-related commands --- lua/orgmode/org/mappings.lua | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/lua/orgmode/org/mappings.lua b/lua/orgmode/org/mappings.lua index 614e28e9b..3b35105ab 100644 --- a/lua/orgmode/org/mappings.lua +++ b/lua/orgmode/org/mappings.lua @@ -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 .. ' ' @@ -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!]]) From c445736dafc682d09c676afe40692505e0d913bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Fl=C3=BCgge?= Date: Thu, 3 Apr 2025 18:46:35 +0200 Subject: [PATCH 5/5] test: add permutation test for file specific todos --- tests/plenary/ui/mappings/todo_spec.lua | 50 +++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/tests/plenary/ui/mappings/todo_spec.lua b/tests/plenary/ui/mappings/todo_spec.lua index 6f2ff2f3a..51450bca8 100644 --- a/tests/plenary/ui/mappings/todo_spec.lua +++ b/tests/plenary/ui/mappings/todo_spec.lua @@ -495,6 +495,56 @@ describe('Todo mappings', function() '** 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({