//! The parser of the query language of the `Tasks` plugin. use notescraft_tasks::{ DateCmp, DateField, Field, Filter, GroupField, Leaf, Priority, PriorityOp, Query, SortField, SortSpec, StatusFilter, TextField, TextOp, parse_date_word, }; use notescraft_templates::{Date, Weekday}; /// Every filter of the spec, one per line. const FILTERS: &[&str] = &[ "done", "not done", "cancelled", "status.name includes Done", "status.name does not include Done", "status.type is TODO", "status.type is IN_PROGRESS", "status.type is DONE", "status.type is CANCELLED", "has due date", "no due date", "has scheduled date", "no scheduled date", "has start date", "no start date", "has created date", "no created date", "has done date", "no done date", "has cancelled date", "no cancelled date", "has happens date", "no happens date", "due date is invalid", "scheduled date is invalid", "due today", "due before 2026-11-01", "due on or after next monday", "due in this week", "due in 2026-10", "due in 2026-Q4", "due after tomorrow", "due on or before next week", "description includes milk", "description does not include milk", "path includes projects/", "path does not include projects/", "filename includes note", "filename does not include note", "folder includes sub", "folder does not include sub", "root includes vault", "root does not include vault", "heading includes Work", "heading does not include Work", "recurrence includes every week", "recurrence does not include every week", "description regex matches ^Buy", "description regex does not match ^Buy", "no tags", "has tags", "tags include #home", "tags do not include #home", "priority is highest", "priority is high", "priority is medium", "priority is low", "priority is lowest", "priority is none", "priority is not none", "priority above none", "priority above low", "priority below medium", "priority below highest", "is recurring", "is not recurring", "exclude sub-items", ]; /// The date the words of the query are read against. fn today() -> Date { Date::new(2026, 10, 21).unwrap() } #[test] fn every_filter_of_the_spec_parses() { for line in FILTERS { let query = Query::parse(line).unwrap_or_else(|error| panic!("{line}: {error}")); assert!(matches!(query.filters, Filter::Leaf(_)), "{line}: {:?}", query.filters); } } #[test] fn a_filter_is_read_into_its_leaf() { let query = Query::parse("due today").expect("a query"); let Filter::Leaf(Leaf::DateCompare { field, cmp, word }) = query.filters else { panic!("the filter is not a date: {:?}", query.filters); }; assert_eq!(field, DateField::Due); assert_eq!(cmp, DateCmp::In); assert_eq!(word, "today"); let query = Query::parse("not done").expect("a query"); let Filter::Leaf(Leaf::Status(status)) = query.filters else { panic!("the filter is not a status: {:?}", query.filters); }; assert_eq!(status, StatusFilter::NotDone); let query = Query::parse("priority is high").expect("a query"); let Filter::Leaf(Leaf::Priority { op, value }) = query.filters else { panic!("the filter is not a priority: {:?}", query.filters); }; assert_eq!(op, PriorityOp::Is); assert_eq!(value, Priority::High); let query = Query::parse("tags include #home").expect("a query"); let Filter::Leaf(Leaf::Tag { negated, tag }) = query.filters else { panic!("the filter is not a tag: {:?}", query.filters); }; assert!(!negated); assert_eq!(tag, "home"); let query = Query::parse("description regex matches ^Buy").expect("a query"); let Filter::Leaf(Leaf::Text { field, op, value }) = query.filters else { panic!("the filter is not a text: {:?}", query.filters); }; assert_eq!(field, TextField::Description); assert_eq!(op, TextOp::RegexMatches); assert_eq!(value, "^Buy"); } #[test] fn the_boolean_forms_parse() { let query = Query::parse("(due today) AND (priority is high)").expect("a query"); let Filter::And(parts) = &query.filters else { panic!("the filters are not an AND: {:?}", query.filters); }; assert_eq!(parts.len(), 2); let query = Query::parse("done OR cancelled").expect("a query"); let Filter::Or(parts) = &query.filters else { panic!("the filters are not an OR: {:?}", query.filters); }; assert_eq!(parts.len(), 2); let query = Query::parse("NOT done").expect("a query"); assert!(matches!(query.filters, Filter::Not(_))); let query = Query::parse("done AND NOT cancelled").expect("a query"); let Filter::And(parts) = &query.filters else { panic!("the filters are not an AND: {:?}", query.filters); }; assert!(matches!(parts.get(1), Some(Filter::Not(_)))); let query = Query::parse("done OR NOT cancelled").expect("a query"); let Filter::Or(parts) = &query.filters else { panic!("the filters are not an OR: {:?}", query.filters); }; assert!(matches!(parts.get(1), Some(Filter::Not(_)))); let query = Query::parse("(done) XOR (cancelled)").expect("a query"); assert!(matches!(query.filters, Filter::Xor(_, _))); } #[test] fn the_lines_of_a_query_are_read() { let query = Query::parse("sort by due reverse\nsort by priority\ngroup by heading\ngroup by due reverse\nlimit 5\nlimit groups to 2\nhide backlink\nshow urgency\nshort mode\nexplain").expect("a query"); assert_eq!(query.sorts, vec![SortSpec { field: SortField::Due, reverse: true }, SortSpec { field: SortField::Priority, reverse: false }]); assert_eq!(query.groups.len(), 2); assert_eq!(query.groups.first().map(|group| group.field), Some(GroupField::Heading)); assert_eq!(query.limit, Some(5)); assert_eq!(query.limit_groups, Some(2)); assert_eq!(query.fields.len(), 2); assert_eq!(query.fields.first().map(|field| (field.field, field.hidden)), Some((Field::Backlink, true))); assert!(query.short_mode); assert!(query.explain); } #[test] fn the_comments_and_the_empty_lines_are_skipped() { let query = Query::parse("# a comment\n\nnot done\n # another comment").expect("a query"); let Filter::Leaf(Leaf::Status(status)) = query.filters else { panic!("the filter is not a status: {:?}", query.filters); }; assert_eq!(status, StatusFilter::NotDone); } #[test] fn an_unknown_line_is_an_error() { let error = Query::parse("not done\nsort by due\nwhat is this").expect_err("an error"); assert_eq!(error.line, 3); assert!(error.message.contains("unknown filter"), "{}", error.message); } #[test] fn a_query_of_more_than_a_hundred_lines_is_refused() { let src = (0..101).map(|_| "not done".to_owned()).collect::>().join("\n"); let error = Query::parse(&src).expect_err("an error"); assert_eq!(error.line, 101); let src = (0..100).map(|_| "not done".to_owned()).collect::>().join("\n"); Query::parse(&src).expect("a query of a hundred lines"); } #[test] fn a_query_that_is_too_long_is_refused() { let src = format!("not done\n{}", "#".repeat(9000)); let error = Query::parse(&src).expect_err("an error"); assert!(error.message.contains("longer"), "{}", error.message); } #[test] fn a_javascript_filter_is_refused() { let error = Query::parse("filter by function task.description.includes('x')").expect_err("an error"); assert_eq!(error.line, 1); assert!(error.message.contains("not supported"), "{}", error.message); let error = Query::parse("group by function task.file.name").expect_err("an error"); assert!(error.message.contains("not supported"), "{}", error.message); let error = Query::parse("sort by function task.due.format('YYYY')").expect_err("an error"); assert!(error.message.contains("not supported"), "{}", error.message); } #[test] fn a_placeholder_is_recorded() { let query = Query::parse("path includes {{query.file.path}}\nfolder includes {{query.file.folder}}\nfilename includes {{query.file.filename}}").expect("a query"); assert_eq!(query.placeholders.len(), 3); assert_eq!(query.placeholders.first().map(|ph| (ph.name.as_str(), ph.line)), Some(("query.file.path", 1))); assert_eq!(query.placeholders.get(1).map(|ph| (ph.name.as_str(), ph.line)), Some(("query.file.folder", 2))); assert_eq!(query.placeholders.get(2).map(|ph| (ph.name.as_str(), ph.line)), Some(("query.file.filename", 3))); } #[test] fn an_unknown_placeholder_is_an_error() { let error = Query::parse("path includes {{query.file.author}}").expect_err("an error"); assert_eq!(error.line, 1); assert!(error.message.contains("unknown placeholder"), "{}", error.message); } #[test] fn an_invalid_regular_expression_is_an_error() { let error = Query::parse("description regex matches (unclosed").expect_err("an error"); assert_eq!(error.line, 1); assert!(error.message.contains("invalid regular expression"), "{}", error.message); } #[test] fn an_unknown_date_word_is_an_error() { let error = Query::parse("due tommorow").expect_err("an error"); assert_eq!(error.line, 1); assert!(error.message.contains("unknown date"), "{}", error.message); } #[test] fn the_date_words_are_read() { let today = today(); let monday = Weekday::Monday; let day = |year: i32, month: u8, day: u8| Date::new(year, month, day).unwrap(); let range = parse_date_word("2026-10-21", today, monday).expect("a day"); assert_eq!(range.first(), Some(day(2026, 10, 21))); assert_eq!(range.last(), Some(day(2026, 10, 21))); let range = parse_date_word("today", today, monday).expect("today"); assert_eq!(range.first(), Some(today)); let range = parse_date_word("tomorrow", today, monday).expect("tomorrow"); assert_eq!(range.first(), Some(day(2026, 10, 22))); let range = parse_date_word("yesterday", today, monday).expect("yesterday"); assert_eq!(range.first(), Some(day(2026, 10, 20))); let range = parse_date_word("next monday", today, monday).expect("next monday"); assert_eq!(range.first(), Some(day(2026, 10, 26))); let range = parse_date_word("last monday", today, monday).expect("last monday"); assert_eq!(range.first(), Some(day(2026, 10, 19))); let range = parse_date_word("this monday", today, monday).expect("this monday"); assert_eq!(range.first(), Some(day(2026, 10, 19))); let range = parse_date_word("this week", today, monday).expect("this week"); assert_eq!(range.first(), Some(day(2026, 10, 19))); assert_eq!(range.last(), Some(day(2026, 10, 25))); let range = parse_date_word("next week", today, monday).expect("next week"); assert_eq!(range.first(), Some(day(2026, 10, 26))); assert_eq!(range.last(), Some(day(2026, 11, 1))); let range = parse_date_word("last week", today, monday).expect("last week"); assert_eq!(range.first(), Some(day(2026, 10, 12))); let range = parse_date_word("this month", today, monday).expect("this month"); assert_eq!(range.first(), Some(day(2026, 10, 1))); assert_eq!(range.last(), Some(day(2026, 10, 31))); let range = parse_date_word("next month", today, monday).expect("next month"); assert_eq!(range.first(), Some(day(2026, 11, 1))); let range = parse_date_word("last month", today, monday).expect("last month"); assert_eq!(range.first(), Some(day(2026, 9, 1))); let range = parse_date_word("this quarter", today, monday).expect("this quarter"); assert_eq!(range.first(), Some(day(2026, 10, 1))); assert_eq!(range.last(), Some(day(2026, 12, 31))); let range = parse_date_word("next quarter", today, monday).expect("next quarter"); assert_eq!(range.first(), Some(day(2027, 1, 1))); let range = parse_date_word("last quarter", today, monday).expect("last quarter"); assert_eq!(range.first(), Some(day(2026, 7, 1))); let range = parse_date_word("this year", today, monday).expect("this year"); assert_eq!(range.first(), Some(day(2026, 1, 1))); assert_eq!(range.last(), Some(day(2026, 12, 31))); let range = parse_date_word("next year", today, monday).expect("next year"); assert_eq!(range.first(), Some(day(2027, 1, 1))); let range = parse_date_word("last year", today, monday).expect("last year"); assert_eq!(range.first(), Some(day(2025, 1, 1))); let range = parse_date_word("2026-10", today, monday).expect("a month"); assert_eq!(range.first(), Some(day(2026, 10, 1))); assert_eq!(range.last(), Some(day(2026, 10, 31))); let range = parse_date_word("2026-Q4", today, monday).expect("a quarter"); assert_eq!(range.first(), Some(day(2026, 10, 1))); assert_eq!(range.last(), Some(day(2026, 12, 31))); assert!(parse_date_word("someday", today, monday).is_none()); assert!(parse_date_word("", today, monday).is_none()); assert!(parse_date_word("2026-13-40", today, monday).is_none()); }