1use chrono::{Duration, NaiveDate};
10
11use crate::{AgdError, ErrorCode, Item};
12
13use super::engine::AgendaEngine;
14use super::sort::compare_items;
15use crate::date::parse_org_date;
16
17impl<'a> AgendaEngine<'a> {
18 pub fn query(&self, expression: &str, today: NaiveDate) -> Result<Vec<Item>, AgdError> {
19 let expression = QlExpr::parse(expression)?;
20 let mut items: Vec<Item> = self
21 .index
22 .items()
23 .filter(|item| expression.matches(item, today))
24 .cloned()
25 .collect();
26 items.sort_by(|left, right| compare_items(left, right, self.index));
27 Ok(items)
28 }
29}
30
31const QL_PREDICATES: &str = "(and ...), (or ...), (not ...), (todo), (todo \"KEYWORD\" ...), \
34(done), (tags \"TAG\"), (priority \"A\"), (category \"NAME\"), (text \"WORDS\"), \
35(deadline :to DAYS), (scheduled :to DAYS)";
36
37#[derive(Clone, Debug)]
41enum QlExpr {
42 And(Vec<QlExpr>),
43 Or(Vec<QlExpr>),
44 Not(Box<QlExpr>),
45 Todo(Vec<String>),
47 Tags(String),
48 Priority(String),
49 Category(String),
50 Text(String),
51 Deadline(i64),
52 Scheduled(i64),
53 Done,
54}
55
56impl QlExpr {
57 fn parse(input: &str) -> Result<Self, AgdError> {
58 let tokens = tokenize(input)?;
59 let mut cursor = 0;
60 let expression = parse_ql_expression(&tokens, &mut cursor)?;
61 if cursor != tokens.len() {
62 return Err(invalid_query(
63 "expected the query to end after its first predicate; combine predicates with (and ...) or (or ...)",
64 ));
65 }
66 Ok(expression)
67 }
68
69 fn matches(&self, item: &Item, today: NaiveDate) -> bool {
70 match self {
71 Self::And(expressions) => expressions
72 .iter()
73 .all(|expression| expression.matches(item, today)),
74 Self::Or(expressions) => expressions
75 .iter()
76 .any(|expression| expression.matches(item, today)),
77 Self::Not(expression) => !expression.matches(item, today),
78 Self::Todo(keywords) if keywords.is_empty() => item.todo.is_some() && !item.done,
79 Self::Todo(keywords) => item
80 .todo
81 .as_ref()
82 .is_some_and(|todo| keywords.contains(todo)),
83 Self::Tags(tag) => item
84 .tags
85 .iter()
86 .chain(&item.inherited_tags)
87 .any(|value| value == tag),
88 Self::Priority(priority) => item.priority.as_ref() == Some(priority),
89 Self::Category(category) => item.category == *category,
90 Self::Text(text) => item.title.to_lowercase().contains(&text.to_lowercase()),
91 Self::Deadline(days) => item
92 .planning
93 .deadline
94 .as_ref()
95 .and_then(|timestamp| parse_org_date(×tamp.date).ok())
96 .is_some_and(|date| date <= today + Duration::days(*days)),
97 Self::Scheduled(days) => item
98 .planning
99 .scheduled
100 .as_ref()
101 .and_then(|timestamp| parse_org_date(×tamp.date).ok())
102 .is_some_and(|date| date <= today + Duration::days(*days)),
103 Self::Done => item.done,
104 }
105 }
106}
107
108#[derive(Clone, Debug, Eq, PartialEq)]
109enum Token {
110 Open,
111 Close,
112 Word(String),
113 String(String),
114}
115
116fn tokenize(input: &str) -> Result<Vec<Token>, AgdError> {
117 let mut tokens = Vec::new();
118 let mut chars = input.char_indices().peekable();
119 while let Some((_, character)) = chars.next() {
120 match character {
121 '(' => tokens.push(Token::Open),
122 ')' => tokens.push(Token::Close),
123 '"' => {
124 let mut value = String::new();
125 let mut closed = false;
126 while let Some((_, character)) = chars.next() {
127 if character == '"' {
128 closed = true;
129 break;
130 }
131 if character == '\\' {
132 let Some((_, escaped)) = chars.next() else {
133 break;
134 };
135 value.push(escaped);
136 } else {
137 value.push(character);
138 }
139 }
140 if !closed {
141 return Err(invalid_query("expected a closing `\"` for a string"));
142 }
143 tokens.push(Token::String(value));
144 }
145 character if character.is_whitespace() => {}
146 _ => {
147 let mut value = String::from(character);
148 while let Some((_, next)) = chars.peek() {
149 if next.is_whitespace() || matches!(next, '(' | ')') {
150 break;
151 }
152 value.push(*next);
153 chars.next();
154 }
155 tokens.push(Token::Word(value));
156 }
157 }
158 }
159 Ok(tokens)
160}
161
162fn parse_ql_expression(tokens: &[Token], cursor: &mut usize) -> Result<QlExpr, AgdError> {
163 expect(tokens, cursor, Token::Open, "`(` to start a predicate")?;
164 let operator = word(tokens, cursor, "a predicate name after `(`")?;
165 let expression = match operator.as_str() {
166 "and" | "or" => {
167 let mut children = Vec::new();
168 while !matches!(tokens.get(*cursor), Some(Token::Close) | None) {
169 children.push(parse_ql_expression(tokens, cursor)?);
170 }
171 if children.is_empty() {
172 return Err(invalid_query(format!(
173 "expected one or more predicates inside ({operator} ...)"
174 )));
175 }
176 if operator == "and" {
177 QlExpr::And(children)
178 } else {
179 QlExpr::Or(children)
180 }
181 }
182 "not" => QlExpr::Not(Box::new(parse_ql_expression(tokens, cursor)?)),
183 "todo" => {
184 let mut keywords = Vec::new();
185 while let Some(Token::String(keyword)) = tokens.get(*cursor) {
186 keywords.push(keyword.clone());
187 *cursor += 1;
188 }
189 QlExpr::Todo(keywords)
190 }
191 "tags" => QlExpr::Tags(string_value(tokens, cursor, "(tags \"work\")")?),
192 "priority" => QlExpr::Priority(string_value(tokens, cursor, "(priority \"A\")")?),
193 "category" => QlExpr::Category(string_value(tokens, cursor, "(category \"home\")")?),
194 "text" => QlExpr::Text(string_value(tokens, cursor, "(text \"report\")")?),
195 "done" => QlExpr::Done,
196 "deadline" | "scheduled" => {
197 let usage = format!("`:to DAYS`, as in ({operator} :to 7)");
198 if word(tokens, cursor, &usage)? != ":to" {
199 return Err(invalid_query(format!("expected {usage}")));
200 }
201 let days = word(tokens, cursor, &usage)?
202 .parse::<i64>()
203 .map_err(|_| invalid_query(format!("expected {usage}")))?;
204 if operator == "deadline" {
205 QlExpr::Deadline(days)
206 } else {
207 QlExpr::Scheduled(days)
208 }
209 }
210 _ => {
211 return Err(invalid_query(format!("unknown predicate `{operator}`")));
212 }
213 };
214 expect(
215 tokens,
216 cursor,
217 Token::Close,
218 &format!("`)` to close ({operator} ...)"),
219 )?;
220 Ok(expression)
221}
222
223fn invalid_query(problem: impl std::fmt::Display) -> AgdError {
226 AgdError::new(
227 ErrorCode::InvalidQuery,
228 format!("{problem}; supported predicates: {QL_PREDICATES}"),
229 )
230}
231
232fn expect(
233 tokens: &[Token],
234 cursor: &mut usize,
235 expected: Token,
236 what: &str,
237) -> Result<(), AgdError> {
238 if tokens.get(*cursor) == Some(&expected) {
239 *cursor += 1;
240 Ok(())
241 } else {
242 Err(invalid_query(format!("expected {what}")))
243 }
244}
245
246fn word(tokens: &[Token], cursor: &mut usize, what: &str) -> Result<String, AgdError> {
247 match tokens.get(*cursor) {
248 Some(Token::Word(value)) => {
249 *cursor += 1;
250 Ok(value.clone())
251 }
252 _ => Err(invalid_query(format!("expected {what}"))),
253 }
254}
255
256fn string_value(tokens: &[Token], cursor: &mut usize, example: &str) -> Result<String, AgdError> {
257 match tokens.get(*cursor) {
258 Some(Token::String(value)) => {
259 *cursor += 1;
260 Ok(value.clone())
261 }
262 _ => Err(invalid_query(format!(
263 "expected a quoted string, as in {example}"
264 ))),
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use crate::agenda::test_support::fixture;
272
273 #[test]
274 fn evaluates_documented_org_ql_subset() {
275 let (_temp, index) = fixture(
276 "#+TODO: TODO NEXT | DONE\n* NEXT [#A] Report :work:\nDEADLINE: <2026-10-16 Fri>\n* TODO Other :personal:\n",
277 );
278 let items = AgendaEngine::new(&index)
279 .query(
280 "(and (todo \"NEXT\") (tags \"work\") (deadline :to 7))",
281 NaiveDate::from_ymd_opt(2026, 10, 13).unwrap(),
282 )
283 .unwrap();
284 assert_eq!(items.len(), 1);
285 assert_eq!(items[0].title, "Report");
286 }
287
288 #[test]
289 fn org_ql_deadline_to_includes_past_dates_and_bare_todo_means_not_done() {
290 let (_temp, index) = fixture(
291 "#+TODO: TODO NEXT WAIT | DONE\n* TODO Overdue\nDEADLINE: <2026-10-11 Sun>\n* NEXT Soon\nDEADLINE: <2026-10-20 Tue>\n* WAIT Later\nDEADLINE: <2026-10-21 Wed>\n* DONE Finished\nDEADLINE: <2026-10-12 Mon>\n* Plain note\n",
292 );
293 let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
294 let titles = |expression: &str| -> Vec<String> {
295 AgendaEngine::new(&index)
296 .query(expression, today)
297 .unwrap()
298 .into_iter()
299 .map(|item| item.title)
300 .collect()
301 };
302 assert_eq!(titles("(deadline :to 7)"), ["Overdue", "Soon", "Finished"]);
304 assert_eq!(titles("(scheduled :to 7)"), Vec::<String>::new());
305 assert_eq!(titles("(todo)"), ["Overdue", "Soon", "Later"]);
306 assert_eq!(titles("(and (todo) (deadline :to 7))"), ["Overdue", "Soon"]);
307 assert_eq!(titles("(todo \"NEXT\" \"WAIT\")"), ["Soon", "Later"]);
308 }
309
310 #[test]
311 fn org_ql_errors_say_what_was_expected_and_list_the_predicates() {
312 let (_temp, index) = fixture("* TODO Task\n");
313 let today = NaiveDate::from_ymd_opt(2026, 10, 13).unwrap();
314 let error = |expression: &str| {
315 let error = AgendaEngine::new(&index)
316 .query(expression, today)
317 .unwrap_err();
318 assert_eq!(error.code().as_str(), "invalid_query");
319 error.to_string()
320 };
321 let unknown = error("(heading \"x\")");
322 assert!(
323 unknown.starts_with("unknown predicate `heading`; supported predicates: (and ...)")
324 );
325 assert!(unknown.contains("(todo \"KEYWORD\" ...)"));
326 assert!(unknown.contains("(deadline :to DAYS)"));
327 assert!(
328 error("(tags work)").starts_with("expected a quoted string, as in (tags \"work\")")
329 );
330 assert!(error("(deadline 7)").starts_with("expected `:to DAYS`, as in (deadline :to 7)"));
331 assert!(error("todo").starts_with("expected `(` to start a predicate"));
332 assert!(error("(and (todo)").starts_with("expected `)` to close (and ...)"));
333 assert!(error("(todo) (done)").starts_with("expected the query to end"));
334 }
335}