diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index 49334e387..b17bf3d5b 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -1,4 +1,5 @@ ---@class DefaultConfig +---@field org_log_done 'time' | 'note' | false local DefaultConfig = { org_agenda_files = '', org_default_notes_file = '', diff --git a/lua/orgmode/parser/search.lua b/lua/orgmode/parser/search.lua index 755a241cc..1baef8d19 100644 --- a/lua/orgmode/parser/search.lua +++ b/lua/orgmode/parser/search.lua @@ -1,6 +1,7 @@ --TODO: Support regex search local Date = require('orgmode.objects.date') +local parsing = require('orgmode.parser.utils') ---@class Search ---@field term string @@ -88,66 +89,6 @@ local OPERATORS = { end, } ----Parses a pattern from the beginning of an input using Lua's pattern syntax ----@param input string ----@param pattern string ----@return string?, string -local function parse_pattern(input, pattern) - local value = input:match('^' .. pattern) - if value then - return value, input:sub(#value + 1) - else - return nil, input - end -end - ----Parses the first of a sequence of patterns ----@param input string The input to parse ----@param ... string The patterns to accept ----@return string?, string -local function parse_pattern_choice(input, ...) - for _, pattern in ipairs({ ... }) do - local value, remaining = parse_pattern(input, pattern) - if value then - return value, remaining - end - end - - return nil, input -end - ----@generic T ----@param input string ----@param item_parser fun(input: string): (T?, string) ----@param delimiter_pattern string ----@return (T[])?, string -local function parse_delimited_sequence(input, item_parser, delimiter_pattern) - local sequence, item, delimiter = {}, nil, nil - local original_input = input - - -- Parse the first item - item, input = item_parser(input) - if not item then - return sequence, input - end - table.insert(sequence, item) - - -- Continue parsing items while there's a trailing delimiter - delimiter, input = parse_pattern(input, delimiter_pattern) - while delimiter do - item, input = item_parser(input) - if not item then - return nil, original_input - end - - table.insert(sequence, item) - - delimiter, input = parse_pattern(input, delimiter_pattern) - end - - return sequence, input -end - ---@param term string ---@return Search function Search:new(term) @@ -190,7 +131,7 @@ end function Search:_parse() local input = self.term -- Parse the sequence of ORs - self.or_items, input = parse_delimited_sequence(input, function(i) + self.or_items, input = parsing.parse_delimited_sequence(input, function(i) return OrItem:parse(i) end, '%|') @@ -220,7 +161,7 @@ function OrItem:parse(input) local and_items local original_input = input - and_items, input = parse_delimited_sequence(input, function(i) + and_items, input = parsing.parse_delimited_sequence(input, function(i) return AndItem:parse(i) end, '%&') @@ -269,7 +210,7 @@ function AndItem:parse(input) local operator local original_input = input - operator, input = parse_pattern(input, '[%+%-]?') + operator, input = parsing.parse_pattern(input, '[%+%-]?') -- A '+' operator is implied if none is present if operator == '' then @@ -300,7 +241,7 @@ function AndItem:parse(input) end -- Attempt to parse the next operator - operator, input = parse_pattern(input, '[%+%-]') + operator, input = parsing.parse_pattern(input, '[%+%-]') end return and_item, input @@ -339,7 +280,7 @@ end ---@return TagMatch?, string function TagMatch:parse(input) local tag - tag, input = parse_pattern(input, '[%w_@#%%]+') + tag, input = parsing.parse_pattern(input, '[%w_@#%%]+') if not tag then return nil, input end @@ -371,7 +312,7 @@ function PropertyMatch:parse(input) local name, operator, string_str, number_str, date_str local original_input = input - name, input = parse_pattern(input, '[^=<>]+') + name, input = parsing.parse_pattern(input, '[^=<>]+') if not name then return nil, original_input end @@ -383,14 +324,14 @@ function PropertyMatch:parse(input) end -- Number property - number_str, input = parse_pattern(input, '%d+') + number_str, input = parsing.parse_pattern(input, '%d+') if number_str then local number = tonumber(number_str) --[[@as number]] return PropertyNumberMatch:new(name, operator, number), input end -- Date property - date_str, input = parse_pattern(input, '"(<[^>]+>)"') + date_str, input = parsing.parse_pattern(input, '"(<[^>]+>)"') if date_str then ---@type string?, Date? local date_content, date_value @@ -422,7 +363,7 @@ function PropertyMatch:parse(input) end -- String property - string_str, input = parse_pattern(input, '"[^"]+"') + string_str, input = parsing.parse_pattern(input, '"[^"]+"') if string_str then ---@type string local unquote_string = string_str:match('^"([^"]+)"$') @@ -437,7 +378,7 @@ end ---@param input string ---@return PropertyMatchOperator, string function PropertyMatch:_parse_operator(input) - return parse_pattern_choice(input, '%=', '%<%>', '%<%=', '%<', '%>%=', '%>') --[[@as PropertyMatchOperator]] + return parsing.parse_pattern_choice(input, '%=', '%<%>', '%<%=', '%<', '%>%=', '%>') --[[@as PropertyMatchOperator]] end ---Constructs a PropertyNumberMatch @@ -559,7 +500,7 @@ function TodoMatch:parse(input) -- Parse the '/' or '/!' prefix that indicates a TodoMatch ---@type string? local prefix - prefix, input = parse_pattern(input, '%/[%!]?') + prefix, input = parsing.parse_pattern(input, '%/[%!]?') if not prefix then return nil, original_input end @@ -567,8 +508,8 @@ function TodoMatch:parse(input) -- Parse a whitelist of keywords --- @type string[]? local anyOf - anyOf, input = parse_delimited_sequence(input, function(i) - return parse_pattern(i, '%w+') + anyOf, input = parsing.parse_delimited_sequence(input, function(i) + return parsing.parse_pattern(i, '%w+') end, '%|') if anyOf and #anyOf > 0 then -- Successfully parsed the whitelist, return it @@ -580,11 +521,11 @@ function TodoMatch:parse(input) -- Parse a blacklist of keywords ---@type string? local negation - negation, input = parse_pattern(input, '-') + negation, input = parsing.parse_pattern(input, '-') if negation then local negative_items - negative_items, input = parse_delimited_sequence(input, function(i) - return parse_pattern(i, '%w+') + negative_items, input = parsing.parse_delimited_sequence(input, function(i) + return parsing.parse_pattern(i, '%w+') end, '%-') if negative_items then diff --git a/lua/orgmode/parser/todo-config.lua b/lua/orgmode/parser/todo-config.lua new file mode 100644 index 000000000..d73820cf0 --- /dev/null +++ b/lua/orgmode/parser/todo-config.lua @@ -0,0 +1,187 @@ +local parsing = require('orgmode.parser.utils') + +--- @class TodoConfig +--- @field words TodoConfigWord[] +local TodoConfig = {} +TodoConfig.__index = TodoConfig + +--- @alias TodoConfigRecordBehavior 'time' | 'note' | false + +--- @class TodoConfigWord +--- @field name string +--- @field is_active boolean +--- @field hotkey string +--- @field on_enter TodoConfigRecordBehavior +--- @field on_leave TodoConfigRecordBehavior +local TodoConfigWord = {} + +--- @param words TodoConfigWord[] +--- @return TodoConfig +function TodoConfig:_new(words) + --- @type TodoConfig + local instance = {} + setmetatable(instance, TodoConfig) + + instance.words = words + + return instance +end + +--- @param input string +--- @return TodoConfig?, string +function TodoConfig:parse(input) + local original = input + + --- @type TodoConfigWord[] + local active + active, input = parsing.parse_delimited_sequence(input, function(inner_input) + return TodoConfigWord:parse(inner_input, true) + end, '%s+') + + if #active == 0 then + return nil, original + end + + local pipe + pipe, input = parsing.parse_pattern(input, '%s*%|%s*') + if pipe == nil then + return nil, original + end + + --- @type TodoConfigWord[] + local inactive + inactive, input = parsing.parse_delimited_sequence(input, function(inner_input) + return TodoConfigWord:parse(inner_input, false) + end, '%s+') + + if #inactive == 0 then + return nil, original + end + + --- @type TodoConfigWord[] + local words = {} + for _, x in ipairs(active) do + table.insert(words, x) + end + for _, x in ipairs(inactive) do + table.insert(words, x) + end + + return TodoConfig:_new(words), input +end + +--- @param from string +--- @param to string +--- @return TodoConfigRecordBehavior +function TodoConfig:get_logging_behavior(from, to) + --- Find the from config + local from_config = self:_find_word(from) + local to_config = self:_find_word(to) + + -- Ensure the described transition is valid + if from_config == nil or to_config == nil then + return false + end + + return to_config.on_enter or from_config.on_leave +end + +--- Finds the word config with the associated name +--- @private +--- @param name string +--- @return TodoConfigWord? +function TodoConfig:_find_word(name) + for _, x in ipairs(self.words) do + if x.name == name then + return x + end + end + + return nil +end + +--- @param name string +--- @param hotkey string +--- @param is_active boolean +--- @param on_enter TodoConfigRecordBehavior +--- @param on_leave TodoConfigRecordBehavior +--- @return TodoConfigWord +function TodoConfigWord:_new(name, is_active, hotkey, on_enter, on_leave) + --- @type TodoConfigWord + local instance = {} + setmetatable(instance, TodoConfigWord) + + instance.name = name + instance.is_active = is_active + instance.hotkey = hotkey + instance.on_enter = on_enter + instance.on_leave = on_leave + + return instance +end + +--- @param input string +--- @param is_active boolean +--- @return TodoConfigWord?, string +function TodoConfigWord:parse(input, is_active) + local original = input + + --- @type string?, string?, string?, string? + local name, open, hotkey, enter, slash, leave, close + + name, input = parsing.parse_pattern(input, '%w+') + if name == nil then + return nil, original + end + + open, input = parsing.parse_pattern(input, '%(') + if open == nil then + return nil, original + end + + hotkey, input = parsing.parse_pattern(input, '%w') + if hotkey == nil then + return nil, original + end + + ---@type TodoConfigRecordBehavior + local on_enter = false + enter, input = parsing.parse_pattern_choice(input, '%@', '%!') + if enter ~= nil then + if enter == '!' then + on_enter = 'time' + elseif enter == '@' then + on_enter = 'note' + else + return nil, original + end + end + + --- @type TodoConfigRecordBehavior + local on_leave = false + slash, input = parsing.parse_pattern(input, '%/') + if slash ~= nil then + leave, input = parsing.parse_pattern_choice(input, '%@', '%!') + if leave == nil then + return nil, original + end + + if leave == '!' then + on_leave = 'time' + elseif leave == '@' then + on_leave = 'note' + else + return nil, original + end + end + + close, input = parsing.parse_pattern(input, '%)') + if close == nil then + return nil, original + end + + local word = TodoConfigWord:_new(name, is_active, hotkey, on_enter, on_leave) + return word, input +end + +return TodoConfig diff --git a/lua/orgmode/parser/utils.lua b/lua/orgmode/parser/utils.lua new file mode 100644 index 000000000..56a7615df --- /dev/null +++ b/lua/orgmode/parser/utils.lua @@ -0,0 +1,69 @@ +local M = {} + +---Parses a pattern from the beginning of an input using Lua's pattern syntax +---@param input string +---@param pattern string +---@return string?, string +function M.parse_pattern(input, pattern) + local value = input:match('^' .. pattern) + if value then + return value, input:sub(#value + 1) + else + return nil, input + end +end + +---Parses the first of a sequence of patterns +---@param input string The input to parse +---@param ... string The patterns to accept +---@return string?, string +function M.parse_pattern_choice(input, ...) + for _, pattern in ipairs({ ... }) do + local value, remaining = M.parse_pattern(input, pattern) + if value then + return value, remaining + end + end + + return nil, input +end + +---@generic T +---@param input string +---@param item_parser fun(input: string): (T?, string) +---@param delimiter_pattern string +---@return (T[])?, string +function M.parse_delimited_sequence(input, item_parser, delimiter_pattern) + local sequence, item, delimiter = {}, nil, nil + local original_input = input + + -- Parse the first item + item, input = item_parser(input) + if not item then + return sequence, input + end + table.insert(sequence, item) + + --- @type string + local snapshot = input + + -- Continue parsing items while there's a trailing delimiter + delimiter, input = M.parse_pattern(input, delimiter_pattern) + while delimiter do + item, input = item_parser(input) + + -- If not another element, eturn the previously parsed items + if not item then + return sequence, snapshot + end + + table.insert(sequence, item) + snapshot = input + + delimiter, input = M.parse_pattern(input, delimiter_pattern) + end + + return sequence, input +end + +return M diff --git a/lua/orgmode/treesitter/headline.lua b/lua/orgmode/treesitter/headline.lua index 48df63637..cf1b67b3c 100644 --- a/lua/orgmode/treesitter/headline.lua +++ b/lua/orgmode/treesitter/headline.lua @@ -217,9 +217,8 @@ function Headline:item() return self.headline:field('item')[1] end --- Returns the headlines todo node, it's keyword, --- and if it's in done state --- @return Node, string, boolean +--- Returns the headlines todo node, it's keyword, and if it's in done state +--- @return Node? node, string? word, boolean? isDone function Headline:todo() local todo_keywords = config:get_todo_keywords() local keywords = todo_keywords.ALL