diff --git a/lua/orgmode/objects/date.lua b/lua/orgmode/objects/date.lua index 4807aec65..2435a27ea 100644 --- a/lua/orgmode/objects/date.lua +++ b/lua/orgmode/objects/date.lua @@ -25,7 +25,28 @@ local time_format = '%H:%M' ---@field related_date_range Date ---@field dayname string ---@field adjustments string[] -local Date = {} +local Date = { + ---@type fun(this: Date, other: Date): boolean + __eq = function(this, other) + return this.timestamp == other.timestamp + end, + ---@type fun(this: Date, other: Date): boolean + __lt = function(this, other) + return this.timestamp < other.timestamp + end, + ---@type fun(this: Date, other: Date): boolean + __le = function(this, other) + return this.timestamp <= other.timestamp + end, + ---@type fun(this: Date, other: Date): boolean + __gt = function(this, other) + return this.timestamp > other.timestamp + end, + ---@type fun(this: Date, other: Date): boolean + __ge = function(this, other) + return this.timestamp >= other.timestamp + end, +} ---@param source table ---@param target? table @@ -166,6 +187,12 @@ local function today(data) return Date:new(opts) end +---@return Date +local function tomorrow() + local today_date = today() + return today_date:adjust('+1d') +end + ---@param data? table ---@return Date local function now(data) @@ -909,6 +936,7 @@ return { from_string = from_string, now = now, today = today, + tomorrow = tomorrow, parse_all_from_line = parse_all_from_line, is_valid_date = is_valid_date, is_date_instance = is_date_instance, diff --git a/lua/orgmode/parser/file.lua b/lua/orgmode/parser/file.lua index 41a81d24b..77a55018c 100644 --- a/lua/orgmode/parser/file.lua +++ b/lua/orgmode/parser/file.lua @@ -205,9 +205,17 @@ function File:apply_search(search, todo_only) if item:is_archived() or (todo_only and not item:is_todo()) then return false end + + local deadline = item:get_deadline_date() + local scheduled = item:get_scheduled_date() + local closed = item:get_closed_date() + return search:check({ props = vim.tbl_extend('keep', {}, item.properties.items, { category = item.category, + deadline = deadline and deadline:to_wrapped_string(true), + scheduled = scheduled and scheduled:to_wrapped_string(true), + closed = closed and closed:to_wrapped_string(false), }), tags = item.tags, todo = item.todo_keyword.value, diff --git a/lua/orgmode/parser/search.lua b/lua/orgmode/parser/search.lua index 20162a8c1..a3265801e 100644 --- a/lua/orgmode/parser/search.lua +++ b/lua/orgmode/parser/search.lua @@ -1,139 +1,629 @@ ---TODO: ---Support regex search and date search +--TODO: Support regex search + +local Date = require('orgmode.objects.date') ---@class Search ---@field term string ---@field expressions table ----@field logic table ----@field todo_search table +---@field or_items OrItem[] +---@field todo_search? TodoMatch local Search = {} +---@class Searchable +---@field props table +---@field tags string|string[] +---@field todo string + +---@class OrItem +---@field and_items AndItem[] +local OrItem = {} +OrItem.__index = OrItem + +---@class AndItem +---@field contains Matchable[] +---@field excludes Matchable[] +local AndItem = {} +AndItem.__index = AndItem + +---@alias Matchable TagMatch|PropertyMatch + +---@class TagMatch +---@field value string +local TagMatch = {} +TagMatch.__index = TagMatch + +---@alias PropertyMatch PropertyDateMatch|PropertyStringMatch|PropertyNumberMatch +local PropertyMatch = {} +PropertyMatch.__index = PropertyMatch + +---@alias PropertyMatchOperator '='|'<>'|'<'|'<='|'>'|'>=' + +---@class PropertyDateMatch +---@field name string +---@field operator PropertyMatchOperator +---@field value Date +local PropertyDateMatch = {} +PropertyDateMatch.__index = PropertyDateMatch + +---@class PropertyStringMatch +---@field name string +---@field operator PropertyMatchOperator +---@field value string +local PropertyStringMatch = {} +PropertyStringMatch.__index = PropertyStringMatch + +---@class PropertyNumberMatch +---@field name string +---@field operator PropertyMatchOperator +---@field value number +local PropertyNumberMatch = {} +PropertyNumberMatch.__index = PropertyNumberMatch + +---@class TodoMatch +---@field anyOf string[] +---@field noneOf string[] +local TodoMatch = {} +TodoMatch.__index = TodoMatch + +---@type table +local OPERATORS = { + ['='] = function(a, b) + local result = a == b + return result + end, + ['<='] = function(a, b) + return a <= b + end, + ['<'] = function(a, b) + return a < b + end, + ['>='] = function(a, b) + return a >= b + end, + ['>'] = function(a, b) + return a > b + end, + ['<>'] = function(a, b) + return a ~= b + 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) + ---@type Search local data = { term = term, expressions = {}, - logic = {}, + or_items = {}, todo_search = nil, } setmetatable(data, self) self.__index = self data:_parse() + return data end +---@param item Searchable +---@return boolean function Search:check(item) - for _, or_item in ipairs(self.logic) do - local passes = self:_check_or(or_item, item) - if passes then - return true + local ors_match = false + for _, or_item in ipairs(self.or_items) do + if or_item:match(item) then + ors_match = true + break end end - return false + + local todos_match + if self.todo_search then + todos_match = self.todo_search:match(item) + else + todos_match = true + end + + return ors_match and todos_match end -function Search:_check_or(or_item, item) - for _, val in ipairs(or_item.contains) do - if not self:_matches(val, item) then +---@private +function Search:_parse() + local input = self.term + -- Parse the sequence of ORs + self.or_items, input = parse_delimited_sequence(input, function(i) + return OrItem:parse(i) + end, '%|') + + -- If the sequence failed to parse, reset the array + self.or_items = self.or_items or {} + + -- Parse the TODO word filters if present + self.todo_search, input = TodoMatch:parse(input) +end + +---@private +---@return OrItem +function OrItem:_new() + ---@type OrItem + local or_item = { + and_items = {}, + } + + setmetatable(or_item, OrItem) + return or_item +end + +---@param input string +---@return OrItem?, string +function OrItem:parse(input) + ---@type AndItem[]? + local and_items + local original_input = input + + and_items, input = parse_delimited_sequence(input, function(i) + return AndItem:parse(i) + end, '%&') + + if not and_items then + return nil, original_input + end + + local or_item = OrItem:_new() + or_item.and_items = and_items + + return or_item, input +end + +---Verifies that each AndItem contained within the OrItem matches +---@param item Searchable +---@return boolean +function OrItem:match(item) + for _, and_item in ipairs(self.and_items) do + if not and_item:match(item) then return false end end - for _, val in ipairs(or_item.excludes) do - if self:_matches(val, item) then + return true +end + +---@private +---@return AndItem +function AndItem:_new() + ---@type AndItem + local and_item = { + contains = {}, + excludes = {}, + } + + setmetatable(and_item, AndItem) + return and_item +end + +---@param input string +---@return AndItem?, string +function AndItem:parse(input) + ---@type AndItem + local and_item = AndItem:_new() + ---@type string? + local operator + local original_input = input + + operator, input = parse_pattern(input, '[%+%-]?') + + -- A '+' operator is implied if none is present + if operator == '' then + operator = '+' + end + + while operator do + ---@type Matchable? + local matchable + + -- Try to parse as a PropertyMatch first + matchable, input = PropertyMatch:parse(input) + + -- If it wasn't a property match, then try a tag match + if not matchable then + matchable, input = TagMatch:parse(input) + if not matchable then + return nil, original_input + end + end + + if operator == '+' then + table.insert(and_item.contains, matchable) + elseif operator == '-' then + table.insert(and_item.excludes, matchable) + else + -- This should never happen if I wrote the operator pattern correctly + end + + -- Attempt to parse the next operator + operator, input = parse_pattern(input, '[%+%-]') + end + + return and_item, input +end + +---@param item Searchable +---@return boolean +function AndItem:match(item) + for _, c in ipairs(self.contains) do + if not c:match(item) then return false end end - if self.todo_search then - return self.todo_search:check({ tags = item.todo }) + for _, e in ipairs(self.excludes) do + if e:match(item) then + return false + end end return true end -function Search:_matches(val, item) - local prop_name, operator, prop_val = val:match('([^=<>]*)([=<>]+)([^=<>]*)') - if not prop_name then - if not item.tags then - return false - end - if type(item.tags) == 'table' then - return vim.tbl_contains(item.tags, val) +---@private +---@param tag string +---@return TagMatch +function TagMatch:_new(tag) + ---@type TagMatch + local tag_match = { value = tag } + setmetatable(tag_match, TagMatch) + + return tag_match +end + +---@param input string +---@return TagMatch?, string +function TagMatch:parse(input) + local tag + tag, input = parse_pattern(input, '%w+') + if not tag then + return nil, input + end + + return TagMatch:_new(tag), input +end + +---@param item Searchable +---@return boolean +function TagMatch:match(item) + local item_tags = item.tags + if type(item_tags) == 'string' then + return item_tags == self.value + end + + for _, tag in ipairs(item_tags) do + if tag == self.value then + return true end - return val == item.tags end - prop_name = string.lower(vim.trim(prop_name)) - prop_val = vim.trim(prop_val) - if not item.props or not item.props[prop_name] then - return false + + return false +end + +---@param input string +---@return PropertyMatch?, string +function PropertyMatch:parse(input) + ---@type string?, PropertyMatchOperator? + local name, operator, string_str, number_str, date_str + local original_input = input + + name, input = parse_pattern(input, '[^=<>]+') + if not name then + return nil, original_input end - local item_val = item.props[prop_name] + name = name:lower() - if tonumber(prop_val) then - prop_val = tonumber(prop_val) - item_val = tonumber(item_val) - if not item_val then - return false + operator, input = self:_parse_operator(input) + if not operator then + return nil, original_input + end + + -- Number property + number_str, input = 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, '"(<[^>]+>)"') + if date_str then + ---@type string?, Date? + local date_content, date_value + if date_str == '' then + date_value = Date.today() + elseif date_str == '' then + date_value = Date.tomorrow() + else + -- Parse relative formats (e.g. <+1d>) as well as absolute + date_content = date_str:match('^<([%+%-]%d+[dmyhwM])>$') + if date_content then + date_value = Date.now() + date_value = date_value:adjust(date_content) + else + date_content = date_str:match('^<([^>]+)>$') + if date_content then + date_value = Date.from_string(date_str) + end + end + end + + ---@type Date? + if date_value then + return PropertyDateMatch:new(name, operator, date_value), input + else + -- It could be a string query so reset the parse input + input = date_str .. input end end - if type(prop_val) == 'string' then - prop_val = prop_val:gsub('^"', ''):gsub('"$', '') - end - - local operators = { - ['='] = function(a, b) - return a == b - end, - ['<='] = function(a, b) - return a <= b - end, - ['<'] = function(a, b) - return a < b - end, - ['>='] = function(a, b) - return a >= b - end, - ['>'] = function(a, b) - return a > b - end, - ['<>'] = function(a, b) - return a ~= b - end, + -- String property + string_str, input = parse_pattern(input, '"[^"]+"') + if string_str then + ---@type string + local unquote_string = string_str:match('^"([^"]+)"$') + return PropertyStringMatch:new(name, operator, unquote_string), input + end + + return nil, original_input +end + +---@private +---Parses one of the comparison operators (=, <>, <, <=, >, >=) +---@param input string +---@return PropertyMatchOperator, string +function PropertyMatch:_parse_operator(input) + return parse_pattern_choice(input, '%=', '%<%>', '%<%=', '%<', '%>%=', '%>') --[[@as PropertyMatchOperator]] +end + +---Constructs a PropertyNumberMatch +---@param name string +---@param operator PropertyMatchOperator +---@param value number +---@return PropertyNumberMatch +function PropertyNumberMatch:new(name, operator, value) + ---@type PropertyNumberMatch + local number_match = { + name = name, + operator = operator, + value = value, + } + + setmetatable(number_match, PropertyNumberMatch) + + return number_match +end + +---@param item Searchable +---@return boolean +function PropertyNumberMatch:match(item) + local item_str_value = item.props[self.name] + + -- If the property in question is not a number, it's not a match + local item_num_value = tonumber(item_str_value) + if not item_num_value then + return false + end + + return OPERATORS[self.operator](item_num_value, self.value) +end + +---@param name string +---@param operator PropertyMatchOperator +---@param value Date +---@return PropertyDateMatch +function PropertyDateMatch:new(name, operator, value) + ---@type PropertyDateMatch + local date_match = { + name = name, + operator = operator, + value = value, } - if not operators[operator] then + setmetatable(date_match, PropertyDateMatch) + return date_match +end + +---@param item Searchable +---@return boolean +function PropertyDateMatch:match(item) + local item_value = item.props[self.name] + + -- If the property is missing, then it's not a match + if not item_value then return false end - return operators[operator](item_val, prop_val) + -- Extract the content between the braces/brackets + local date_content = item_value:match('^[<%[]([^>%]]+)[>%]]$') + if not date_content then + return false + end + + ---@type Date? + local item_date = Date.from_string(date_content) + if not item_date then + return false + end + + return OPERATORS[self.operator](item_date, self.value) +end + +---@param name string +---@param operator PropertyMatchOperator +---@param value string +---@return PropertyStringMatch +function PropertyStringMatch:new(name, operator, value) + ---@type PropertyStringMatch + local string_match = { + name = name, + operator = operator, + value = value, + } + + setmetatable(string_match, PropertyStringMatch) + + return string_match +end + +---@param item Searchable +---@return boolean +function PropertyStringMatch:match(item) + local item_value = item.props[self.name] or '' + return OPERATORS[self.operator](item_value, self.value) end ---@private ----@return string -function Search:_parse() - local term = self.term - local todo_search = term:match('/([^/]*)$') - if todo_search then - self.todo_search = Search:new(todo_search) - term = term:gsub('/([^/]*)$', '') - end - for or_item in vim.gsplit(term, '|', true) do - local a = { - contains = {}, - excludes = {}, - } - for and_item in vim.gsplit(or_item, '&', true) do - for op, exp in and_item:gmatch('([%+%-]*)([^%-%+]+)') do - if op == '' or op:match('^%+*$') then - table.insert(a.contains, exp) - else - table.insert(a.excludes, exp) - end +---@return TodoMatch +function TodoMatch:_new() + ---@type TodoMatch + local todo_match = { + anyOf = {}, + noneOf = {}, + } + + setmetatable(todo_match, TodoMatch) + + return todo_match +end + +---@param input string +---@return TodoMatch?, string +function TodoMatch:parse(input) + local original_input = input + + -- Parse the '/' or '/!' prefix that indicates a TodoMatch + ---@type string? + local prefix + prefix, input = parse_pattern(input, '%/[%!]?') + if not prefix then + return nil, original_input + end + + -- Parse a whitelist of keywords + --- @type string[]? + local anyOf + anyOf, input = parse_delimited_sequence(input, function(i) + return parse_pattern(i, '%w+') + end, '%|') + if anyOf and #anyOf > 0 then + -- Successfully parsed the whitelist, return it + local todo_match = TodoMatch:_new() + todo_match.anyOf = anyOf + return todo_match, input + end + + -- Parse a blacklist of keywords + ---@type string? + local negation + negation, input = parse_pattern(input, '-') + if negation then + local negative_items + negative_items, input = parse_delimited_sequence(input, function(i) + return parse_pattern(i, '%w+') + end, '%-') + + if negative_items then + if #negation > 0 then + local todo_match = TodoMatch:_new() + todo_match.noneOf = negative_items + return todo_match, input + else + return nil, original_input end end - table.insert(self.logic, a) + end + + return nil, original_input +end + +---@param item Searchable +---@return boolean +function TodoMatch:match(item) + local item_todo = item.todo + + if #self.anyOf > 0 then + for _, todo_value in ipairs(self.anyOf) do + if item_todo == todo_value then + return true + end + end + + return false + elseif #self.noneOf > 0 then + for _, todo_value in ipairs(self.noneOf) do + if item_todo == todo_value then + return false + end + end + + return true + else + return true end end diff --git a/lua/orgmode/parser/section.lua b/lua/orgmode/parser/section.lua index c07b53d73..4872382e9 100644 --- a/lua/orgmode/parser/section.lua +++ b/lua/orgmode/parser/section.lua @@ -27,13 +27,16 @@ local config = require('orgmode.config') ---@field file string ---@field content string[] ---@field dates Date[] ----@field properties table +---@field properties SectionProperties ---@field tags string[] ---@field own_tags string[] ---@field logbook Logbook ---@field clocked_in boolean local Section = {} +---@class SectionProperties +---@field items table + ---@class SectionTodoKeyword ---@field node unknown ---@field type 'TODO'|'DONE'|'' diff --git a/tests/plenary/parser/search_spec.lua b/tests/plenary/parser/search_spec.lua index 4d5ff1bd8..f123aecfc 100644 --- a/tests/plenary/parser/search_spec.lua +++ b/tests/plenary/parser/search_spec.lua @@ -55,9 +55,36 @@ describe('Search parser', function() result = Search:new('TAGS|TWO+THREE-FOUR&FIVE') assert.are.same({ - { contains = { 'TAGS' }, excludes = {} }, - { contains = { 'TWO', 'THREE', 'FIVE' }, excludes = { 'FOUR' } }, - }, result.logic) + { + and_items = { + { + contains = { + { value = 'TAGS' }, + }, + excludes = {}, + }, + }, + }, + { + and_items = { + { + contains = { + { value = 'TWO' }, + { value = 'THREE' }, + }, + excludes = { + { value = 'FOUR' }, + }, + }, + { + contains = { + { value = 'FIVE' }, + }, + excludes = {}, + }, + }, + }, + }, result.or_items) assert.Is.True(result:check({ tags = { 'TAGS', 'THREE' } })) assert.Is.True(result:check({ tags = { 'TWO', 'THREE', 'FIVE' } })) @@ -66,7 +93,7 @@ describe('Search parser', function() end) it('should parse search term and match string properties and value', function() - local result = Search:new('CATEGORY="test"&MYPROP=myval+WORK') + local result = Search:new('CATEGORY="test"&MYPROP="myval"+WORK') assert.Is.True(result:check({ props = { category = 'test', myprop = 'myval', age = 10 }, tags = { 'WORK', 'OFFICE' }, @@ -127,11 +154,11 @@ describe('Search parser', function() end) it('should search props, tags and todo keywords', function() - local result = Search:new('CATEGORY="test"&MYPROP=myval+WORK/TODO|NEXT') + local result = Search:new('CATEGORY="test"&MYPROP="myval"+WORK/TODO|NEXT') assert.Is.True(result:check({ props = { category = 'test', myprop = 'myval', age = 10 }, tags = { 'WORK', 'OFFICE' }, - todo = { 'TODO' }, + todo = 'TODO', })) assert.Is.True(result:check({ props = { category = 'test', myprop = 'myval', age = 10 }, @@ -141,38 +168,38 @@ describe('Search parser', function() assert.Is.False(result:check({ props = { category = 'test', myprop = 'myval', age = 10 }, tags = { 'WORK', 'OFFICE' }, - todo = { 'DONE' }, + todo = 'DONE', })) result = Search:new('CATEGORY="test"+WORK/-WAITING') assert.Is.True(result:check({ props = { category = 'test' }, tags = { 'WORK' }, - todo = { 'TODO' }, + todo = 'TODO', })) assert.Is.True(result:check({ props = { category = 'test' }, tags = { 'WORK' }, - todo = { 'DONE' }, + todo = 'DONE', })) assert.Is.False(result:check({ props = { category = 'test' }, tags = { 'WORK' }, - todo = { 'WAITING' }, + todo = 'WAITING', })) assert.Is.False(result:check({ props = { category = 'test_bad' }, tags = { 'WORK' }, - todo = { 'DONE' }, + todo = 'DONE', })) assert.Is.False(result:check({ props = { category = 'test' }, tags = { 'OFFICE' }, - todo = { 'DONE' }, + todo = 'DONE', })) end) end)