336 lines
13 KiB
Rust
336 lines
13 KiB
Rust
//! 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::<Vec<String>>().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::<Vec<String>>().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());
|
|
}
|