Skip to main content

mail_query/
parser.rs

1//! Hand-written recursive-descent parser for Gmail-style email queries.
2//!
3//! The grammar (informal):
4//!
5//! ```text
6//! query    = or ( (AND | implicit) or )*
7//! or       = near ( OR near )*
8//! near     = unary ( AROUND <n> unary )*
9//! unary    = ('-' | NOT)? atom
10//! atom     = '(' query ')'
11//!          | '{' group '}'                ( OR-joined )
12//!          | field ':' value
13//!          | field ':' '(' field_group ')'
14//!          | PHRASE
15//!          | '+' WORD                     ( -> Exact )
16//!          | WORD
17//! ```
18
19use crate::ast::{DateBound, DateValue, FilterKind, QueryField, QueryNode, RelativeUnit, SizeOp};
20use crate::error::ParseError;
21use crate::options::ParserOptions;
22
23// -- Tokens -------------------------------------------------------------------
24
25#[derive(Debug, Clone, PartialEq)]
26enum Token {
27    Word(String),
28    Phrase(String),
29    Colon,
30    Minus,
31    LParen,
32    RParen,
33    LBrace,
34    RBrace,
35    And,
36    Or,
37    Not,
38    Around,
39}
40
41// -- Tokenizer ----------------------------------------------------------------
42
43fn tokenize(input: &str) -> Result<Vec<Token>, ParseError> {
44    let mut tokens = Vec::new();
45    let mut chars = input.chars().peekable();
46
47    while let Some(&ch) = chars.peek() {
48        match ch {
49            ' ' | '\t' | '\n' | '\r' => {
50                chars.next();
51            }
52            '(' => {
53                chars.next();
54                tokens.push(Token::LParen);
55            }
56            ')' => {
57                chars.next();
58                tokens.push(Token::RParen);
59            }
60            '{' => {
61                chars.next();
62                tokens.push(Token::LBrace);
63            }
64            '}' => {
65                chars.next();
66                tokens.push(Token::RBrace);
67            }
68            ':' => {
69                chars.next();
70                tokens.push(Token::Colon);
71            }
72            '-' => {
73                chars.next();
74                tokens.push(Token::Minus);
75            }
76            '"' => {
77                chars.next();
78                let mut s = String::new();
79                loop {
80                    match chars.next() {
81                        Some('\\') => match chars.next() {
82                            // Escaped inner double-quote: `"foo \"bar\""`.
83                            Some('"') => s.push('"'),
84                            Some(c) => {
85                                s.push('\\');
86                                s.push(c);
87                            }
88                            None => break,
89                        },
90                        Some('"') => break,
91                        Some(c) => s.push(c),
92                        None => break,
93                    }
94                }
95                tokens.push(Token::Phrase(s));
96            }
97            _ => {
98                let mut word = String::new();
99                while let Some(&c) = chars.peek() {
100                    if c.is_whitespace()
101                        || c == '('
102                        || c == ')'
103                        || c == '{'
104                        || c == '}'
105                        || c == ':'
106                        || c == '"'
107                    {
108                        break;
109                    }
110                    word.push(c);
111                    chars.next();
112                }
113                if word.eq_ignore_ascii_case("AND") {
114                    tokens.push(Token::And);
115                } else if word.eq_ignore_ascii_case("OR") {
116                    tokens.push(Token::Or);
117                } else if word.eq_ignore_ascii_case("NOT") {
118                    tokens.push(Token::Not);
119                } else if word.eq_ignore_ascii_case("AROUND") {
120                    tokens.push(Token::Around);
121                } else {
122                    tokens.push(Token::Word(word));
123                }
124            }
125        }
126    }
127
128    Ok(tokens)
129}
130
131// -- Parser -------------------------------------------------------------------
132
133struct Parser<'a> {
134    tokens: Vec<Token>,
135    pos: usize,
136    options: &'a ParserOptions,
137}
138
139impl<'a> Parser<'a> {
140    fn new(tokens: Vec<Token>, options: &'a ParserOptions) -> Self {
141        Self {
142            tokens,
143            pos: 0,
144            options,
145        }
146    }
147
148    fn peek(&self) -> Option<&Token> {
149        self.tokens.get(self.pos)
150    }
151
152    fn next(&mut self) -> Option<Token> {
153        let tok = self.tokens.get(self.pos).cloned();
154        if tok.is_some() {
155            self.pos += 1;
156        }
157        tok
158    }
159
160    fn at_end(&self) -> bool {
161        self.pos >= self.tokens.len()
162    }
163
164    /// Top-level entry: parse a full expression. OR has lower precedence
165    /// than (implicit/explicit) AND, matching Gmail and Lucene
166    /// conventions.
167    fn parse_expression(&mut self) -> Result<QueryNode, ParseError> {
168        self.parse_or()
169    }
170
171    /// `parse_or` chains OR over AND-groups.
172    fn parse_or(&mut self) -> Result<QueryNode, ParseError> {
173        let mut left = self.parse_and()?;
174
175        while matches!(self.peek(), Some(Token::Or)) {
176            self.next();
177            let right = self.parse_and()?;
178            left = QueryNode::Or(Box::new(left), Box::new(right));
179        }
180
181        Ok(left)
182    }
183
184    /// `parse_and` chains implicit and explicit AND between adjacent
185    /// terms. Stops at OR or any closing delimiter.
186    fn parse_and(&mut self) -> Result<QueryNode, ParseError> {
187        let mut left = self.parse_near()?;
188
189        while !self.at_end() {
190            if matches!(self.peek(), Some(Token::RParen | Token::RBrace | Token::Or)) {
191                break;
192            }
193            if matches!(self.peek(), Some(Token::And)) {
194                self.next();
195            }
196            if self.at_end()
197                || matches!(self.peek(), Some(Token::RParen | Token::RBrace | Token::Or))
198            {
199                break;
200            }
201            let right = self.parse_near()?;
202            left = QueryNode::And(Box::new(left), Box::new(right));
203        }
204
205        Ok(left)
206    }
207
208    fn parse_near(&mut self) -> Result<QueryNode, ParseError> {
209        let mut left = self.parse_unary()?;
210
211        while matches!(self.peek(), Some(Token::Around)) {
212            self.next();
213            let distance = match self.next() {
214                Some(Token::Word(w)) => w
215                    .parse::<u32>()
216                    .map_err(|_| ParseError::UnexpectedToken(w.to_string()))?,
217                Some(tok) => return Err(ParseError::UnexpectedToken(format!("{:?}", tok))),
218                None => return Err(ParseError::UnexpectedEnd),
219            };
220            let right = self.parse_unary()?;
221            let left_term = near_term(&left)?;
222            let right_term = near_term(&right)?;
223            left = QueryNode::Near {
224                left: left_term,
225                right: right_term,
226                distance,
227            };
228        }
229
230        Ok(left)
231    }
232
233    fn parse_unary(&mut self) -> Result<QueryNode, ParseError> {
234        match self.peek() {
235            Some(Token::Minus) => {
236                self.next();
237                let node = self.parse_atom()?;
238                Ok(QueryNode::Not(Box::new(node)))
239            }
240            Some(Token::Not) => {
241                self.next();
242                let node = self.parse_atom()?;
243                Ok(QueryNode::Not(Box::new(node)))
244            }
245            _ => self.parse_atom(),
246        }
247    }
248
249    fn parse_atom(&mut self) -> Result<QueryNode, ParseError> {
250        match self.peek() {
251            Some(Token::LParen) => {
252                self.next();
253                let node = self.parse_expression()?;
254                match self.next() {
255                    Some(Token::RParen) => Ok(node),
256                    _ => Err(ParseError::UnmatchedParen),
257                }
258            }
259            Some(Token::LBrace) => self.parse_brace_group(),
260            Some(Token::Phrase(s)) => {
261                let s = s.clone();
262                self.next();
263                phrase_node(s)
264            }
265            Some(Token::Word(_)) => {
266                // Is this a field:value pattern?
267                if self.pos + 1 < self.tokens.len()
268                    && matches!(self.tokens[self.pos + 1], Token::Colon)
269                {
270                    return self.parse_field_value();
271                }
272                let word = match self.next() {
273                    Some(Token::Word(w)) => w,
274                    _ => unreachable!(),
275                };
276                // `+word` => Exact (no stemming).
277                if let Some(rest) = word.strip_prefix('+') {
278                    if !rest.is_empty() {
279                        return Ok(QueryNode::Exact(rest.to_string()));
280                    }
281                }
282                Ok(QueryNode::Text(word))
283            }
284            Some(tok) => Err(ParseError::UnexpectedToken(format!("{:?}", tok))),
285            None => Err(ParseError::UnexpectedEnd),
286        }
287    }
288
289    /// Brace groups OR-join their contents regardless of separators.
290    /// Each iteration grabs one `parse_unary` (a single atom, possibly
291    /// negated). We deliberately do *not* recurse into `parse_or` or
292    /// `parse_and` here — the brace's implicit OR would conflict with
293    /// parse_and's implicit AND if we did.
294    fn parse_brace_group(&mut self) -> Result<QueryNode, ParseError> {
295        self.next();
296        let mut node: Option<QueryNode> = None;
297
298        while !self.at_end() {
299            if matches!(self.peek(), Some(Token::RBrace)) {
300                self.next();
301                return node.ok_or(ParseError::UnexpectedEnd);
302            }
303            if matches!(self.peek(), Some(Token::And | Token::Or)) {
304                self.next();
305                continue;
306            }
307
308            let part = self.parse_unary()?;
309            node = Some(match node {
310                Some(left) => QueryNode::Or(Box::new(left), Box::new(part)),
311                None => part,
312            });
313        }
314
315        Err(ParseError::UnmatchedBrace)
316    }
317
318    fn parse_field_value(&mut self) -> Result<QueryNode, ParseError> {
319        let field_name = match self.next() {
320            Some(Token::Word(w)) => w,
321            _ => return Err(ParseError::UnexpectedEnd),
322        };
323
324        match self.next() {
325            Some(Token::Colon) => {}
326            _ => return Err(ParseError::ExpectedValue),
327        }
328
329        if matches!(self.peek(), Some(Token::LParen)) {
330            return self.parse_field_group(&field_name);
331        }
332
333        let value = match self.next() {
334            Some(Token::Word(w)) => w,
335            Some(Token::Phrase(p)) => p,
336            _ => return Err(ParseError::ExpectedValue),
337        };
338
339        self.build_field_value(&field_name, normalize_value(value))
340    }
341
342    fn parse_field_group(&mut self, field_name: &str) -> Result<QueryNode, ParseError> {
343        self.next();
344        let mut node: Option<QueryNode> = None;
345        let mut use_or = false;
346
347        while !self.at_end() {
348            match self.peek() {
349                Some(Token::RParen) => {
350                    self.next();
351                    return node.ok_or(ParseError::ExpectedValue);
352                }
353                Some(Token::And) => {
354                    self.next();
355                    use_or = false;
356                }
357                Some(Token::Or) => {
358                    self.next();
359                    use_or = true;
360                }
361                Some(Token::Minus | Token::Not) => {
362                    self.next();
363                    let value = self.next_field_group_value(field_name)?;
364                    node = Some(combine_group_node(
365                        node,
366                        QueryNode::Not(Box::new(value)),
367                        use_or,
368                    ));
369                    use_or = false;
370                }
371                _ => {
372                    let value = self.next_field_group_value(field_name)?;
373                    node = Some(combine_group_node(node, value, use_or));
374                    use_or = false;
375                }
376            }
377        }
378
379        Err(ParseError::UnmatchedParen)
380    }
381
382    fn next_field_group_value(&mut self, field_name: &str) -> Result<QueryNode, ParseError> {
383        let value = match self.next() {
384            Some(Token::Word(w)) => normalize_value(w),
385            Some(Token::Phrase(p)) => normalize_value(p),
386            Some(tok) => return Err(ParseError::UnexpectedToken(format!("{:?}", tok))),
387            None => return Err(ParseError::UnexpectedEnd),
388        };
389        self.build_field_value(field_name, value)
390    }
391
392    fn build_field_value(&self, field_name: &str, value: String) -> Result<QueryNode, ParseError> {
393        match field_name.to_lowercase().as_str() {
394            "from" => Ok(QueryNode::Field {
395                field: QueryField::From,
396                value,
397            }),
398            "to" => Ok(QueryNode::Field {
399                field: QueryField::To,
400                value,
401            }),
402            "cc" => Ok(QueryNode::Field {
403                field: QueryField::Cc,
404                value,
405            }),
406            "bcc" => Ok(QueryNode::Field {
407                field: QueryField::Bcc,
408                value,
409            }),
410            "subject" => Ok(QueryNode::Field {
411                field: QueryField::Subject,
412                value,
413            }),
414            "body" => Ok(QueryNode::Field {
415                field: QueryField::Body,
416                value,
417            }),
418            "filename" => Ok(QueryNode::Field {
419                field: QueryField::Filename,
420                value,
421            }),
422            "list" => Ok(QueryNode::Field {
423                field: QueryField::List,
424                value,
425            }),
426            "deliveredto" => Ok(QueryNode::Field {
427                field: QueryField::DeliveredTo,
428                value,
429            }),
430            "rfc822msgid" => Ok(QueryNode::Field {
431                field: QueryField::Rfc822MsgId,
432                value,
433            }),
434            "label" => Ok(QueryNode::Label(value)),
435            "category" => category_label(&value)
436                .map(|label| QueryNode::Label(label.to_string()))
437                .ok_or_else(|| ParseError::UnknownFilter(value.to_lowercase())),
438            "is" => self.build_is_filter(value),
439            "in" => self.build_in_filter(value),
440            "has" => self.build_has_filter(value),
441            "size" => {
442                let (op, bytes) = parse_size_value(&value)?;
443                Ok(QueryNode::Size { op, bytes })
444            }
445            "larger" => {
446                let bytes = parse_size_bytes(&value)?;
447                Ok(QueryNode::Size {
448                    op: SizeOp::GreaterThan,
449                    bytes,
450                })
451            }
452            "smaller" => {
453                let bytes = parse_size_bytes(&value)?;
454                Ok(QueryNode::Size {
455                    op: SizeOp::LessThan,
456                    bytes,
457                })
458            }
459            "after" => Ok(QueryNode::DateRange {
460                bound: DateBound::After,
461                date: parse_date_value(&value)?,
462            }),
463            "before" => Ok(QueryNode::DateRange {
464                bound: DateBound::Before,
465                date: parse_date_value(&value)?,
466            }),
467            "date" => Ok(QueryNode::DateRange {
468                bound: DateBound::Exact,
469                date: parse_date_value(&value)?,
470            }),
471            "older" | "older_than" => Ok(QueryNode::DateRange {
472                bound: DateBound::Before,
473                date: parse_relative_duration(&value)?,
474            }),
475            "newer" | "newer_than" => Ok(QueryNode::DateRange {
476                bound: DateBound::After,
477                date: parse_relative_duration(&value)?,
478            }),
479            other => Err(ParseError::UnknownFilter(other.to_string())),
480        }
481    }
482
483    fn build_is_filter(&self, value: String) -> Result<QueryNode, ParseError> {
484        match value.to_lowercase().as_str() {
485            "unread" => Ok(QueryNode::Filter(FilterKind::Unread)),
486            "read" => Ok(QueryNode::Filter(FilterKind::Read)),
487            "starred" => Ok(QueryNode::Filter(FilterKind::Starred)),
488            "important" => Ok(QueryNode::Label("IMPORTANT".to_string())),
489            "muted" => Ok(QueryNode::Label("MUTED".to_string())),
490            "draft" | "drafts" => Ok(QueryNode::Filter(FilterKind::Draft)),
491            "sent" => Ok(QueryNode::Filter(FilterKind::Sent)),
492            "trash" | "deleted" => Ok(QueryNode::Filter(FilterKind::Trash)),
493            "spam" | "junk" => Ok(QueryNode::Filter(FilterKind::Spam)),
494            "answered" | "replied" => Ok(QueryNode::Filter(FilterKind::Answered)),
495            "inbox" => Ok(QueryNode::Filter(FilterKind::Inbox)),
496            "archived" | "archive" => Ok(QueryNode::Filter(FilterKind::Archived)),
497            other => self.resolve_custom_filter(other),
498        }
499    }
500
501    fn build_in_filter(&self, value: String) -> Result<QueryNode, ParseError> {
502        match value.to_lowercase().as_str() {
503            "inbox" => Ok(QueryNode::Filter(FilterKind::Inbox)),
504            "anywhere" | "all" | "allmail" | "all_mail" => {
505                Ok(QueryNode::Filter(FilterKind::Anywhere))
506            }
507            "draft" | "drafts" => Ok(QueryNode::Filter(FilterKind::Draft)),
508            "sent" => Ok(QueryNode::Filter(FilterKind::Sent)),
509            "trash" | "deleted" => Ok(QueryNode::Filter(FilterKind::Trash)),
510            "spam" | "junk" => Ok(QueryNode::Filter(FilterKind::Spam)),
511            "archived" | "archive" => Ok(QueryNode::Filter(FilterKind::Archived)),
512            "snoozed" => Ok(QueryNode::Label("SNOOZED".to_string())),
513            other => self.resolve_custom_filter(other),
514        }
515    }
516
517    fn build_has_filter(&self, value: String) -> Result<QueryNode, ParseError> {
518        match value.to_lowercase().as_str() {
519            "attachment" | "attachments" => Ok(QueryNode::Filter(FilterKind::HasAttachment)),
520            "calendar" | "invite" | "invites" => Ok(QueryNode::Filter(FilterKind::HasCalendar)),
521            "userlabels" => Ok(QueryNode::Filter(FilterKind::HasUserLabels)),
522            "nouserlabels" => Ok(QueryNode::Filter(FilterKind::NoUserLabels)),
523            "drive" => Ok(QueryNode::Filter(FilterKind::HasDrive)),
524            "document" => Ok(QueryNode::Filter(FilterKind::HasDocument)),
525            "spreadsheet" => Ok(QueryNode::Filter(FilterKind::HasSpreadsheet)),
526            "presentation" => Ok(QueryNode::Filter(FilterKind::HasPresentation)),
527            "youtube" => Ok(QueryNode::Filter(FilterKind::HasYoutube)),
528            "inline" | "image" | "inline-image" | "inline-images" => {
529                Ok(QueryNode::Filter(FilterKind::HasInlineImage))
530            }
531            "link" | "links" => Ok(QueryNode::Filter(FilterKind::HasLink)),
532            "link-heavy" | "links-heavy" | "linkheavy" => {
533                Ok(QueryNode::Filter(FilterKind::HasLinkHeavy))
534            }
535            "link-none" | "no-link" | "no-links" | "linkfree" | "link-free" => {
536                Ok(QueryNode::Filter(FilterKind::NoLinks))
537            }
538            "yellow-star" | "orange-star" | "red-star" | "purple-star" | "blue-star"
539            | "green-star" | "red-bang" | "orange-guillemet" | "yellow-bang" | "green-check"
540            | "blue-info" | "purple-question" => Ok(QueryNode::Filter(FilterKind::Starred)),
541            other => self.resolve_custom_filter(other),
542        }
543    }
544
545    /// Filters not in the built-in set route through
546    /// `FilterKind::Custom(canonical_name)` when the caller has
547    /// registered them via `ParserOptions::custom_filters`. Names are
548    /// normalised to lowercase + hyphenated (`reply_later` → `reply-later`).
549    fn resolve_custom_filter(&self, value: &str) -> Result<QueryNode, ParseError> {
550        let canonical = canonical_filter_name(value);
551        if self.options.has_custom_filter(&canonical) {
552            return Ok(QueryNode::Filter(FilterKind::Custom(canonical)));
553        }
554        Err(ParseError::UnknownFilter(value.to_string()))
555    }
556}
557
558fn combine_group_node(left: Option<QueryNode>, right: QueryNode, use_or: bool) -> QueryNode {
559    match left {
560        Some(left) if use_or => QueryNode::Or(Box::new(left), Box::new(right)),
561        Some(left) => QueryNode::And(Box::new(left), Box::new(right)),
562        None => right,
563    }
564}
565
566/// Strip a leading `+` from field values (the no-stemming hint is
567/// meaningless once you've narrowed to a specific field).
568fn normalize_value(value: String) -> String {
569    value.strip_prefix('+').unwrap_or(&value).to_string()
570}
571
572/// Lowercase + replace underscores with hyphens.
573/// `reply_later` and `Reply-Later` both canonicalise to `reply-later`.
574pub(crate) fn canonical_filter_name(value: &str) -> String {
575    value.to_lowercase().replace('_', "-")
576}
577
578fn near_term(node: &QueryNode) -> Result<String, ParseError> {
579    match node {
580        QueryNode::Text(value) | QueryNode::Phrase(value) | QueryNode::Exact(value) => {
581            Ok(value.clone())
582        }
583        other => Err(ParseError::UnexpectedToken(format!("{:?}", other))),
584    }
585}
586
587fn phrase_node(value: String) -> Result<QueryNode, ParseError> {
588    let parts = value.split_whitespace().collect::<Vec<_>>();
589    if parts.len() == 4 && parts[1].eq_ignore_ascii_case("AROUND") {
590        if let Ok(distance) = parts[2].parse::<u32>() {
591            return Ok(QueryNode::Near {
592                left: parts[0].to_string(),
593                right: parts[3].to_string(),
594                distance,
595            });
596        }
597    }
598    Ok(QueryNode::Phrase(value))
599}
600
601fn category_label(value: &str) -> Option<&'static str> {
602    match value.to_lowercase().as_str() {
603        "primary" | "personal" => Some("CATEGORY_PERSONAL"),
604        "social" => Some("CATEGORY_SOCIAL"),
605        "promotions" => Some("CATEGORY_PROMOTIONS"),
606        "updates" => Some("CATEGORY_UPDATES"),
607        "forums" => Some("CATEGORY_FORUMS"),
608        "reservations" => Some("CATEGORY_RESERVATIONS"),
609        "purchases" => Some("CATEGORY_PURCHASES"),
610        _ => None,
611    }
612}
613
614fn parse_date_value(s: &str) -> Result<DateValue, ParseError> {
615    use chrono::NaiveDate;
616    match s.to_lowercase().as_str() {
617        "today" => Ok(DateValue::Today),
618        "yesterday" => Ok(DateValue::Yesterday),
619        "this-week" => Ok(DateValue::ThisWeek),
620        "this-month" => Ok(DateValue::ThisMonth),
621        _ => {
622            for format in ["%Y-%m-%d", "%Y/%m/%d", "%m/%d/%Y"] {
623                if let Ok(date) = NaiveDate::parse_from_str(s, format) {
624                    return Ok(DateValue::Specific(date));
625                }
626            }
627            Err(ParseError::InvalidDate(s.to_string()))
628        }
629    }
630}
631
632/// `older_than:5d` → `Relative { amount: 5, unit: Day }`. We deliberately
633/// do not resolve to a concrete `NaiveDate` here — backends do that at
634/// query-execution time using the configured `now_provider` (lets the
635/// same AST be valid yesterday and tomorrow, and lets `Display` round-
636/// trip without embedding a date).
637fn parse_relative_duration(s: &str) -> Result<DateValue, ParseError> {
638    let input = s.trim().to_lowercase();
639    if input.len() < 2 {
640        return Err(ParseError::InvalidDate(s.to_string()));
641    }
642
643    let (amount_str, unit_str) = input.split_at(input.len() - 1);
644    let amount = amount_str
645        .parse::<u32>()
646        .map_err(|_| ParseError::InvalidDate(s.to_string()))?;
647    let unit = match unit_str {
648        "d" => RelativeUnit::Day,
649        "w" => RelativeUnit::Week,
650        "m" => RelativeUnit::Month,
651        "y" => RelativeUnit::Year,
652        _ => return Err(ParseError::InvalidDate(s.to_string())),
653    };
654    Ok(DateValue::Relative { amount, unit })
655}
656
657fn parse_size_value(s: &str) -> Result<(SizeOp, u64), ParseError> {
658    let input = s.trim().to_lowercase();
659    if input.is_empty() {
660        return Err(ParseError::InvalidSize(s.to_string()));
661    }
662
663    let (op, rest) = if let Some(rest) = input.strip_prefix(">=") {
664        (SizeOp::GreaterThanOrEqual, rest)
665    } else if let Some(rest) = input.strip_prefix("<=") {
666        (SizeOp::LessThanOrEqual, rest)
667    } else if let Some(rest) = input.strip_prefix('>') {
668        (SizeOp::GreaterThan, rest)
669    } else if let Some(rest) = input.strip_prefix('<') {
670        (SizeOp::LessThan, rest)
671    } else if let Some(rest) = input.strip_prefix('=') {
672        (SizeOp::Equal, rest)
673    } else {
674        (SizeOp::Equal, input.as_str())
675    };
676
677    let number_end = rest
678        .find(|ch: char| !ch.is_ascii_digit() && ch != '.')
679        .unwrap_or(rest.len());
680    let (number_part, unit_part) = rest.split_at(number_end);
681    if number_part.is_empty() {
682        return Err(ParseError::InvalidSize(s.to_string()));
683    }
684
685    let value = number_part
686        .parse::<f64>()
687        .map_err(|_| ParseError::InvalidSize(s.to_string()))?;
688    let multiplier = match unit_part {
689        "" | "b" => 1_f64,
690        "k" | "kb" => 1024_f64,
691        "m" | "mb" => 1024_f64 * 1024_f64,
692        "g" | "gb" => 1024_f64 * 1024_f64 * 1024_f64,
693        other => return Err(ParseError::InvalidSize(other.to_string())),
694    };
695
696    #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)]
697    let bytes = (value * multiplier).round() as u64;
698    Ok((op, bytes))
699}
700
701fn parse_size_bytes(s: &str) -> Result<u64, ParseError> {
702    parse_size_value(s).map(|(_, bytes)| bytes)
703}
704
705// -- Public API ---------------------------------------------------------------
706
707/// Parse an email query string into a [`QueryNode`] AST.
708///
709/// Equivalent to [`parse_with`] with [`ParserOptions::default()`].
710pub fn parse(input: &str) -> Result<QueryNode, ParseError> {
711    parse_with(input, &ParserOptions::default())
712}
713
714/// Parse with caller-provided options. Use this to register custom
715/// filter names (so e.g. `is:owed-reply` parses to
716/// `FilterKind::Custom("owed-reply")` instead of returning
717/// [`ParseError::UnknownFilter`]).
718pub fn parse_with(input: &str, options: &ParserOptions) -> Result<QueryNode, ParseError> {
719    let input = input.trim();
720    if input.is_empty() {
721        return Err(ParseError::UnexpectedEnd);
722    }
723    let tokens = tokenize(input)?;
724    if tokens.is_empty() {
725        return Err(ParseError::UnexpectedEnd);
726    }
727    let mut parser = Parser::new(tokens, options);
728    let node = parser.parse_expression()?;
729    if !parser.at_end() {
730        match parser.peek() {
731            Some(Token::RParen) => return Err(ParseError::UnmatchedParen),
732            Some(Token::RBrace) => return Err(ParseError::UnmatchedBrace),
733            _ => {}
734        }
735    }
736    Ok(node)
737}