Skip to main content

tellaro_query_language/parser/
mod.rs

1//! TQL Parser module.
2//!
3//! This module provides parsing functionality for TQL query strings using pest.
4
5pub mod ast;
6pub mod string_escapes;
7
8use pest::Parser as PestParser;
9use pest_derive::Parser;
10
11pub use string_escapes::{escape_string_literal, unescape_string_literal};
12
13pub use ast::{
14    Aggregation, AstNode, CollectionOpNode, ComparisonNode, GeoExprNode, GroupBy, LogicalOpNode,
15    Mutator, NslookupExprNode, QueryWithStatsNode, StatsNode, UnaryOpNode, Value, VizParamValue,
16};
17
18use crate::error::{Result, TqlError};
19
20/// pest parser for TQL grammar
21#[derive(Parser)]
22#[grammar = "parser/grammar.pest"]
23pub struct TqlPestParser;
24
25/// Main TQL parser
26pub struct TqlParser {
27    /// Maximum allowed query depth to prevent stack overflow
28    max_depth: usize,
29}
30
31impl Default for TqlParser {
32    fn default() -> Self {
33        Self::new()
34    }
35}
36
37impl TqlParser {
38    /// Maximum query depth (matches Python implementation)
39    pub const MAX_QUERY_DEPTH: usize = 50;
40
41    /// Create a new parser with default settings
42    pub fn new() -> Self {
43        Self {
44            max_depth: Self::MAX_QUERY_DEPTH,
45        }
46    }
47
48    /// Create a new parser with custom max depth
49    pub fn with_max_depth(max_depth: usize) -> Self {
50        Self { max_depth }
51    }
52
53    /// Parse a TQL query string into an AST
54    ///
55    /// # Arguments
56    ///
57    /// * `query` - The TQL query string to parse
58    ///
59    /// # Returns
60    ///
61    /// An AST node representing the parsed query
62    ///
63    /// # Errors
64    ///
65    /// Returns a `TqlError` if the query has invalid syntax or exceeds max depth
66    ///
67    /// # Examples
68    ///
69    /// ```ignore
70    /// use tql::parser::TqlParser;
71    ///
72    /// let parser = TqlParser::new();
73    /// let ast = parser.parse("field eq 'value'").unwrap();
74    /// ```
75    pub fn parse(&self, query: &str) -> Result<AstNode> {
76        // Handle empty or whitespace-only queries
77        if query.trim().is_empty() {
78            return Ok(AstNode::MatchAll);
79        }
80
81        // Parse with pest
82        let pairs = TqlPestParser::parse(Rule::query, query).map_err(|e| {
83            let location = match &e.location {
84                pest::error::InputLocation::Pos(pos) => *pos,
85                pest::error::InputLocation::Span((start, _)) => *start,
86            };
87
88            TqlError::ParseError {
89                message: format!("Parse error: {}", e),
90                position: location,
91                query: Some(query.to_string()),
92            }
93        })?;
94
95        // Build AST from pest pairs
96        self.build_ast_from_pairs(pairs, 0)
97    }
98
99    /// Build AST from pest parse pairs
100    fn build_ast_from_pairs(
101        &self,
102        mut pairs: pest::iterators::Pairs<Rule>,
103        depth: usize,
104    ) -> Result<AstNode> {
105        // Check depth limit
106        if depth > self.max_depth {
107            return Err(TqlError::SyntaxError {
108                message: format!(
109                    "Query depth exceeds maximum allowed depth of {}",
110                    self.max_depth
111                ),
112                position: Some(0),
113                query: None,
114                suggestions: vec![
115                    "Reduce query nesting depth".to_string(),
116                    "Split into multiple simpler queries".to_string(),
117                ],
118            });
119        }
120
121        // Get the first pair (should be query rule)
122        if let Some(pair) = pairs.next() {
123            match pair.as_rule() {
124                Rule::query => {
125                    // Query contains one of: query_with_stats | stats_expr | logical_expr
126                    let inner = pair.into_inner();
127                    return self.build_ast_from_pairs(inner, depth);
128                }
129                Rule::query_with_stats => {
130                    return self.parse_query_with_stats(pair, depth + 1);
131                }
132                Rule::stats_expr => {
133                    return self.parse_stats_expr(pair, depth + 1);
134                }
135                Rule::logical_expr => {
136                    return self.parse_logical_expr(pair, depth + 1);
137                }
138                _ => {
139                    return Err(TqlError::ParseError {
140                        message: format!("Unexpected rule: {:?}", pair.as_rule()),
141                        position: 0,
142                        query: None,
143                    });
144                }
145            }
146        }
147
148        // Empty query returns MatchAll
149        Ok(AstNode::MatchAll)
150    }
151
152    /// Parse a query with stats (filter | stats)
153    fn parse_query_with_stats(
154        &self,
155        pair: pest::iterators::Pair<Rule>,
156        depth: usize,
157    ) -> Result<AstNode> {
158        let mut inner = pair.into_inner();
159
160        // First part is the logical_expr (filter)
161        let filter_pair = inner.next().ok_or_else(|| TqlError::ParseError {
162            message: "Missing filter expression in query_with_stats".to_string(),
163            position: 0,
164            query: None,
165        })?;
166        let filter = Box::new(self.parse_logical_expr(filter_pair, depth + 1)?);
167
168        // Second part is stats_expr
169        let stats_pair = inner.next().ok_or_else(|| TqlError::ParseError {
170            message: "Missing stats expression in query_with_stats".to_string(),
171            position: 0,
172            query: None,
173        })?;
174
175        // Parse stats_expr and extract StatsNode
176        match self.parse_stats_expr(stats_pair, depth + 1)? {
177            AstNode::StatsExpr(stats) => Ok(AstNode::QueryWithStats(QueryWithStatsNode {
178                filter,
179                stats,
180            })),
181            _ => Err(TqlError::ParseError {
182                message: "Expected stats expression".to_string(),
183                position: 0,
184                query: None,
185            }),
186        }
187    }
188
189    /// Parse a stats expression
190    fn parse_stats_expr(
191        &self,
192        pair: pest::iterators::Pair<Rule>,
193        _depth: usize,
194    ) -> Result<AstNode> {
195        let mut aggregations = Vec::new();
196        let mut group_by = Vec::new();
197        let mut viz_hint = None;
198        let mut viz_params = None;
199
200        for inner_pair in pair.into_inner() {
201            match inner_pair.as_rule() {
202                Rule::aggregation => {
203                    aggregations.push(self.parse_aggregation(inner_pair)?);
204                }
205                Rule::group_by_list => {
206                    group_by = self.parse_group_by_list(inner_pair)?;
207                }
208                Rule::viz_hint => {
209                    let mut viz_inner = inner_pair.into_inner();
210                    // First child is the identifier (chart type)
211                    viz_hint = Some(
212                        viz_inner
213                            .next()
214                            .ok_or_else(|| TqlError::ParseError {
215                                message: "Missing viz hint identifier".to_string(),
216                                position: 0,
217                                query: None,
218                            })?
219                            .as_str()
220                            .to_string(),
221                    );
222                    // Second child (optional) is viz_params
223                    if let Some(params_pair) = viz_inner.next() {
224                        if params_pair.as_rule() == Rule::viz_params {
225                            let mut params = std::collections::HashMap::new();
226                            for param_pair in params_pair.into_inner() {
227                                if param_pair.as_rule() == Rule::viz_param {
228                                    let mut param_inner = param_pair.into_inner();
229                                    let key = param_inner
230                                        .next()
231                                        .ok_or_else(|| TqlError::ParseError {
232                                            message: "Missing viz param key".to_string(),
233                                            position: 0,
234                                            query: None,
235                                        })?
236                                        .as_str()
237                                        .to_string();
238                                    let value_pair =
239                                        param_inner.next().ok_or_else(|| TqlError::ParseError {
240                                            message: format!(
241                                                "Missing viz param value for key '{}'",
242                                                key
243                                            ),
244                                            position: 0,
245                                            query: None,
246                                        })?;
247                                    let value = Self::parse_viz_value(value_pair)?;
248                                    params.insert(key, value);
249                                }
250                            }
251                            if !params.is_empty() {
252                                viz_params = Some(params);
253                            }
254                        }
255                    }
256                }
257                _ => {}
258            }
259        }
260
261        Ok(AstNode::StatsExpr(StatsNode {
262            aggregations,
263            group_by,
264            viz_hint,
265            viz_params,
266        }))
267    }
268
269    /// Canonicalise the two aggregation aliases Python canonicalises.
270    ///
271    /// The result key is the function NAME, so an un-normalised alias means the
272    /// same query answers under a different key depending on which engine ran
273    /// it: `| stats avg(salary) by d` produced `{"avg": 150.0}` here and
274    /// `{"average": 150.0}` in Python, and a consumer reading one gets nothing
275    /// from the other. Measured in both engines on 2026-09-04 across all seven
276    /// documented alias pairs; `avg`/`average` and `med`/`median` are the only
277    /// two that diverge. `cardinality`/`unique_count` and
278    /// `unique`/`distinct`/`values` already agree, in BOTH engines, by keeping
279    /// the spelling the user typed — which is why this normalises exactly the
280    /// two Python normalises rather than introducing a general rule that would
281    /// change five more keys.
282    ///
283    /// Python is the reference because it is the backend's engine: any existing
284    /// consumer was written against its keys. `stats_evaluator` already matches
285    /// `"average" | "avg" | "mean"` and `"median" | "med"`, so the canonical
286    /// spellings were always accepted; only the key differed.
287    ///
288    /// `mean` is deliberately NOT folded in: Python leaves it as `mean`, so
289    /// folding it here would create the divergence this removes.
290    fn normalise_agg_alias(name: &str) -> String {
291        match name {
292            "avg" => "average".to_string(),
293            "med" => "median".to_string(),
294            other => other.to_string(),
295        }
296    }
297
298    /// Parse an aggregation function
299    fn parse_aggregation(&self, pair: pest::iterators::Pair<Rule>) -> Result<Aggregation> {
300        let mut function = String::new();
301        let mut field = None;
302        let mut alias = None;
303        let mut modifier = None;
304        let mut limit = None;
305        let mut percentile_values = None;
306        let mut rank_values = None;
307        let mut field_mutators = None;
308
309        for inner_pair in pair.into_inner() {
310            match inner_pair.as_rule() {
311                Rule::agg_func_name => {
312                    function = Self::normalise_agg_alias(&inner_pair.as_str().to_lowercase());
313                }
314                Rule::agg_field => {
315                    let field_str = inner_pair.as_str();
316                    if field_str != "*" {
317                        // Parse field_with_mutators
318                        let mut field_name = String::new();
319                        let mut mutators = Vec::new();
320
321                        // `agg_field = { field_with_mutators | "*" }`, so the
322                        // pairs yielded here are `field_with_mutators` — NOT
323                        // `field_name`. Matching on `Rule::field_name` at this
324                        // level therefore never fired: `field_name` stayed
325                        // empty and every field aggregation silently ran
326                        // against a field called "", producing `sum` = -0.0 and
327                        // `avg`/`min`/`max`/`median` = null. `count()` was the
328                        // only survivor, because the `"*"` literal has no inner
329                        // pair and takes the else branch.
330                        //
331                        // `parse_group_by_list` already performs exactly this
332                        // descent, which is why `by <field>` worked while
333                        // `sum(<field>)` did not — the two were written against
334                        // different assumptions about the same grammar rule.
335                        //
336                        // Descend one level so both shapes are handled: a bare
337                        // `field_name` if the grammar is ever flattened, and the
338                        // `field_with_mutators` wrapper it actually produces.
339                        for field_inner in inner_pair.into_inner() {
340                            match field_inner.as_rule() {
341                                Rule::field_with_mutators => {
342                                    for fwm_inner in field_inner.into_inner() {
343                                        match fwm_inner.as_rule() {
344                                            Rule::field_name => {
345                                                field_name = fwm_inner.as_str().to_string();
346                                            }
347                                            Rule::mutator => {
348                                                mutators.push(self.parse_mutator(fwm_inner)?);
349                                            }
350                                            _ => {}
351                                        }
352                                    }
353                                }
354                                Rule::field_name => {
355                                    field_name = field_inner.as_str().to_string();
356                                }
357                                Rule::mutator => {
358                                    mutators.push(self.parse_mutator(field_inner)?);
359                                }
360                                _ => {}
361                            }
362                        }
363
364                        field = Some(field_name);
365                        if !mutators.is_empty() {
366                            field_mutators = Some(mutators);
367                        }
368                    } else {
369                        field = Some("*".to_string());
370                    }
371                }
372                Rule::field_with_mutators => {
373                    // The in-parens-modifier alternative of `aggregation` names
374                    // `field_with_mutators` DIRECTLY rather than going through
375                    // `agg_field`, so this arm is how its field arrives.
376                    //
377                    // It is spelled that way on purpose: `agg_field` also matches
378                    // `*`, and routing the in-parens modifier through it would
379                    // make Rust newly accept `count(*, top 2)` -- which Python
380                    // REFUSES, so the fix for one non-portable spelling would have
381                    // introduced another. Python's `count_all` / `count_empty`
382                    // rules have no modifier slot at all; adding one was tried and
383                    // reverted, because the flat (ungrouped) token shape those
384                    // rules produce makes the stats builder in `parser.py` read
385                    // the modifier into the field position -- it then parses
386                    // `count(*) top 2` while silently dropping BOTH the modifier
387                    // and any alias. A spelling that parses and quietly discards
388                    // the instruction is worse than one that is refused.
389                    //
390                    // `count(*) top 2` (modifier AFTER the parens) therefore stays
391                    // Rust-only, exactly as it was before this change. That gap is
392                    // pre-existing and is NOT closed here; closing it needs the
393                    // stats builder in `parser.py`, not the grammars.
394                    let (f, fm, _) = self.parse_field_with_mutators(inner_pair)?;
395                    field = Some(f);
396                    if fm.is_some() {
397                        field_mutators = fm;
398                    }
399                }
400                Rule::agg_modifier => {
401                    // `agg_modifier` is `(^"top" | ^"bottom") ~ integer`, so this
402                    // is the SOURCE text and `TOP 10` is well-formed. A
403                    // case-SENSITIVE `starts_with("top")` therefore matched
404                    // neither arm for any non-lowercase spelling and left
405                    // `modifier: None` beside a perfectly good `limit: Some(10)`
406                    // -- a top-N request that parsed, carried its N, and lost the
407                    // instruction to apply it. Python has never had this: its
408                    // `one_of(..., caseless=True)` yields the canonical lowercase
409                    // form.
410                    let mod_text = inner_pair.as_str().to_lowercase();
411                    if mod_text.starts_with("top") {
412                        modifier = Some("top".to_string());
413                    } else if mod_text.starts_with("bottom") {
414                        modifier = Some("bottom".to_string());
415                    }
416                    // Extract number from modifier.
417                    //
418                    // ABSENT and UNPARSEABLE are different questions, and
419                    // `.unwrap_or(10)` answered both with 10. Only the first one
420                    // wants a default: a modifier with no count produces no
421                    // `integer` pair at all, so this loop never runs and `limit`
422                    // stays `None`. A count the author DID write and that cannot
423                    // be represented is a defect in the query.
424                    //
425                    // `integer` is `"-"? ~ ASCII_DIGIT+` (grammar.pest), so
426                    // `top -1` arrives here as well-formed SOURCE that
427                    // `usize::from_str` refuses. Measured 2026-09-04 before this
428                    // change: `stats sum(salary) top -1 by department` parsed to
429                    // `modifier: Some("top"), limit: Some(10)` and the in-memory
430                    // engine answered ten buckets, while the SAME query through
431                    // the Python pushdown was refused outright
432                    // (`opensearch_stats.py::_validate_modifier`) and a live
433                    // cluster replies "[size] must be greater than 0". A saved
434                    // query therefore got an answer from the detection engine and
435                    // an error from the backend.
436                    //
437                    // The message says what Rust can OBSERVE, and deliberately
438                    // does NOT copy Python's "non-positive" phrasing. Python's
439                    // parser carries a signed int all the way to its translator,
440                    // so it can see a negative limit and name it. `Option<usize>`
441                    // cannot hold one at all, so what `from_str` actually rejects
442                    // here is a leading `-` or a count past `usize::MAX` --
443                    // "non-positive" would describe a state this type excludes,
444                    // and `top 0` (which IS representable) still parses and is
445                    // refused downstream by `stats_translator.rs`.
446                    for mod_inner in inner_pair.into_inner() {
447                        if mod_inner.as_rule() == Rule::integer {
448                            let raw = mod_inner.as_str();
449                            let position = mod_inner.as_span().start();
450                            limit =
451                                Some(raw.parse::<usize>().map_err(|_| TqlError::ParseError {
452                                    message: format!(
453                                        "'{raw}' is not a usable bucket count for a \
454                                         'top'/'bottom' modifier: the count must be a whole \
455                                         number from 0 to {}, so it can be neither negative nor \
456                                         larger than this engine can index. Write a positive \
457                                         count, or drop the modifier.",
458                                        usize::MAX
459                                    ),
460                                    position,
461                                    query: None,
462                                })?);
463                        }
464                    }
465                }
466                Rule::percentile_values => {
467                    // Same shape as the modifier count above, failing in the
468                    // other direction: `.ok()` inside `filter_map` DROPPED an
469                    // unparseable value, so `percentile(x, 50, 90)` would have
470                    // become a one-value request rather than an error -- and
471                    // arity is what `stats_translator.rs` dispatches on.
472                    //
473                    // This one is currently UNREACHABLE and is not tested as if
474                    // it were: `number = { float | integer }`, and every string
475                    // those two productions can match is accepted by
476                    // `f64::from_str` (a digit run past `f64::MAX` parses to
477                    // `inf`, it does not error). It is written to fail closed so
478                    // that widening `number` -- a hex form, digit separators, a
479                    // suffix -- surfaces as a refusal instead of a silently
480                    // shortened argument list.
481                    let values = inner_pair
482                        .into_inner()
483                        .filter(|p| p.as_rule() == Rule::number)
484                        .map(|p| {
485                            p.as_str().parse::<f64>().map_err(|_| TqlError::ParseError {
486                                message: format!("Invalid number in value list: {}", p.as_str()),
487                                position: p.as_span().start(),
488                                query: None,
489                            })
490                        })
491                        .collect::<Result<Vec<f64>>>()?;
492                    percentile_values = Some(values);
493                }
494                Rule::identifier => {
495                    // This is the alias after "as"
496                    alias = Some(inner_pair.as_str().to_string());
497                }
498                _ => {}
499            }
500        }
501
502        // The grammar has ONE numeric-list slot (`percentile_values`), which the
503        // rank family reuses: `pct_rank(n, 5, 8)` puts 5 and 8 there. Routing
504        // it to `rank_values` for those functions is what Python's parser does,
505        // and without it `agg.rank_values` was hardcoded `None` at construction
506        // -- so `opensearch/stats_translator.rs`'s percentile_ranks arm could
507        // only ever return "percentile_rank requires at least one value". That
508        // arm was unreachable anyway until the grammar guard above landed;
509        // reaching it and then failing unconditionally is not an improvement.
510        if matches!(
511            function.as_str(),
512            "percentile_rank" | "percentile_ranks" | "pct_rank" | "pct_ranks"
513        ) {
514            rank_values = percentile_values.take();
515        }
516
517        Ok(Aggregation {
518            function,
519            field,
520            alias,
521            modifier,
522            limit,
523            percentile_values,
524            rank_values,
525            field_mutators,
526        })
527    }
528
529    /// Parse group by list
530    fn parse_group_by_list(&self, pair: pest::iterators::Pair<Rule>) -> Result<Vec<GroupBy>> {
531        let mut group_by = Vec::new();
532
533        for inner_pair in pair.into_inner() {
534            if inner_pair.as_rule() == Rule::group_by_field {
535                let mut field = String::new();
536                let mut bucket_size = None;
537
538                for field_inner in inner_pair.into_inner() {
539                    match field_inner.as_rule() {
540                        Rule::field_with_mutators => {
541                            // Extract field name from field_with_mutators
542                            for fwm_inner in field_inner.into_inner() {
543                                if fwm_inner.as_rule() == Rule::field_name {
544                                    field = fwm_inner.as_str().to_string();
545                                    break;
546                                }
547                            }
548                        }
549                        Rule::integer => {
550                            // The group-by twin of the aggregation modifier's
551                            // count -- same `.unwrap_or(10)`, same grammar
552                            // (`group_by_field = { field_with_mutators ~ (^"top"
553                            // ~ integer)? }`, and `integer` admits a sign), so
554                            // the same silent substitution. Measured 2026-09-04
555                            // before this change: `stats count() by department
556                            // top -1` parsed to `bucket_size: Some(10)` in Rust
557                            // while the Python pushdown refused it
558                            // (`opensearch_stats.py::_validate_bucket_sizes`).
559                            //
560                            // A field with no `top` clause produces no `integer`
561                            // pair, so an ABSENT bucket size still arrives as
562                            // `None` and every later default keeps working. See
563                            // `parse_aggregation` for why the message is not
564                            // phrased as Python's is.
565                            let raw = field_inner.as_str();
566                            let position = field_inner.as_span().start();
567                            let whose = if field.is_empty() {
568                                "a group-by field".to_string()
569                            } else {
570                                format!("group-by field '{field}'")
571                            };
572                            bucket_size =
573                                Some(raw.parse::<usize>().map_err(|_| TqlError::ParseError {
574                                    message: format!(
575                                        "'top {raw}' on {whose} is not a usable bucket count: the \
576                                         count must be a whole number from 0 to {}, so it can be \
577                                         neither negative nor larger than this engine can index. \
578                                         Write a positive count, or drop the modifier.",
579                                        usize::MAX
580                                    ),
581                                    position,
582                                    query: None,
583                                })?);
584                        }
585                        _ => {}
586                    }
587                }
588
589                group_by.push(GroupBy { field, bucket_size });
590            }
591        }
592
593        Ok(group_by)
594    }
595
596    /// Parse a logical expression (AND/OR chain)
597    fn parse_logical_expr(
598        &self,
599        pair: pest::iterators::Pair<Rule>,
600        depth: usize,
601    ) -> Result<AstNode> {
602        let mut inner = pair.into_inner();
603
604        // Parse first term
605        let first_term_pair = inner.next().ok_or_else(|| TqlError::ParseError {
606            message: "Missing term in logical expression".to_string(),
607            position: 0,
608            query: None,
609        })?;
610        // The grammar is a flat `term ~ (logical_op ~ term)*`, so precedence has
611        // to be applied here rather than by the parser generator.
612        let mut terms = vec![self.parse_term(first_term_pair, depth + 1)?];
613        let mut operators: Vec<String> = Vec::new();
614
615        while let Some(op_pair) = inner.next() {
616            if op_pair.as_rule() != Rule::logical_op {
617                return Err(TqlError::ParseError {
618                    message: "Expected logical operator".to_string(),
619                    position: 0,
620                    query: None,
621                });
622            }
623
624            // Normalised to the word form here, so `&&` and `and` build the SAME
625            // node. Python now accepts `&&`/`||` (it used to reject them outright)
626            // and normalises at parse, so leaving the symbol verbatim would make
627            // the two engines emit different ASTs for one query -- and AST shape is
628            // what the cross-language fixtures compare.
629            let operator = self.normalize_operator(op_pair.as_str());
630
631            let right_pair = inner.next().ok_or_else(|| TqlError::ParseError {
632                message: "Missing right operand after logical operator".to_string(),
633                position: 0,
634                query: None,
635            })?;
636            terms.push(self.parse_term(right_pair, depth + 1)?);
637            operators.push(operator);
638        }
639
640        Ok(Self::fold_with_precedence(terms, operators))
641    }
642
643    /// Fold a flat `term (op term)*` sequence honouring AND-over-OR precedence.
644    ///
645    /// This was a plain left-to-right fold, which gives `a OR b AND c` the shape
646    /// `(a OR b) AND c`. The Python evaluator builds `a OR (b AND c)` --
647    /// pyparsing `infixNotation` lists AND before OR, both left-associative --
648    /// so the two engines answered differently for **356 of the 2,313 bundled
649    /// detection rules**, 182 of them severity 4.
650    ///
651    /// Nothing ever failed, because the two shapes coincide whenever AND comes
652    /// first: `a AND b OR c` folds identically either way. Only an OR *followed
653    /// by* an AND diverges -- and detection rules execute on the Rust agent
654    /// while every test, preview and live-fire check runs the Python evaluator.
655    ///
656    /// Both operators stay left-associative, matching Python. The operator string
657    /// arrives already normalised to `and`/`or`; it used to be preserved verbatim
658    /// on the reasoning that "the evaluator accepts both spellings and rewriting
659    /// would churn the AST for no gain", which was true only while Python REJECTED
660    /// `&&` and `||` outright. Python accepts them now and normalises at parse, so
661    /// the gain is that one query yields one AST in both engines. `is_and` still
662    /// matches both spellings, so this fold is correct either way.
663    fn fold_with_precedence(terms: Vec<AstNode>, operators: Vec<String>) -> AstNode {
664        debug_assert_eq!(terms.len(), operators.len() + 1);
665
666        let is_and = |op: &str| matches!(op, "and" | "&&");
667
668        // First pass: bind every AND, which leaves the operands of the ORs.
669        let mut iter = terms.into_iter();
670        let mut current = iter.next().expect("logical_expr always has one term");
671        let mut or_operands: Vec<AstNode> = Vec::new();
672        let mut or_operators: Vec<String> = Vec::new();
673
674        for (operator, term) in operators.into_iter().zip(iter) {
675            if is_and(&operator) {
676                current = AstNode::LogicalOp(LogicalOpNode {
677                    operator,
678                    left: Box::new(current),
679                    right: Box::new(term),
680                });
681            } else {
682                or_operands.push(current);
683                or_operators.push(operator);
684                current = term;
685            }
686        }
687        or_operands.push(current);
688
689        // Second pass: bind the ORs left-associatively over what remains.
690        let mut result_iter = or_operands.into_iter();
691        let mut result = result_iter.next().expect("at least one operand");
692        for (operator, operand) in or_operators.into_iter().zip(result_iter) {
693            result = AstNode::LogicalOp(LogicalOpNode {
694                operator,
695                left: Box::new(result),
696                right: Box::new(operand),
697            });
698        }
699        result
700    }
701
702    /// Parse a term (NOT expression or primary)
703    fn parse_term(&self, pair: pest::iterators::Pair<Rule>, depth: usize) -> Result<AstNode> {
704        match pair.as_rule() {
705            Rule::term => {
706                // Term contains either not_expr or primary
707                let inner_pair = pair
708                    .into_inner()
709                    .next()
710                    .ok_or_else(|| TqlError::ParseError {
711                        message: "Empty term".to_string(),
712                        position: 0,
713                        query: None,
714                    })?;
715                self.parse_term(inner_pair, depth + 1)
716            }
717            Rule::not_expr => {
718                let mut inner = pair.into_inner();
719                let _op = inner.next(); // Skip the NOT operator
720                let operand_pair = inner.next().ok_or_else(|| TqlError::ParseError {
721                    message: "Missing operand after NOT".to_string(),
722                    position: 0,
723                    query: None,
724                })?;
725                let operand = self.parse_term(operand_pair, depth + 1)?;
726
727                Ok(AstNode::UnaryOp(UnaryOpNode {
728                    operator: "not".to_string(),
729                    operand: Box::new(operand),
730                }))
731            }
732            Rule::primary => self.parse_primary(pair, depth + 1),
733            _ => Err(TqlError::ParseError {
734                message: format!("Unexpected rule in term: {:?}", pair.as_rule()),
735                position: 0,
736                query: None,
737            }),
738        }
739    }
740
741    /// Parse a primary expression (paren_expr or comparison)
742    fn parse_primary(&self, pair: pest::iterators::Pair<Rule>, depth: usize) -> Result<AstNode> {
743        match pair.as_rule() {
744            Rule::primary => {
745                let inner_pair = pair
746                    .into_inner()
747                    .next()
748                    .ok_or_else(|| TqlError::ParseError {
749                        message: "Empty primary".to_string(),
750                        position: 0,
751                        query: None,
752                    })?;
753                self.parse_primary(inner_pair, depth + 1)
754            }
755            Rule::paren_expr => {
756                let inner_pair = pair
757                    .into_inner()
758                    .next()
759                    .ok_or_else(|| TqlError::ParseError {
760                        message: "Empty parenthesized expression".to_string(),
761                        position: 0,
762                        query: None,
763                    })?;
764                self.parse_logical_expr(inner_pair, depth + 1)
765            }
766            Rule::comparison => self.parse_comparison(pair, depth + 1),
767            _ => Err(TqlError::ParseError {
768                message: format!("Unexpected rule in primary: {:?}", pair.as_rule()),
769                position: 0,
770                query: None,
771            }),
772        }
773    }
774
775    /// Parse a comparison expression
776    fn parse_comparison(
777        &self,
778        pair: pest::iterators::Pair<Rule>,
779        _depth: usize,
780    ) -> Result<AstNode> {
781        let inner_pair = pair
782            .into_inner()
783            .next()
784            .ok_or_else(|| TqlError::ParseError {
785                message: "Empty comparison".to_string(),
786                position: 0,
787                query: None,
788            })?;
789
790        match inner_pair.as_rule() {
791            Rule::collection_comparison => self.parse_collection_comparison(inner_pair),
792            Rule::between_comparison => self.parse_between_comparison(inner_pair),
793            Rule::in_fields_comparison | Rule::in_field_comparison => {
794                self.parse_in_fields_comparison(inner_pair)
795            }
796            Rule::is_null_comparison => self.parse_is_null_comparison(inner_pair),
797            Rule::unary_comparison => self.parse_unary_comparison(inner_pair),
798            Rule::binary_comparison => self.parse_binary_comparison(inner_pair),
799            Rule::field_only_expression => self.parse_field_only_expression(inner_pair),
800            _ => Err(TqlError::ParseError {
801                message: format!("Unknown comparison type: {:?}", inner_pair.as_rule()),
802                position: 0,
803                query: None,
804            }),
805        }
806    }
807
808    /// Parse collection comparison (ANY/ALL/NONE field op value)
809    fn parse_collection_comparison(&self, pair: pest::iterators::Pair<Rule>) -> Result<AstNode> {
810        let mut inner = pair.into_inner();
811
812        let first = inner.next().ok_or_else(|| TqlError::ParseError {
813            message: "Missing collection comparison element".to_string(),
814            position: 0,
815            query: None,
816        })?;
817
818        // Detect operator-first vs field-first syntax
819        let (operator, field, field_mutators, type_hint) = match first.as_rule() {
820            Rule::collection_op => {
821                // Operator-first: ANY field op value
822                let op = self.normalize_operator(first.as_str());
823                let field_pair = inner.next().ok_or_else(|| TqlError::ParseError {
824                    message: "Missing field in collection comparison".to_string(),
825                    position: 0,
826                    query: None,
827                })?;
828                let (f, fm, th) = self.parse_field_with_mutators(field_pair)?;
829                (op, f, fm, th)
830            }
831            Rule::field_with_mutators => {
832                // Field-first: field ANY op value
833                let (f, fm, th) = self.parse_field_with_mutators(first)?;
834                let op_pair = inner.next().ok_or_else(|| TqlError::ParseError {
835                    message: "Missing collection operator".to_string(),
836                    position: 0,
837                    query: None,
838                })?;
839                let op = self.normalize_operator(op_pair.as_str());
840                (op, f, fm, th)
841            }
842            _ => {
843                return Err(TqlError::ParseError {
844                    message: format!(
845                        "Unexpected rule in collection comparison: {:?}",
846                        first.as_rule()
847                    ),
848                    position: 0,
849                    query: None,
850                });
851            }
852        };
853
854        // Next could be comparison_op or value_with_mutators (shorthand: implicit eq)
855        let next_pair = inner.next().ok_or_else(|| TqlError::ParseError {
856            message: "Missing comparison operator or value in collection comparison".to_string(),
857            position: 0,
858            query: None,
859        })?;
860
861        let (comparison_operator, value) = match next_pair.as_rule() {
862            Rule::comparison_op => {
863                let comp_op = self.normalize_operator(next_pair.as_str());
864                let value_pair = inner.next().ok_or_else(|| TqlError::ParseError {
865                    message: "Missing value in collection comparison".to_string(),
866                    position: 0,
867                    query: None,
868                })?;
869                let (val, _value_mutators) = self.parse_value_with_mutators(value_pair)?;
870                (comp_op, val)
871            }
872            Rule::value_with_mutators => {
873                // Shorthand: implicit eq operator
874                let (val, _value_mutators) = self.parse_value_with_mutators(next_pair)?;
875                ("eq".to_string(), val)
876            }
877            _ => {
878                return Err(TqlError::ParseError {
879                    message: format!(
880                        "Unexpected rule in collection comparison: {:?}",
881                        next_pair.as_rule()
882                    ),
883                    position: 0,
884                    query: None,
885                });
886            }
887        };
888
889        Ok(AstNode::CollectionOp(CollectionOpNode {
890            operator,
891            field,
892            comparison_operator,
893            value,
894            field_mutators,
895            type_hint,
896        }))
897    }
898
899    /// Parse between comparison (field between [val1, val2] or field between val1 and val2)
900    fn parse_between_comparison(&self, pair: pest::iterators::Pair<Rule>) -> Result<AstNode> {
901        let mut inner = pair.into_inner();
902
903        let field_pair = inner.next().ok_or_else(|| TqlError::ParseError {
904            message: "Missing field in between comparison".to_string(),
905            position: 0,
906            query: None,
907        })?;
908        let (field, field_mutators, type_hint) = self.parse_field_with_mutators(field_pair)?;
909
910        let op_pair = inner.next().ok_or_else(|| TqlError::ParseError {
911            message: "Missing operator in between comparison".to_string(),
912            position: 0,
913            query: None,
914        })?;
915        let operator = self.normalize_operator(op_pair.as_str());
916
917        let next_pair = inner.next().ok_or_else(|| TqlError::ParseError {
918            message: "Missing value in between comparison".to_string(),
919            position: 0,
920            query: None,
921        })?;
922
923        // Handle both list syntax [val1, val2] and natural syntax val1 and val2
924        let value = match next_pair.as_rule() {
925            Rule::list_value => self.parse_list(next_pair)?,
926            Rule::value => {
927                // Natural syntax: value AND value
928                let first = self.parse_value(next_pair)?;
929                let second_pair = inner.next().ok_or_else(|| TqlError::ParseError {
930                    message: "Missing second value in between X and Y".to_string(),
931                    position: 0,
932                    query: None,
933                })?;
934                let second = self.parse_value(second_pair)?;
935                Value::List(vec![first, second])
936            }
937            _ => {
938                return Err(TqlError::ParseError {
939                    message: format!(
940                        "Unexpected rule in between comparison: {:?}",
941                        next_pair.as_rule()
942                    ),
943                    position: 0,
944                    query: None,
945                });
946            }
947        };
948
949        Ok(AstNode::Comparison(ComparisonNode {
950            field,
951            operator,
952            value: Some(value),
953            field_mutators,
954            value_mutators: None,
955            type_hint,
956        }))
957    }
958
959    /// Parse in fields comparison (value in [field1, field2] or value in field)
960    fn parse_in_fields_comparison(&self, pair: pest::iterators::Pair<Rule>) -> Result<AstNode> {
961        let mut inner = pair.into_inner();
962
963        let value_pair = inner.next().ok_or_else(|| TqlError::ParseError {
964            message: "Missing value in 'value in field' comparison".to_string(),
965            position: 0,
966            query: None,
967        })?;
968        let (value, value_mutators) = self.parse_value_with_mutators(value_pair)?;
969
970        let _op_pair = inner.next(); // Skip in_op
971
972        let next_pair = inner.next().ok_or_else(|| TqlError::ParseError {
973            message: "Missing field/list in 'value in ...' comparison".to_string(),
974            position: 0,
975            query: None,
976        })?;
977
978        match next_pair.as_rule() {
979            Rule::in_fields_list => {
980                // value in [field1, field2] - check if value equals any named field
981                // Generates: field1 = value OR field2 = value OR ...
982                let fields: Vec<String> = next_pair
983                    .into_inner()
984                    .filter(|p| p.as_rule() == Rule::field_name)
985                    .map(|p| p.as_str().to_string())
986                    .collect();
987
988                if fields.is_empty() {
989                    return Ok(AstNode::MatchAll);
990                }
991
992                // Build a chain of OR comparisons
993                let mut result = AstNode::Comparison(ComparisonNode {
994                    field: fields[0].clone(),
995                    operator: "eq".to_string(),
996                    value: Some(value.clone()),
997                    field_mutators: None,
998                    value_mutators: value_mutators.clone(),
999                    type_hint: None,
1000                });
1001
1002                for field in &fields[1..] {
1003                    let right = AstNode::Comparison(ComparisonNode {
1004                        field: field.clone(),
1005                        operator: "eq".to_string(),
1006                        value: Some(value.clone()),
1007                        field_mutators: None,
1008                        value_mutators: value_mutators.clone(),
1009                        type_hint: None,
1010                    });
1011                    result = AstNode::LogicalOp(LogicalOpNode {
1012                        operator: "or".to_string(),
1013                        left: Box::new(result),
1014                        right: Box::new(right),
1015                    });
1016                }
1017
1018                Ok(result)
1019            }
1020            Rule::field_with_mutators => {
1021                // value in field - semantically equivalent to: field contains value
1022                let (field, field_mutators, type_hint) =
1023                    self.parse_field_with_mutators(next_pair)?;
1024                Ok(AstNode::Comparison(ComparisonNode {
1025                    field,
1026                    operator: "contains".to_string(),
1027                    value: Some(value),
1028                    field_mutators,
1029                    value_mutators,
1030                    type_hint,
1031                }))
1032            }
1033            _ => Err(TqlError::ParseError {
1034                message: format!(
1035                    "Unexpected rule in in_fields_comparison: {:?}",
1036                    next_pair.as_rule()
1037                ),
1038                position: 0,
1039                query: None,
1040            }),
1041        }
1042    }
1043
1044    /// Parse is null comparison (field IS NULL / IS NOT NULL)
1045    fn parse_is_null_comparison(&self, pair: pest::iterators::Pair<Rule>) -> Result<AstNode> {
1046        let mut inner = pair.into_inner();
1047
1048        let field_pair = inner.next().ok_or_else(|| TqlError::ParseError {
1049            message: "Missing field in is null comparison".to_string(),
1050            position: 0,
1051            query: None,
1052        })?;
1053        let (field, field_mutators, type_hint) = self.parse_field_with_mutators(field_pair)?;
1054
1055        let op_pair = inner.next().ok_or_else(|| TqlError::ParseError {
1056            message: "Missing operator in is null comparison".to_string(),
1057            position: 0,
1058            query: None,
1059        })?;
1060        let operator = self.normalize_operator(op_pair.as_str());
1061
1062        Ok(AstNode::Comparison(ComparisonNode {
1063            field,
1064            operator,
1065            value: Some(Value::Null),
1066            field_mutators,
1067            value_mutators: None,
1068            type_hint,
1069        }))
1070    }
1071
1072    /// Parse unary comparison (field EXISTS / field NOT EXISTS)
1073    fn parse_unary_comparison(&self, pair: pest::iterators::Pair<Rule>) -> Result<AstNode> {
1074        let mut inner = pair.into_inner();
1075
1076        let field_pair = inner.next().ok_or_else(|| TqlError::ParseError {
1077            message: "Missing field in unary comparison".to_string(),
1078            position: 0,
1079            query: None,
1080        })?;
1081        let (field, field_mutators, type_hint) = self.parse_field_with_mutators(field_pair)?;
1082
1083        let op_pair = inner.next().ok_or_else(|| TqlError::ParseError {
1084            message: "Missing operator in unary comparison".to_string(),
1085            position: 0,
1086            query: None,
1087        })?;
1088        let operator = self.normalize_operator(op_pair.as_str());
1089
1090        Ok(AstNode::Comparison(ComparisonNode {
1091            field,
1092            operator,
1093            value: None,
1094            field_mutators,
1095            value_mutators: None,
1096            type_hint,
1097        }))
1098    }
1099
1100    /// Parse binary comparison (field op value)
1101    fn parse_binary_comparison(&self, pair: pest::iterators::Pair<Rule>) -> Result<AstNode> {
1102        let mut inner = pair.into_inner();
1103
1104        let field_pair = inner.next().ok_or_else(|| TqlError::ParseError {
1105            message: "Missing field in binary comparison".to_string(),
1106            position: 0,
1107            query: None,
1108        })?;
1109        let (field, field_mutators, type_hint) = self.parse_field_with_mutators(field_pair)?;
1110
1111        let op_pair = inner.next().ok_or_else(|| TqlError::ParseError {
1112            message: "Missing operator in binary comparison".to_string(),
1113            position: 0,
1114            query: None,
1115        })?;
1116        let operator = self.normalize_operator(op_pair.as_str());
1117
1118        let value_pair = inner.next().ok_or_else(|| TqlError::ParseError {
1119            message: "Missing value in binary comparison".to_string(),
1120            position: 0,
1121            query: None,
1122        })?;
1123        let (value, value_mutators) = self.parse_value_with_mutators(value_pair)?;
1124
1125        Ok(AstNode::Comparison(ComparisonNode {
1126            field,
1127            operator,
1128            value: Some(value),
1129            field_mutators,
1130            value_mutators,
1131            type_hint,
1132        }))
1133    }
1134
1135    /// Parse field-only expression (enrichment without filtering)
1136    /// Matches all records and applies mutators to the field
1137    fn parse_field_only_expression(&self, pair: pest::iterators::Pair<Rule>) -> Result<AstNode> {
1138        // field_only_expression contains field_with_mutators
1139        let field_pair = pair
1140            .into_inner()
1141            .next()
1142            .ok_or_else(|| TqlError::ParseError {
1143                message: "Missing field in field-only expression".to_string(),
1144                position: 0,
1145                query: None,
1146            })?;
1147
1148        let (field, field_mutators, type_hint) = self.parse_field_with_mutators(field_pair)?;
1149
1150        // What `field | mutator` MEANS with no operator depends on what the
1151        // last mutator answers.
1152        //
1153        // A PREDICATE (`ip | is_loopback`) is a FILTER: it must compare against
1154        // `true`. A TRANSFORM (`ip | lowercase`) is a PROJECTION: keep every
1155        // record that has the field and apply the mutator on the way out, which
1156        // is what `exists` expresses.
1157        //
1158        // Every mutator took the projection branch, so all five IP predicates
1159        // matched every record that HAS the field at all while reading as a
1160        // filter -- a clause that cannot fail loudly, it just stops filtering.
1161        //
1162        // The predicate set is DERIVED from the mutator itself
1163        // (`mutators::returns_boolean`), never enumerated here. Python spelled
1164        // the equivalent list out by hand in three places and the three
1165        // predicates added later reached none of them; a name list at the use
1166        // site is exactly that defect waiting to happen again.
1167        let last_is_predicate = field_mutators
1168            .as_ref()
1169            .and_then(|m| m.last())
1170            .is_some_and(|m| crate::mutators::returns_boolean(&m.name));
1171
1172        let (operator, value) = if last_is_predicate {
1173            ("eq".to_string(), Some(Value::Boolean(true)))
1174        } else {
1175            ("exists".to_string(), None)
1176        };
1177
1178        Ok(AstNode::Comparison(ComparisonNode {
1179            field,
1180            operator,
1181            value,
1182            field_mutators,
1183            value_mutators: None,
1184            type_hint,
1185        }))
1186    }
1187
1188    /// Parse field with mutators and type hint
1189    fn parse_field_with_mutators(
1190        &self,
1191        pair: pest::iterators::Pair<Rule>,
1192    ) -> Result<(String, Option<Vec<Mutator>>, Option<String>)> {
1193        let mut field = String::new();
1194        let mut mutators = Vec::new();
1195        let mut type_hint = None;
1196
1197        for inner_pair in pair.into_inner() {
1198            match inner_pair.as_rule() {
1199                Rule::field_name => {
1200                    field = inner_pair.as_str().to_string();
1201                }
1202                Rule::mutator => {
1203                    mutators.push(self.parse_mutator(inner_pair)?);
1204                }
1205                Rule::type_hint => {
1206                    for type_inner in inner_pair.into_inner() {
1207                        if type_inner.as_rule() == Rule::type_name {
1208                            type_hint = Some(type_inner.as_str().to_lowercase());
1209                        }
1210                    }
1211                }
1212                _ => {}
1213            }
1214        }
1215
1216        let field_mutators = if mutators.is_empty() {
1217            None
1218        } else {
1219            Some(mutators)
1220        };
1221        Ok((field, field_mutators, type_hint))
1222    }
1223
1224    /// Parse value with mutators
1225    fn parse_value_with_mutators(
1226        &self,
1227        pair: pest::iterators::Pair<Rule>,
1228    ) -> Result<(Value, Option<Vec<Mutator>>)> {
1229        let mut value = Value::Null;
1230        let mut mutators = Vec::new();
1231
1232        for inner_pair in pair.into_inner() {
1233            match inner_pair.as_rule() {
1234                Rule::value => {
1235                    value = self.parse_value(inner_pair)?;
1236                }
1237                Rule::mutator => {
1238                    mutators.push(self.parse_mutator(inner_pair)?);
1239                }
1240                _ => {}
1241            }
1242        }
1243
1244        let value_mutators = if mutators.is_empty() {
1245            None
1246        } else {
1247            Some(mutators)
1248        };
1249        Ok((value, value_mutators))
1250    }
1251
1252    /// Parse a value
1253    fn parse_value(&self, pair: pest::iterators::Pair<Rule>) -> Result<Value> {
1254        let inner_pair = pair
1255            .into_inner()
1256            .next()
1257            .ok_or_else(|| TqlError::ParseError {
1258                message: "Empty value".to_string(),
1259                position: 0,
1260                query: None,
1261            })?;
1262
1263        match inner_pair.as_rule() {
1264            Rule::string => self.parse_string(inner_pair),
1265            Rule::cidr_value | Rule::ip_value => {
1266                // CIDR/IP literals parsed as strings (e.g., 192.168.1.0/24, 10.0.0.1)
1267                Ok(Value::String(inner_pair.as_str().to_string()))
1268            }
1269            Rule::number => self.parse_number(inner_pair),
1270            Rule::boolean => Ok(Value::Boolean(inner_pair.as_str().to_lowercase() == "true")),
1271            Rule::null => Ok(Value::Null),
1272            Rule::list_value => self.parse_list(inner_pair),
1273            Rule::identifier => {
1274                // Treat unquoted identifiers as string values (for feature parity with Python)
1275                // This enables queries like: user.name != local_service
1276                Ok(Value::String(inner_pair.as_str().to_string()))
1277            }
1278            _ => Err(TqlError::ParseError {
1279                message: format!("Unknown value type: {:?}", inner_pair.as_rule()),
1280                position: 0,
1281                query: None,
1282            }),
1283        }
1284    }
1285
1286    /// Parse a string value
1287    fn parse_string(&self, pair: pest::iterators::Pair<Rule>) -> Result<Value> {
1288        // string -> string_double/string_single
1289        let string_pair = pair
1290            .into_inner()
1291            .next()
1292            .ok_or_else(|| TqlError::ParseError {
1293                message: "Empty string rule".to_string(),
1294                position: 0,
1295                query: None,
1296            })?;
1297
1298        // string_double/string_single -> inner_double/inner_single (quotes are matched but not captured as pairs)
1299        let inner_pair = string_pair
1300            .into_inner()
1301            .next()
1302            .ok_or_else(|| TqlError::ParseError {
1303                message: "No inner string content".to_string(),
1304                position: 0,
1305                query: None,
1306            })?;
1307
1308        // inner_double/inner_single has the actual string content without quotes
1309        let content = inner_pair.as_str();
1310
1311        Ok(Value::String(unescape_string_literal(content)))
1312    }
1313
1314    /// Parse a number value
1315    fn parse_number(&self, pair: pest::iterators::Pair<Rule>) -> Result<Value> {
1316        let inner_pair = pair
1317            .into_inner()
1318            .next()
1319            .ok_or_else(|| TqlError::ParseError {
1320                message: "Empty number".to_string(),
1321                position: 0,
1322                query: None,
1323            })?;
1324
1325        match inner_pair.as_rule() {
1326            Rule::float => {
1327                let f = inner_pair
1328                    .as_str()
1329                    .parse::<f64>()
1330                    .map_err(|_| TqlError::ParseError {
1331                        message: format!("Invalid float: {}", inner_pair.as_str()),
1332                        position: 0,
1333                        query: None,
1334                    })?;
1335                Ok(Value::Float(f))
1336            }
1337            Rule::integer => {
1338                let i = inner_pair
1339                    .as_str()
1340                    .parse::<i64>()
1341                    .map_err(|_| TqlError::ParseError {
1342                        message: format!("Invalid integer: {}", inner_pair.as_str()),
1343                        position: 0,
1344                        query: None,
1345                    })?;
1346                Ok(Value::Integer(i))
1347            }
1348            _ => Err(TqlError::ParseError {
1349                message: format!("Unknown number type: {:?}", inner_pair.as_rule()),
1350                position: 0,
1351                query: None,
1352            }),
1353        }
1354    }
1355
1356    /// Parse a list value
1357    fn parse_list(&self, pair: pest::iterators::Pair<Rule>) -> Result<Value> {
1358        let mut values = Vec::new();
1359
1360        for inner_pair in pair.into_inner() {
1361            if inner_pair.as_rule() == Rule::value {
1362                values.push(self.parse_value(inner_pair)?);
1363            }
1364        }
1365
1366        Ok(Value::List(values))
1367    }
1368
1369    /// Parse a mutator
1370    fn parse_mutator(&self, pair: pest::iterators::Pair<Rule>) -> Result<Mutator> {
1371        let mut name = String::new();
1372        let mut args = Vec::new();
1373        let mut named_args = std::collections::HashMap::new();
1374
1375        for inner_pair in pair.into_inner() {
1376            match inner_pair.as_rule() {
1377                Rule::mutator_name => {
1378                    name = inner_pair.as_str().to_string();
1379                }
1380                Rule::mutator_args => {
1381                    for arg_pair in inner_pair.into_inner() {
1382                        if arg_pair.as_rule() == Rule::mutator_arg {
1383                            // Check if this arg contains a named_arg
1384                            let mut inner = arg_pair.into_inner();
1385                            let first = inner.next().ok_or_else(|| TqlError::ParseError {
1386                                message: "Empty mutator argument".to_string(),
1387                                position: 0,
1388                                query: None,
1389                            })?;
1390                            if first.as_rule() == Rule::mutator_named_arg {
1391                                let (key, val) = self.parse_mutator_named_arg(first)?;
1392                                named_args.insert(key, val);
1393                            } else {
1394                                args.push(self.parse_value_from_rule(first)?);
1395                            }
1396                        }
1397                    }
1398                }
1399                _ => {}
1400            }
1401        }
1402
1403        Ok(Mutator {
1404            name,
1405            args,
1406            named_args,
1407        })
1408    }
1409
1410    /// Parse a named mutator argument (key=value)
1411    fn parse_mutator_named_arg(
1412        &self,
1413        pair: pest::iterators::Pair<Rule>,
1414    ) -> Result<(String, Value)> {
1415        let mut inner = pair.into_inner();
1416        let key = inner
1417            .next()
1418            .ok_or_else(|| TqlError::ParseError {
1419                message: "Missing named arg key".to_string(),
1420                position: 0,
1421                query: None,
1422            })?
1423            .as_str()
1424            .to_string();
1425        let value_pair = inner.next().ok_or_else(|| TqlError::ParseError {
1426            message: "Missing named arg value".to_string(),
1427            position: 0,
1428            query: None,
1429        })?;
1430        let value = self.parse_value_from_rule(value_pair)?;
1431        Ok((key, value))
1432    }
1433
1434    /// Parse a value from any rule type (string, number, boolean, null, identifier)
1435    fn parse_value_from_rule(&self, pair: pest::iterators::Pair<Rule>) -> Result<Value> {
1436        match pair.as_rule() {
1437            Rule::string => self.parse_string(pair),
1438            Rule::number => self.parse_number(pair),
1439            Rule::boolean => Ok(Value::Boolean(pair.as_str().to_lowercase() == "true")),
1440            Rule::null => Ok(Value::Null),
1441            Rule::identifier => Ok(Value::String(pair.as_str().to_string())),
1442            _ => Err(TqlError::ParseError {
1443                message: format!("Invalid mutator argument type: {:?}", pair.as_rule()),
1444                position: 0,
1445                query: None,
1446            }),
1447        }
1448    }
1449
1450    /// Normalize operator to canonical form
1451    fn normalize_operator(&self, op: &str) -> String {
1452        let normalized = op.to_lowercase().replace(' ', "_");
1453
1454        // Symbol operators
1455        match normalized.as_str() {
1456            "=" => return "eq".to_string(),
1457            "!=" => return "ne".to_string(),
1458            ">" => return "gt".to_string(),
1459            ">=" => return "gte".to_string(),
1460            "<" => return "lt".to_string(),
1461            "<=" => return "lte".to_string(),
1462            "&&" => return "and".to_string(),
1463            "||" => return "or".to_string(),
1464            "!" => return "not".to_string(),
1465            _ => {}
1466        }
1467
1468        // Decompose into (negated, base_operator)
1469        let (negated, base) = if let Some(rest) = normalized.strip_prefix('!') {
1470            (true, rest.to_string())
1471        } else if let Some(rest) = normalized.strip_prefix("not_") {
1472            (true, rest.to_string())
1473        } else {
1474            (false, normalized)
1475        };
1476
1477        // Normalize regex/regexp synonyms to "matches"
1478        let base = match base.as_str() {
1479            "regex" | "regexp" => "matches".to_string(),
1480            _ => base,
1481        };
1482
1483        if negated {
1484            format!("not_{}", base)
1485        } else {
1486            base
1487        }
1488    }
1489
1490    /// Extract all field names referenced in a query
1491    ///
1492    /// # Arguments
1493    ///
1494    /// * `query` - The TQL query string
1495    ///
1496    /// # Returns
1497    ///
1498    /// A sorted list of unique field names
1499    ///
1500    /// # Examples
1501    ///
1502    /// ```ignore
1503    /// let parser = TqlParser::new();
1504    /// let fields = parser.extract_fields("name eq 'John' AND age > 25").unwrap();
1505    /// assert_eq!(fields, vec!["age", "name"]);
1506    /// ```
1507    pub fn extract_fields(&self, query: &str) -> Result<Vec<String>> {
1508        let ast = self.parse(query)?;
1509        let mut fields = Vec::new();
1510        self.collect_fields(&ast, &mut fields);
1511        fields.sort();
1512        fields.dedup();
1513        Ok(fields)
1514    }
1515
1516    /// Recursively collect field names from AST
1517    #[allow(clippy::only_used_in_recursion)]
1518    fn collect_fields(&self, node: &AstNode, fields: &mut Vec<String>) {
1519        match node {
1520            AstNode::Comparison(comp) => {
1521                fields.push(comp.field.clone());
1522            }
1523            AstNode::LogicalOp(logical) => {
1524                self.collect_fields(&logical.left, fields);
1525                self.collect_fields(&logical.right, fields);
1526            }
1527            AstNode::UnaryOp(unary) => {
1528                self.collect_fields(&unary.operand, fields);
1529            }
1530            AstNode::CollectionOp(coll) => {
1531                fields.push(coll.field.clone());
1532            }
1533            AstNode::GeoExpr(geo) => {
1534                fields.push(geo.field.clone());
1535                if let Some(ref cond) = geo.conditions {
1536                    self.collect_fields(cond, fields);
1537                }
1538            }
1539            AstNode::NslookupExpr(nslookup) => {
1540                fields.push(nslookup.field.clone());
1541                if let Some(ref cond) = nslookup.conditions {
1542                    self.collect_fields(cond, fields);
1543                }
1544            }
1545            AstNode::QueryWithStats(qws) => {
1546                self.collect_fields(&qws.filter, fields);
1547                for agg in &qws.stats.aggregations {
1548                    if let Some(ref field) = agg.field {
1549                        if field != "*" {
1550                            fields.push(field.clone());
1551                        }
1552                    }
1553                }
1554                for group_by in &qws.stats.group_by {
1555                    fields.push(group_by.field.clone());
1556                }
1557            }
1558            AstNode::StatsExpr(stats) => {
1559                for agg in &stats.aggregations {
1560                    if let Some(ref field) = agg.field {
1561                        if field != "*" {
1562                            fields.push(field.clone());
1563                        }
1564                    }
1565                }
1566                for group_by in &stats.group_by {
1567                    fields.push(group_by.field.clone());
1568                }
1569            }
1570            AstNode::MatchAll => {}
1571        }
1572    }
1573
1574    /// Parse a viz_value into VizParamValue
1575    fn parse_viz_value(pair: pest::iterators::Pair<Rule>) -> Result<VizParamValue> {
1576        // viz_value = { string | number | boolean | identifier }
1577        let inner = pair
1578            .into_inner()
1579            .next()
1580            .ok_or_else(|| TqlError::ParseError {
1581                message: "Empty viz value".to_string(),
1582                position: 0,
1583                query: None,
1584            })?;
1585        match inner.as_rule() {
1586            Rule::string => {
1587                // string -> string_double | string_single -> inner_double | inner_single
1588                let string_inner = inner.into_inner().next().unwrap();
1589                let content = string_inner
1590                    .into_inner()
1591                    .next()
1592                    .map(|p| p.as_str().to_string())
1593                    .unwrap_or_default();
1594                Ok(VizParamValue::String(content))
1595            }
1596            Rule::number => {
1597                let num_inner = inner.into_inner().next().unwrap();
1598                match num_inner.as_rule() {
1599                    Rule::float => {
1600                        let f: f64 =
1601                            num_inner
1602                                .as_str()
1603                                .parse()
1604                                .map_err(|_| TqlError::ParseError {
1605                                    message: format!("Invalid float: {}", num_inner.as_str()),
1606                                    position: 0,
1607                                    query: None,
1608                                })?;
1609                        Ok(VizParamValue::Float(f))
1610                    }
1611                    Rule::integer => {
1612                        let i: i64 =
1613                            num_inner
1614                                .as_str()
1615                                .parse()
1616                                .map_err(|_| TqlError::ParseError {
1617                                    message: format!("Invalid integer: {}", num_inner.as_str()),
1618                                    position: 0,
1619                                    query: None,
1620                                })?;
1621                        Ok(VizParamValue::Integer(i))
1622                    }
1623                    _ => Err(TqlError::ParseError {
1624                        message: format!("Unexpected number type: {:?}", num_inner.as_rule()),
1625                        position: 0,
1626                        query: None,
1627                    }),
1628                }
1629            }
1630            Rule::boolean => {
1631                let b = inner.as_str().eq_ignore_ascii_case("true");
1632                Ok(VizParamValue::Boolean(b))
1633            }
1634            Rule::identifier => {
1635                // Identifiers used as bare values (e.g., legend=right)
1636                Ok(VizParamValue::String(inner.as_str().to_string()))
1637            }
1638            _ => Err(TqlError::ParseError {
1639                message: format!("Unexpected viz value type: {:?}", inner.as_rule()),
1640                position: 0,
1641                query: None,
1642            }),
1643        }
1644    }
1645}
1646
1647#[cfg(test)]
1648mod tests {
1649    use super::*;
1650
1651    #[test]
1652    fn test_parser_creation() {
1653        let parser = TqlParser::new();
1654        assert_eq!(parser.max_depth, TqlParser::MAX_QUERY_DEPTH);
1655    }
1656
1657    #[test]
1658    fn test_empty_query() {
1659        let parser = TqlParser::new();
1660        let result = parser.parse("").unwrap();
1661        assert!(matches!(result, AstNode::MatchAll));
1662    }
1663
1664    #[test]
1665    fn test_whitespace_only_query() {
1666        let parser = TqlParser::new();
1667        let result = parser.parse("   \t\n  ").unwrap();
1668        assert!(matches!(result, AstNode::MatchAll));
1669    }
1670
1671    #[test]
1672    fn test_custom_max_depth() {
1673        let parser = TqlParser::with_max_depth(100);
1674        assert_eq!(parser.max_depth, 100);
1675    }
1676
1677    #[test]
1678    fn test_hyphenated_field_name_eq() {
1679        let parser = TqlParser::new();
1680        let ast = parser.parse("event-code eq 5").unwrap();
1681        match ast {
1682            AstNode::Comparison(comp) => {
1683                assert_eq!(comp.field, "event-code");
1684                assert_eq!(comp.operator, "eq");
1685            }
1686            other => panic!("Expected Comparison, got {:?}", other),
1687        }
1688    }
1689
1690    #[test]
1691    fn test_hyphenated_field_name_contains() {
1692        let parser = TqlParser::new();
1693        let ast = parser.parse("user-agent contains 'Mozilla'").unwrap();
1694        match ast {
1695            AstNode::Comparison(comp) => {
1696                assert_eq!(comp.field, "user-agent");
1697                assert_eq!(comp.operator, "contains");
1698            }
1699            other => panic!("Expected Comparison, got {:?}", other),
1700        }
1701    }
1702
1703    #[test]
1704    fn test_hyphenated_nested_field_name() {
1705        let parser = TqlParser::new();
1706        let ast = parser.parse("http.x-forwarded-for eq '10.0.0.1'").unwrap();
1707        match ast {
1708            AstNode::Comparison(comp) => {
1709                assert_eq!(comp.field, "http.x-forwarded-for");
1710            }
1711            other => panic!("Expected Comparison, got {:?}", other),
1712        }
1713    }
1714}