use crate::model::item::{Task, TaskStatus};
use chrono::NaiveDate;
#[derive(Debug, Clone)]
enum SearchExpr {
Term(String),
And(Box<SearchExpr>, Box<SearchExpr>),
Or(Box<SearchExpr>, Box<SearchExpr>),
Not(Box<SearchExpr>),
}
pub struct Query {
expr: SearchExpr,
}
pub fn extract_highlight_terms(query: &str) -> Vec<String> {
let tokens = tokenize_query(query);
let mut terms = Vec::new();
let lex_guard = crate::model::parser::LEXICON.read().unwrap();
for t in tokens {
if let Token::Text(s) = t {
let lower = s.to_lowercase();
if lower.starts_with("is:")
|| lex_guard.search_is_done.contains(&lower)
|| lex_guard.search_is_active.contains(&lower)
|| lex_guard.search_is_started.contains(&lower)
|| lex_guard.search_is_ongoing.contains(&lower)
|| lex_guard.search_is_ready.contains(&lower)
|| lex_guard.search_is_blocked.contains(&lower)
|| lex_guard.search_is_note.contains(&lower)
|| lex_guard.search_is_page.contains(&lower)
|| lex_guard.search_is_permanent.contains(&lower)
|| lex_guard.search_is_canceled.contains(&lower)
{
continue;
}
let mut clean_term = s.as_str();
if let Some((pref, _, _)) = lex_guard.match_prefix(&lower) {
clean_term = &s[pref.len()..];
} else if let Some(stripped) = s.strip_prefix('#') {
clean_term = stripped;
} else if let Some(stripped) = s.strip_prefix("@@") {
clean_term = stripped;
} else if let Some(stripped) = s.strip_prefix('-') {
clean_term = stripped;
} else if let Some(stripped) = s.strip_prefix('!') {
clean_term = stripped;
} else if let Some(stripped) = s.strip_prefix('~') {
clean_term = stripped;
}
let clean_term = crate::model::parser::strip_quotes(clean_term);
if !clean_term.trim().is_empty() {
terms.push(regex::escape(clean_term.trim()));
}
}
}
terms
}
impl Query {
pub fn new(query: &str) -> Self {
let tokens = tokenize_query(query);
let mut parser = Parser::new(tokens);
Self {
expr: parser.parse(),
}
}
pub fn matches(
&self,
task: &Task,
lex: &crate::model::parser::ParserLexicon,
store: &crate::store::TaskStore,
) -> bool {
self.expr.matches(task, lex, store)
}
}
impl SearchExpr {
fn matches(
&self,
task: &Task,
lex: &crate::model::parser::ParserLexicon,
store: &crate::store::TaskStore,
) -> bool {
match self {
SearchExpr::Term(s) => {
if s.is_empty() {
true
} else {
task.matches_primitive(s, lex, store)
}
}
SearchExpr::And(a, b) => a.matches(task, lex, store) && b.matches(task, lex, store),
SearchExpr::Or(a, b) => a.matches(task, lex, store) || b.matches(task, lex, store),
SearchExpr::Not(a) => !a.matches(task, lex, store),
}
}
}
#[derive(Debug, PartialEq, Clone)]
enum Token {
Text(String),
Or,
LParen,
RParen,
NotPrefix, }
fn tokenize_query(input: &str) -> Vec<Token> {
let mut tokens = Vec::new();
let mut chars = input.chars().peekable();
while let Some(&c) = chars.peek() {
match c {
' ' | '\t' => {
chars.next();
} '(' => {
tokens.push(Token::LParen);
chars.next();
}
')' => {
tokens.push(Token::RParen);
chars.next();
}
'|' => {
tokens.push(Token::Or);
chars.next();
}
'-' => {
chars.next(); if let Some(&next_c) = chars.peek() {
if next_c.is_whitespace() || next_c == '(' || next_c == ')' || next_c == '|' {
if next_c == '(' {
tokens.push(Token::NotPrefix);
} else {
tokens.push(Token::Text("-".to_string()));
}
} else {
tokens.push(Token::NotPrefix);
}
} else {
tokens.push(Token::Text("-".to_string()));
}
}
_ => {
let mut term = String::new();
let mut in_quote = false;
let mut escaped = false;
while let Some(&c) = chars.peek() {
if escaped {
term.push(c);
chars.next();
escaped = false;
} else if c == '\\' {
chars.next(); escaped = true;
if chars.peek().is_none() {
term.push('\\');
}
} else if c == '"' {
in_quote = !in_quote;
term.push(c);
chars.next();
} else if !in_quote && (c == ' ' || c == '(' || c == ')' || c == '|') {
break;
} else {
term.push(c);
chars.next();
}
}
if !term.is_empty() {
tokens.push(Token::Text(term));
}
}
}
}
tokens
}
struct Parser {
tokens: Vec<Token>,
pos: usize,
}
impl Parser {
fn new(tokens: Vec<Token>) -> Self {
Self { tokens, pos: 0 }
}
fn parse(&mut self) -> SearchExpr {
if self.tokens.is_empty() {
return SearchExpr::Term("".to_string());
}
self.parse_or()
}
fn peek(&self) -> Option<&Token> {
self.tokens.get(self.pos)
}
fn advance(&mut self) {
if self.pos < self.tokens.len() {
self.pos += 1;
}
}
fn parse_or(&mut self) -> SearchExpr {
let mut left = self.parse_and();
while let Some(Token::Or) = self.peek() {
self.advance();
let right = self.parse_and();
left = SearchExpr::Or(Box::new(left), Box::new(right));
}
left
}
fn parse_and(&mut self) -> SearchExpr {
let mut left = self.parse_unary();
while let Some(token) = self.peek() {
if matches!(token, Token::Or | Token::RParen) {
break;
}
let right = self.parse_unary();
left = SearchExpr::And(Box::new(left), Box::new(right));
}
left
}
fn parse_unary(&mut self) -> SearchExpr {
if let Some(Token::NotPrefix) = self.peek() {
self.advance();
let expr = self.parse_primary();
return SearchExpr::Not(Box::new(expr));
}
self.parse_primary()
}
fn parse_primary(&mut self) -> SearchExpr {
match self.peek() {
Some(Token::LParen) => {
self.advance();
let expr = self.parse_or();
if let Some(Token::RParen) = self.peek() {
self.advance();
}
expr
}
Some(Token::Text(t)) => {
let term = t.clone();
self.advance();
SearchExpr::Term(term)
}
_ => SearchExpr::Term("".to_string()), }
}
}
pub fn contains_ignore_case(haystack: &str, needle_lower: &str) -> bool {
if needle_lower.is_empty() {
return true;
}
let h = haystack.as_bytes();
let n = needle_lower.as_bytes();
if haystack.is_ascii() && needle_lower.is_ascii() {
if h.len() < n.len() {
return false;
}
for i in 0..=(h.len() - n.len()) {
if h[i..i + n.len()].eq_ignore_ascii_case(n) {
return true;
}
}
return false;
}
haystack.to_lowercase().contains(needle_lower)
}
pub fn starts_with_ignore_case(haystack: &str, prefix_lower: &str) -> bool {
if prefix_lower.is_empty() {
return true;
}
let h = haystack.as_bytes();
let p = prefix_lower.as_bytes();
if haystack.is_ascii() && prefix_lower.is_ascii() {
if h.len() < p.len() {
return false;
}
return h[..p.len()].eq_ignore_ascii_case(p);
}
haystack.to_lowercase().starts_with(prefix_lower)
}
impl Task {
pub fn matches_search_term(&self, query: &str, store: &crate::store::TaskStore) -> bool {
if query.trim().is_empty() {
return true;
}
let q = Query::new(query);
let lex_guard = crate::model::parser::LEXICON.read().unwrap();
q.matches(self, &lex_guard, store)
}
fn matches_primitive(
&self,
part: &str,
lex: &crate::model::parser::ParserLexicon,
store: &crate::store::TaskStore,
) -> bool {
if part.is_empty() {
return true;
}
let part = part.trim();
let part_unquoted = if part.starts_with('"') && part.ends_with('"') && part.len() >= 2 {
&part[1..part.len() - 1]
} else {
part
};
let part_lower = part_unquoted.to_lowercase();
let extracted = lex.extract_prefix(part_unquoted, &part_lower);
let rem = extracted.map(|(_, r, _)| r).unwrap_or(part_lower.as_str());
let pref = extracted.map(|(p, _, _)| p);
if part_lower.starts_with("@@") || pref == Some(crate::model::parser::PrefixToken::Loc) {
let loc_query = if part_lower.starts_with("@@") {
part_lower.trim_start_matches('@')
} else {
rem
};
let is_match = |t: &Task| {
t.locations
.iter()
.chain(t.transient_desc_locs.iter())
.any(|l| contains_ignore_case(l, loc_query))
};
if is_match(self) {
return true;
}
let mut curr = self.parent_uid.as_deref();
let mut visited = std::collections::HashSet::new();
while let Some(p_uid) = curr {
if !visited.insert(p_uid) {
break;
}
if let Some(p) = store.get_task_ref(p_uid) {
if is_match(p) {
return true;
}
curr = p.parent_uid.as_deref();
} else {
break;
}
}
return false;
}
if part_lower.starts_with('~') || pref == Some(crate::model::parser::PrefixToken::Duration)
{
let content = if part_lower.starts_with('~') {
part_lower.strip_prefix('~').unwrap()
} else {
rem
};
let (op, val_str) = if let Some(stripped) = content.strip_prefix("<=") {
("<=", stripped)
} else if let Some(stripped) = content.strip_prefix(">=") {
(">=", stripped)
} else if let Some(stripped) = content.strip_prefix('<') {
("<", stripped)
} else if let Some(stripped) = content.strip_prefix('>') {
(">", stripped)
} else if let Some(stripped) = content.strip_prefix('=') {
("=", stripped)
} else {
("=", content)
};
if !op.is_empty()
&& let Some(target) = crate::model::parser::parse_duration_with_lex(val_str, lex)
{
let t_min = self.estimated_duration.unwrap_or(0);
let t_max = self.estimated_duration_max.unwrap_or(t_min);
if self.estimated_duration.is_none() {
return false;
}
let ok = match op {
"<" => t_min < target,
">" => t_max > target,
"<=" => t_min <= target,
">=" => t_max >= target,
_ => target >= t_min && target <= t_max,
};
return ok;
}
}
if part_lower.starts_with('!') {
let (op, val_str) = if let Some(stripped) = part_lower.strip_prefix("!<=") {
("<=", stripped)
} else if let Some(stripped) = part_lower.strip_prefix("!>=") {
(">=", stripped)
} else if let Some(stripped) = part_lower.strip_prefix("!<") {
("<", stripped)
} else if let Some(stripped) = part_lower.strip_prefix("!>") {
(">", stripped)
} else if let Some(stripped) = part_lower.strip_prefix('!') {
("=", stripped)
} else {
("", "")
};
if !op.is_empty()
&& let Ok(target) = val_str.parse::<u8>()
{
let p = self.priority;
let ok = match op {
"<" => p < target,
">" => p > target,
"<=" => p <= target,
">=" => p >= target,
_ => p == target,
};
return ok;
}
}
let check_date_filter = |target_pref: crate::model::parser::PrefixToken,
prefix_char: char,
task_date: Option<NaiveDate>|
-> Option<bool> {
if pref != Some(target_pref) && !part_lower.starts_with(prefix_char) {
return None;
}
let raw_val = if pref == Some(target_pref) {
rem
} else {
part_lower.strip_prefix(prefix_char).unwrap_or("")
};
let (val_str_full, include_none) = if let Some(stripped) = raw_val.strip_suffix('!') {
(stripped, true)
} else {
(raw_val, false)
};
let (op, date_str) = if let Some(s) = val_str_full.strip_prefix("<=") {
("<=", s)
} else if let Some(s) = val_str_full.strip_prefix(">=") {
(">=", s)
} else if let Some(s) = val_str_full.strip_prefix('<') {
("<", s)
} else if let Some(s) = val_str_full.strip_prefix('>') {
(">", s)
} else {
("=", val_str_full)
};
let target_date = crate::model::parser::parse_smart_date_with_lex(date_str, lex)
.map(|d| d.to_date_naive());
if let Some(target) = target_date {
match task_date {
Some(t_date) => {
let ok = match op {
"<" => t_date < target,
">" => t_date > target,
"<=" => t_date <= target,
">=" => t_date >= target,
_ => t_date == target,
};
return Some(ok);
}
None => {
if include_none {
return Some(true);
} else {
return Some(false);
}
}
}
}
None
};
let t_start = self.dtstart.as_ref().map(|d| d.to_date_naive());
if let Some(passed) =
check_date_filter(crate::model::parser::PrefixToken::Start, '^', t_start)
{
return passed;
}
let t_due = self.due.as_ref().map(|d| d.to_date_naive());
if let Some(passed) = check_date_filter(crate::model::parser::PrefixToken::Due, '@', t_due)
{
return passed;
}
if let Some(tag_query) = part_lower.strip_prefix('#') {
let is_match = |t: &Task| {
t.categories
.iter()
.chain(t.transient_desc_tags.iter())
.any(|c| contains_ignore_case(c, tag_query))
};
if is_match(self) {
return true;
}
let mut curr = self.parent_uid.as_deref();
let mut visited = std::collections::HashSet::new();
while let Some(p_uid) = curr {
if !visited.insert(p_uid) {
break;
}
if let Some(p) = store.get_task_ref(p_uid) {
if is_match(p) {
return true;
}
curr = p.parent_uid.as_deref();
} else {
break;
}
}
return false;
}
if part_lower == "is:done" || lex.search_is_done.contains(&part_lower) {
return self.status.is_done();
}
if part_lower == "is:started"
|| part_lower == "is:ongoing"
|| lex.search_is_started.contains(&part_lower)
|| lex.search_is_ongoing.contains(&part_lower)
{
return self.status == TaskStatus::InProcess;
}
if part_lower == "is:active" || lex.search_is_active.contains(&part_lower) {
return !self.status.is_done();
}
if part_lower == "is:ready"
|| part_lower == "is:blocked"
|| lex.search_is_ready.contains(&part_lower)
|| lex.search_is_blocked.contains(&part_lower)
{
return true;
}
if part_lower == "is:note" || lex.search_is_note.contains(&part_lower) {
return self.is_note;
}
if part_lower == "is:page"
|| part_lower == "is:journal"
|| lex.search_is_page.contains(&part_lower)
{
return self.is_journal;
}
if part_lower == "is:permanent" || lex.search_is_permanent.contains(&part_lower) {
return self.permanent;
}
if part_lower == "is:canceled"
|| part_lower == "is:cancelled"
|| lex.search_is_canceled.contains(&part_lower)
{
return self.status == TaskStatus::Cancelled;
}
if lex.exact.get(&part_lower) == Some(&crate::model::parser::ExactToken::IsPinned) {
return self.pinned;
}
let is_match = |t: &Task| {
let summary_match = contains_ignore_case(&t.summary, &part_lower);
let desc_match = contains_ignore_case(&t.description, &part_lower);
let cat_match = t
.categories
.iter()
.chain(t.transient_desc_tags.iter())
.any(|c| contains_ignore_case(c, &part_lower));
let loc_match = t
.locations
.iter()
.chain(t.transient_desc_locs.iter())
.any(|l| contains_ignore_case(l, &part_lower));
summary_match || desc_match || cat_match || loc_match
};
if is_match(self) {
return true;
}
let mut curr = self.parent_uid.as_deref();
let mut visited = std::collections::HashSet::new();
while let Some(p_uid) = curr {
if !visited.insert(p_uid) {
break;
}
if let Some(p) = store.get_task_ref(p_uid) {
if is_match(p) {
return true;
}
curr = p.parent_uid.as_deref();
} else {
break;
}
}
false
}
}
#[cfg(test)]
mod tests {
use crate::model::item::Task;
use std::collections::HashMap;
#[test]
fn test_basic_and_or_not() {
let store =
crate::store::TaskStore::new(std::sync::Arc::new(crate::context::TestContext::new()));
let aliases: HashMap<String, Vec<String>> = HashMap::new();
let mut t = Task::new("Test", &aliases, None);
t.summary = "Work today".to_string();
t.description = "Finish the report".to_string();
t.categories.push("work".to_string());
t.categories.push("today".to_string());
t.locations.push("home office".to_string());
assert!(t.matches_search_term("work today", &store));
assert!(t.matches_search_term("urgent | work", &store));
assert!(t.matches_search_term("urgent | home", &store));
assert!(!t.matches_search_term("urgent | beach", &store));
assert!(!t.matches_search_term("-work", &store));
assert!(t.matches_search_term("-beach", &store));
assert!(!t.matches_search_term("(work | urgent) -today", &store));
}
#[test]
fn test_quotes_and_term() {
let store =
crate::store::TaskStore::new(std::sync::Arc::new(crate::context::TestContext::new()));
let aliases: HashMap<String, Vec<String>> = HashMap::new();
let mut t = Task::new("Test", &aliases, None);
t.summary = "Big task".to_string();
assert!(t.matches_search_term("\"Big task\"", &store));
assert!(t.matches_search_term("big", &store));
assert!(!t.matches_search_term("\"small task\"", &store));
}
#[test]
fn test_tag_and_location_filters() {
let store =
crate::store::TaskStore::new(std::sync::Arc::new(crate::context::TestContext::new()));
let aliases: HashMap<String, Vec<String>> = HashMap::new();
let mut t = Task::new("Test", &aliases, None);
t.categories.push("home".to_string());
t.locations.push("Kitchen".to_string());
assert!(t.matches_search_term("#home", &store));
assert!(t.matches_search_term("loc:Kitchen", &store));
assert!(t.matches_search_term("@@Kitchen", &store));
}
#[test]
fn test_ancestor_search() {
let mut store =
crate::store::TaskStore::new(std::sync::Arc::new(crate::context::TestContext::new()));
let aliases: HashMap<String, Vec<String>> = HashMap::new();
let mut parent = Task::new("PowerShed", &aliases, None);
parent.uid = "parent_uid".to_string();
let mut child = Task::new("BOM", &aliases, None);
child.uid = "child_uid".to_string();
child.parent_uid = Some("parent_uid".to_string());
let mut grandchild = Task::new("Bac d'acier", &aliases, None);
grandchild.uid = "grandchild_uid".to_string();
grandchild.parent_uid = Some("child_uid".to_string());
store.add_task(parent);
store.add_task(child);
assert!(grandchild.matches_search_term("PowerShed bom acier", &store));
}
}