Skip to main content

ddquery_core/
parser.rs

1//! Hand-written recursive-descent parser with a small Pratt loop for
2//! arithmetic precedence.
3//!
4//! The pipeline is `dispatch → recursive-descent → typed AST`. There is no
5//! parser-generator dependency: the grammar is small and stable, and a
6//! generator would add a build-time codegen step. Parsing is a pure function
7//! with no I/O. Recursion depth is bounded ([`MAX_DEPTH`]) so pathologically
8//! nested input is rejected before the stack grows.
9
10use crate::{
11    ast::{
12        ArithOp, BoolOp, ChangeKind, CheckQuery, CmpOp, CompositeExpr, Condition, Filter, FuncKind,
13        FuncParam, MetricExpr, MetricQuery, Modifier, MonitorQuery, MonitorRef, ParamValue, Scalar,
14        SearchQuery, SearchSource, Series, SloQuery, SpaceAgg, TimeAgg, Window,
15    },
16    cursor::Cursor,
17    error::ParseError,
18};
19
20/// Maximum expression/recursion depth. Anything deeper is rejected as
21/// pathological input rather than risking stack growth.
22const MAX_DEPTH: usize = 128;
23
24/// Parse a Datadog monitor query into a typed AST.
25///
26/// # Errors
27///
28/// Returns a [`ParseError`] (byte offset + reason) when the query does not
29/// match any known dialect. For a non-failing entry point that degrades to
30/// [`MonitorQuery::Unparsed`], use [`crate::parse_or_unparsed`].
31pub fn parse(query: &str) -> Result<MonitorQuery, ParseError> {
32    if query.trim().is_empty() {
33        return Err(ParseError::new(0, "empty query"));
34    }
35    let mut parser = Parser::new(query);
36    let parsed = parser.parse_query()?;
37    parser.cursor.skip_ws();
38    if !parser.cursor.is_done() {
39        return Err(parser.cursor.error("unexpected trailing input"));
40    }
41    Ok(parsed)
42}
43
44/// A char predicate for identifier and metric-name-segment characters
45/// (`[A-Za-z0-9_]`).
46fn is_ident(c: char) -> bool {
47    c.is_ascii_alphanumeric() || c == '_'
48}
49
50struct Parser<'a> {
51    cursor: Cursor<'a>,
52}
53
54impl<'a> Parser<'a> {
55    fn new(input: &'a str) -> Self {
56        Self {
57            cursor: Cursor::new(input),
58        }
59    }
60
61    /// Dispatch to a dialect parser based on the leading token.
62    fn parse_query(&mut self) -> Result<MonitorQuery, ParseError> {
63        self.cursor.skip_ws();
64        match self.cursor.peek() {
65            Some('"') => self.parse_check().map(MonitorQuery::ServiceCheck),
66            Some(c) if c.is_ascii_digit() || c == '!' || c == '(' => {
67                self.parse_composite(0).map(MonitorQuery::Composite)
68            }
69            Some(_) => {
70                let head = self.peek_ident();
71                match head.as_str() {
72                    "error_budget" => self.parse_slo().map(MonitorQuery::Slo),
73                    "logs" | "events" | "rum" | "error-tracking" => {
74                        self.parse_search().map(MonitorQuery::Search)
75                    }
76                    "avg" | "sum" | "min" | "max" | "count" | "percentile" | "change"
77                    | "pct_change" => self.parse_metric().map(MonitorQuery::Metric),
78                    "" => Err(self.cursor.error("unrecognized query")),
79                    other => Err(self
80                        .cursor
81                        .error(format!("unrecognized query head `{other}`"))),
82                }
83            }
84            None => Err(self.cursor.error("empty query")),
85        }
86    }
87
88    /// Peek the leading identifier (including `-` for `error-tracking`) without
89    /// consuming it.
90    fn peek_ident(&self) -> String {
91        let mut probe = self.cursor.clone();
92        probe.take_while(|c| is_ident(c) || c == '-').to_string()
93    }
94
95    fn check_depth(&self, depth: usize) -> Result<(), ParseError> {
96        if depth > MAX_DEPTH {
97            Err(self.cursor.error("maximum nesting depth exceeded"))
98        } else {
99            Ok(())
100        }
101    }
102
103    // ----- metric dialect ------------------------------------------------
104
105    fn parse_metric(&mut self) -> Result<MetricQuery, ParseError> {
106        let head = self.take_ident()?;
107        if let Some(kind) = ChangeKind::from_token(&head) {
108            return self.parse_change_metric(kind);
109        }
110        let time_aggregation = TimeAgg::from_token(&head).ok_or_else(|| {
111            self.cursor
112                .error(format!("invalid time aggregation `{head}`"))
113        })?;
114        self.cursor.expect("(")?;
115        let window = self.take_window()?;
116        self.cursor.expect(")")?;
117        self.cursor.expect(":")?;
118        let expr = self.parse_arith(0, 0)?;
119        let condition = self.parse_condition()?;
120        Ok(MetricQuery {
121            time_aggregation,
122            window,
123            expr,
124            condition,
125        })
126    }
127
128    /// `change(avg(last_5m),last_1h): expr op threshold`, or the shorthand
129    /// single-window form `pct_change(last_30m): expr op threshold` (no inner
130    /// aggregation; the window is both the evaluation window and the shift).
131    fn parse_change_metric(&mut self, kind: ChangeKind) -> Result<MetricQuery, ParseError> {
132        self.cursor.expect("(")?;
133        // Distinguish the two forms by looking ahead: the full form opens with a
134        // time aggregation immediately followed by `(` (e.g. `avg(`); the
135        // shorthand opens with a bare window token (e.g. `last_30m`).
136        let mut probe = self.cursor.clone();
137        let head = probe.take_while(is_ident);
138        let is_full_form = TimeAgg::from_token(head).is_some() && probe.peek() == Some('(');
139
140        let (inner_agg, window, shift) = if is_full_form {
141            let inner = self.take_ident()?;
142            let inner_agg = TimeAgg::from_token(&inner).ok_or_else(|| {
143                self.cursor
144                    .error(format!("invalid time aggregation `{inner}`"))
145            })?;
146            self.cursor.expect("(")?;
147            let window = self.take_window()?;
148            self.cursor.expect(")")?;
149            self.cursor.expect(",")?;
150            let shift = self.take_window()?;
151            (inner_agg, window, shift)
152        } else {
153            // Shorthand: the single window serves as both evaluation window and
154            // comparison shift; there is no inner aggregation, so default it.
155            let window = self.take_window()?;
156            (TimeAgg::Avg, window.clone(), window)
157        };
158        self.cursor.expect(")")?;
159        self.cursor.expect(":")?;
160        let inner_expr = self.parse_arith(0, 0)?;
161        let condition = self.parse_condition()?;
162        let expr = MetricExpr::Change {
163            kind,
164            inner_agg,
165            shift,
166            arg: Box::new(inner_expr),
167        };
168        Ok(MetricQuery {
169            time_aggregation: inner_agg,
170            window,
171            expr,
172            condition,
173        })
174    }
175
176    /// Pratt loop: `*`/`/` bind tighter than `+`/`-`.
177    fn parse_arith(&mut self, min_bp: u8, depth: usize) -> Result<MetricExpr, ParseError> {
178        self.check_depth(depth)?;
179        let mut lhs = self.parse_term(depth)?;
180        while let Some(op) = self.peek_arith_op() {
181            let (lbp, rbp) = binding_power(op);
182            if lbp < min_bp {
183                break;
184            }
185            self.cursor.eat(op.as_token());
186            let rhs = self.parse_arith(rbp, depth + 1)?;
187            lhs = MetricExpr::Arith {
188                op,
189                lhs: Box::new(lhs),
190                rhs: Box::new(rhs),
191            };
192        }
193        Ok(lhs)
194    }
195
196    fn peek_arith_op(&self) -> Option<ArithOp> {
197        match self.cursor.peek()? {
198            '+' => Some(ArithOp::Add),
199            '-' => Some(ArithOp::Sub),
200            '*' => Some(ArithOp::Mul),
201            '/' => Some(ArithOp::Div),
202            _ => None,
203        }
204    }
205
206    fn parse_term(&mut self, depth: usize) -> Result<MetricExpr, ParseError> {
207        self.check_depth(depth)?;
208        let c = self
209            .cursor
210            .peek()
211            .ok_or_else(|| self.cursor.error("unexpected end of expression"))?;
212        if c == '(' {
213            self.cursor.expect("(")?;
214            let inner = self.parse_arith(0, depth + 1)?;
215            self.cursor.expect(")")?;
216            return Ok(inner);
217        }
218        if c.is_ascii_digit() || c == '+' || c == '-' || c == '.' {
219            return Ok(MetricExpr::Scalar(Scalar::new(self.cursor.take_number()?)));
220        }
221        // A percentile space aggregation (`p95:`, `p99.9:`) — handled before
222        // `take_ident`, which would otherwise split `p99.9` at the `.`.
223        if let Some(rank) = self.try_take_percentile_prefix() {
224            return Ok(MetricExpr::Series(
225                self.parse_series(SpaceAgg::Percentile(rank))?,
226            ));
227        }
228        let id = self.take_ident()?;
229        if let Some(func) = FuncKind::from_token(&id)
230            && self.cursor.peek() == Some('(')
231        {
232            return self.parse_function(func, depth);
233        }
234        if self.cursor.peek() == Some(':') {
235            let space_aggregation = SpaceAgg::from_token(&id).ok_or_else(|| {
236                self.cursor
237                    .error(format!("invalid space aggregation `{id}`"))
238            })?;
239            self.cursor.expect(":")?;
240            return Ok(MetricExpr::Series(self.parse_series(space_aggregation)?));
241        }
242        if self.cursor.peek() == Some('(') {
243            // `max(a, b)` / `min(…)` / `sum(…)` as a *series combiner* across
244            // multiple sub-expressions — distinct from a postfix transform like
245            // `rollup(60)`. Datadog uses these to combine series.
246            if SpaceAgg::from_token(&id).is_some() {
247                return self.parse_combiner(id, depth);
248            }
249            return self.parse_transform(id, depth);
250        }
251        // A bare metric name with no `space_agg:` prefix, e.g.
252        // `aws.rds.cpuutilization{…}`. Datadog makes the space aggregation
253        // optional and defaults it to `avg`. The metric continues at a `.`
254        // (dotted name) or `{` (filter); anything else is a real error.
255        if matches!(self.cursor.peek(), Some('.' | '{')) {
256            let metric = self.continue_metric_name(id)?;
257            return Ok(MetricExpr::Series(
258                self.parse_series_from_metric(SpaceAgg::Avg, metric)?,
259            ));
260        }
261        Err(self
262            .cursor
263            .error(format!("expected `:` or `(` after `{id}`")))
264    }
265
266    /// `max(a, b, …)` — combine several sub-expressions under a spatial
267    /// aggregator. The opening identifier has been consumed; the `(` has not.
268    fn parse_combiner(&mut self, name: String, depth: usize) -> Result<MetricExpr, ParseError> {
269        self.cursor.expect("(")?;
270        let mut args = vec![self.parse_arith(0, depth + 1)?];
271        while self.cursor.eat(",") {
272            args.push(self.parse_arith(0, depth + 1)?);
273        }
274        self.cursor.expect(")")?;
275        Ok(MetricExpr::Combine { name, args })
276    }
277
278    fn parse_function(&mut self, name: FuncKind, depth: usize) -> Result<MetricExpr, ParseError> {
279        self.cursor.expect("(")?;
280        let arg = self.parse_arith(0, depth + 1)?;
281        let mut params = Vec::new();
282        while self.cursor.eat(",") {
283            params.push(self.parse_func_param()?);
284        }
285        self.cursor.expect(")")?;
286        Ok(MetricExpr::Function {
287            name,
288            arg: Box::new(arg),
289            params,
290        })
291    }
292
293    fn parse_func_param(&mut self) -> Result<FuncParam, ParseError> {
294        match self.cursor.peek() {
295            Some('\'' | '"') => Ok(FuncParam::positional(ParamValue::Str(
296                self.cursor.take_quoted()?,
297            ))),
298            Some(c) if c.is_ascii_digit() || c == '+' || c == '-' || c == '.' => Ok(
299                FuncParam::positional(ParamValue::Number(self.cursor.take_number()?)),
300            ),
301            Some(_) => {
302                let key = self.take_ident()?;
303                if self.cursor.eat("=") {
304                    Ok(FuncParam::keyword(key, self.parse_param_value()?))
305                } else {
306                    Ok(FuncParam::positional(bareword_value(&key)))
307                }
308            }
309            None => Err(self.cursor.error("expected a function parameter")),
310        }
311    }
312
313    fn parse_param_value(&mut self) -> Result<ParamValue, ParseError> {
314        match self.cursor.peek() {
315            Some('\'' | '"') => Ok(ParamValue::Str(self.cursor.take_quoted()?)),
316            Some(c) if c.is_ascii_digit() || c == '+' || c == '-' || c == '.' => {
317                Ok(ParamValue::Number(self.cursor.take_number()?))
318            }
319            Some(_) => Ok(bareword_value(&self.take_ident()?)),
320            None => Err(self.cursor.error("expected a parameter value")),
321        }
322    }
323
324    fn parse_transform(&mut self, name: String, depth: usize) -> Result<MetricExpr, ParseError> {
325        self.cursor.expect("(")?;
326        let arg = self.parse_arith(0, depth + 1)?;
327        let mut args = Vec::new();
328        while self.cursor.eat(",") {
329            // Transform args are mostly numeric (e.g. `rollup(60)`), but some
330            // are strings — e.g. `moving_rollup(expr, 60, 'avg', 'lookback')`.
331            args.push(self.parse_param_value()?);
332        }
333        self.cursor.expect(")")?;
334        Ok(MetricExpr::Transform {
335            name,
336            arg: Box::new(arg),
337            args,
338        })
339    }
340
341    fn parse_series(&mut self, space_aggregation: SpaceAgg) -> Result<Series, ParseError> {
342        let metric = self.take_metric_name()?;
343        self.parse_series_from_metric(space_aggregation, metric)
344    }
345
346    /// Parse the rest of a series (`{filter} by {tags}.modifiers`) given an
347    /// already-consumed space aggregation and metric name. Used both after a
348    /// `space_agg:` prefix and for the bare-metric (default-`avg`) form.
349    fn parse_series_from_metric(
350        &mut self,
351        space_aggregation: SpaceAgg,
352        metric: String,
353    ) -> Result<Series, ParseError> {
354        let filter = if self.cursor.peek() == Some('{') {
355            self.cursor.expect("{")?;
356            let filters = self.parse_filter_list();
357            self.cursor.expect("}")?;
358            filters
359        } else {
360            Vec::new()
361        };
362        let group_by = self.parse_group_by()?;
363        let modifiers = self.parse_modifiers()?;
364        Ok(Series {
365            space_aggregation,
366            metric,
367            filter,
368            group_by,
369            modifiers,
370        })
371    }
372
373    fn parse_filter_list(&mut self) -> Vec<Filter> {
374        let mut filters = Vec::new();
375        loop {
376            self.skip_filter_seps();
377            if matches!(self.cursor.peek(), Some('}') | None) {
378                break;
379            }
380            let token = self
381                .cursor
382                .take_while(|c| c != ',' && c != '}' && !c.is_whitespace());
383            if token.is_empty() {
384                break;
385            }
386            filters.push(classify_filter(token));
387        }
388        filters
389    }
390
391    /// Commas and runs of whitespace are equivalent filter separators.
392    fn skip_filter_seps(&mut self) {
393        loop {
394            self.cursor.skip_ws();
395            if !self.cursor.eat(",") {
396                break;
397            }
398        }
399    }
400
401    fn parse_group_by(&mut self) -> Result<Vec<String>, ParseError> {
402        self.cursor.skip_ws();
403        let rest = self.cursor.rest();
404        let is_by = rest
405            .strip_prefix("by")
406            .is_some_and(|after| after.starts_with(|c: char| c.is_whitespace() || c == '{'));
407        if !is_by {
408            return Ok(Vec::new());
409        }
410        self.cursor.eat("by");
411        self.cursor.expect("{")?;
412        let mut tags = Vec::new();
413        loop {
414            self.skip_filter_seps();
415            if self.cursor.peek() == Some('}') || self.cursor.peek().is_none() {
416                break;
417            }
418            let tag = self
419                .cursor
420                .take_while(|c| c != ',' && c != '}' && !c.is_whitespace());
421            if tag.is_empty() {
422                break;
423            }
424            tags.push(tag.to_string());
425        }
426        self.cursor.expect("}")?;
427        Ok(tags)
428    }
429
430    fn parse_modifiers(&mut self) -> Result<Vec<Modifier>, ParseError> {
431        let mut modifiers = Vec::new();
432        loop {
433            self.cursor.skip_ws();
434            // A `.` immediately following the series begins a modifier. Avoid
435            // skipping whitespace so arithmetic like `a . b` is never read as
436            // a modifier (which Datadog never emits anyway).
437            if !self.cursor.rest().starts_with('.') {
438                break;
439            }
440            self.cursor.expect(".")?;
441            let name = self.take_ident()?;
442            self.cursor.expect("(")?;
443            let args = self.parse_string_args()?;
444            modifiers.push(Modifier { name, args });
445        }
446        Ok(modifiers)
447    }
448
449    // ----- search dialect ------------------------------------------------
450
451    fn parse_search(&mut self) -> Result<SearchQuery, ParseError> {
452        let source = self.take_source()?;
453        self.cursor.expect("(")?;
454        let raw_search = self.cursor.take_quoted()?;
455        self.cursor.expect(")")?;
456
457        let mut index = None;
458        let mut rollup_method = None;
459        let mut rollup_arg = None;
460        let mut group_by = Vec::new();
461        let mut last = None;
462
463        while self.cursor.eat(".") {
464            let method = self.take_ident()?;
465            self.cursor.expect("(")?;
466            let mut args = self.parse_string_args()?;
467            match method.as_str() {
468                "index" => index = args.into_iter().next().filter(|s| !s.is_empty()),
469                "rollup" => {
470                    let mut it = args.into_iter();
471                    rollup_method = it.next();
472                    rollup_arg = it.next();
473                }
474                "by" => group_by = args,
475                "last" => last = args.drain(..).next(),
476                other => {
477                    return Err(self
478                        .cursor
479                        .error(format!("unknown search method `{other}`")));
480                }
481            }
482        }
483
484        let rollup_method = rollup_method
485            .ok_or_else(|| self.cursor.error("search query missing `.rollup(...)`"))?;
486        let last = last.ok_or_else(|| self.cursor.error("search query missing `.last(...)`"))?;
487        let condition = self.parse_condition()?;
488        Ok(SearchQuery {
489            source,
490            raw_search,
491            index,
492            rollup_method,
493            rollup_arg,
494            group_by,
495            last,
496            condition,
497        })
498    }
499
500    fn take_source(&mut self) -> Result<SearchSource, ParseError> {
501        let id = self.cursor.take_while(|c| is_ident(c) || c == '-');
502        match id {
503            "logs" => Ok(SearchSource::Logs),
504            "events" => Ok(SearchSource::Events),
505            "rum" => Ok(SearchSource::Rum),
506            "error-tracking" => Ok(SearchSource::ErrorTracking),
507            other => Err(self
508                .cursor
509                .error(format!("invalid search source `{other}`"))),
510        }
511    }
512
513    // ----- service-check dialect ----------------------------------------
514
515    fn parse_check(&mut self) -> Result<CheckQuery, ParseError> {
516        let check = self.cursor.take_quoted()?;
517        let mut over = Vec::new();
518        let mut by = Vec::new();
519        let mut last = None;
520        let mut saw_count = false;
521
522        while self.cursor.eat(".") {
523            let method = self.take_ident()?;
524            self.cursor.expect("(")?;
525            match method.as_str() {
526                "over" => over = self.parse_string_args()?,
527                "by" => by = self.parse_string_args()?,
528                "last" => {
529                    let n = self.cursor.take_number()?;
530                    self.cursor.expect(")")?;
531                    last = Some(checked_u32(n).ok_or_else(|| {
532                        self.cursor
533                            .error("`.last(n)` must be a non-negative integer")
534                    })?);
535                }
536                "count_by_status" => {
537                    self.parse_string_args()?;
538                    saw_count = true;
539                }
540                other => {
541                    return Err(self.cursor.error(format!("unknown check method `{other}`")));
542                }
543            }
544        }
545
546        let last = last.ok_or_else(|| self.cursor.error("check query missing `.last(n)`"))?;
547        if !saw_count {
548            return Err(self
549                .cursor
550                .error("check query missing `.count_by_status()`"));
551        }
552        Ok(CheckQuery {
553            check,
554            over,
555            by,
556            last,
557        })
558    }
559
560    // ----- SLO dialect ---------------------------------------------------
561
562    fn parse_slo(&mut self) -> Result<SloQuery, ParseError> {
563        self.cursor.expect("error_budget")?;
564        self.cursor.expect("(")?;
565        let id = self.cursor.take_quoted()?;
566        self.cursor.expect(")")?;
567        self.cursor.expect(".")?;
568        let method = self.take_ident()?;
569        if method != "over" {
570            return Err(self
571                .cursor
572                .error(format!("expected `.over(...)`, found `.{method}`")));
573        }
574        self.cursor.expect("(")?;
575        let over = self.cursor.take_quoted()?;
576        self.cursor.expect(")")?;
577        let condition = self.parse_condition()?;
578        Ok(SloQuery {
579            id,
580            over,
581            condition,
582        })
583    }
584
585    // ----- composite dialect --------------------------------------------
586
587    fn parse_composite(&mut self, depth: usize) -> Result<CompositeExpr, ParseError> {
588        self.parse_composite_or(depth)
589    }
590
591    fn parse_composite_or(&mut self, depth: usize) -> Result<CompositeExpr, ParseError> {
592        self.check_depth(depth)?;
593        let mut lhs = self.parse_composite_and(depth)?;
594        while self.cursor.eat("||") {
595            let rhs = self.parse_composite_and(depth + 1)?;
596            lhs = CompositeExpr::Binary {
597                op: BoolOp::Or,
598                lhs: Box::new(lhs),
599                rhs: Box::new(rhs),
600            };
601        }
602        Ok(lhs)
603    }
604
605    fn parse_composite_and(&mut self, depth: usize) -> Result<CompositeExpr, ParseError> {
606        self.check_depth(depth)?;
607        let mut lhs = self.parse_composite_unary(depth)?;
608        while self.cursor.eat("&&") {
609            let rhs = self.parse_composite_unary(depth + 1)?;
610            lhs = CompositeExpr::Binary {
611                op: BoolOp::And,
612                lhs: Box::new(lhs),
613                rhs: Box::new(rhs),
614            };
615        }
616        Ok(lhs)
617    }
618
619    fn parse_composite_unary(&mut self, depth: usize) -> Result<CompositeExpr, ParseError> {
620        self.check_depth(depth)?;
621        if self.cursor.eat("!") {
622            return Ok(CompositeExpr::Not(Box::new(
623                self.parse_composite_unary(depth + 1)?,
624            )));
625        }
626        if self.cursor.eat("(") {
627            let inner = self.parse_composite_or(depth + 1)?;
628            self.cursor.expect(")")?;
629            return Ok(inner);
630        }
631        let id = self.cursor.take_while(|c| c.is_ascii_digit());
632        if id.is_empty() {
633            return Err(self.cursor.error("expected a monitor id"));
634        }
635        Ok(CompositeExpr::Ref(MonitorRef { id: id.to_string() }))
636    }
637
638    // ----- shared helpers ------------------------------------------------
639
640    fn parse_condition(&mut self) -> Result<Condition, ParseError> {
641        let operator = self.parse_cmp_op()?;
642        let critical = Scalar::new(self.cursor.take_number()?);
643        Ok(Condition {
644            operator,
645            critical,
646            critical_recovery: None,
647            warning: None,
648            warning_recovery: None,
649        })
650    }
651
652    fn parse_cmp_op(&mut self) -> Result<CmpOp, ParseError> {
653        for op in [
654            CmpOp::Ge,
655            CmpOp::Le,
656            CmpOp::Eq,
657            CmpOp::Ne,
658            CmpOp::Gt,
659            CmpOp::Lt,
660        ] {
661            if self.cursor.eat(op.as_token()) {
662                return Ok(op);
663            }
664        }
665        Err(self.cursor.error("expected a comparison operator"))
666    }
667
668    /// Parse comma-separated quoted-or-bareword arguments through the closing
669    /// `)`. Assumes the opening `(` has already been consumed.
670    fn parse_string_args(&mut self) -> Result<Vec<String>, ParseError> {
671        let mut out = Vec::new();
672        if self.cursor.eat(")") {
673            return Ok(out);
674        }
675        loop {
676            self.cursor.skip_ws();
677            let value = match self.cursor.peek() {
678                Some('\'' | '"') => self.cursor.take_quoted()?,
679                _ => self
680                    .cursor
681                    .take_while(|c| c != ',' && c != ')')
682                    .trim()
683                    .to_string(),
684            };
685            out.push(value);
686            if self.cursor.eat(",") {
687                continue;
688            }
689            self.cursor.expect(")")?;
690            break;
691        }
692        Ok(out)
693    }
694
695    /// If the cursor is at a percentile space-aggregation prefix `p<rank>:`
696    /// (e.g. `p95:`, `p99.9:`), consume `p<rank>` (not the `:`) and return the
697    /// rank text. Otherwise consume nothing and return `None`.
698    ///
699    /// Needed because `take_ident` stops at the `.` in `p99.9`, so a fractional
700    /// percentile would not be recognized via the normal identifier path.
701    fn try_take_percentile_prefix(&mut self) -> Option<String> {
702        let rest = self.cursor.rest().trim_start();
703        // Must look like `p<digit…>` with an optional `.<digit…>`, terminated by
704        // `:` — the marker that distinguishes it from a metric named `p…`.
705        let body = rest.strip_prefix('p')?;
706        let bytes = body.as_bytes();
707        let mut len = 0;
708        while len < bytes.len() && bytes[len].is_ascii_digit() {
709            len += 1;
710        }
711        if len == 0 {
712            return None;
713        }
714        if bytes.get(len) == Some(&b'.') {
715            let mut frac = len + 1;
716            while frac < bytes.len() && bytes[frac].is_ascii_digit() {
717                frac += 1;
718            }
719            if frac > len + 1 {
720                len = frac;
721            }
722        }
723        // Only a percentile if a `:` follows; else `p5` could be a metric name.
724        if !body[len..].trim_start().starts_with(':') {
725            return None;
726        }
727        let rank = body[..len].to_string();
728        // Commit: advance the real cursor past `p<rank>` and the `:`.
729        self.cursor.eat("p");
730        self.cursor.take_while(|c| c.is_ascii_digit() || c == '.');
731        self.cursor.eat(":");
732        Some(rank)
733    }
734
735    fn take_ident(&mut self) -> Result<String, ParseError> {
736        let id = self.cursor.take_while(is_ident);
737        if id.is_empty() {
738            Err(self.cursor.error("expected an identifier"))
739        } else {
740            Ok(id.to_string())
741        }
742    }
743
744    fn take_window(&mut self) -> Result<Window, ParseError> {
745        let raw = self.cursor.take_while(is_ident);
746        Window::parse(raw).ok_or_else(|| self.cursor.error(format!("invalid window `{raw}`")))
747    }
748
749    /// Read a dotted metric name (`a.b.c`), stopping before a `.modifier(`.
750    fn take_metric_name(&mut self) -> Result<String, ParseError> {
751        let first = self.cursor.take_while(is_ident);
752        if first.is_empty() {
753            return Err(self.cursor.error("expected a metric name"));
754        }
755        self.continue_metric_name(first.to_string())
756    }
757
758    /// Continue a dotted metric name whose first segment was already consumed
759    /// (e.g. via `take_ident` for the bare, prefix-less form).
760    fn continue_metric_name(&mut self, first: String) -> Result<String, ParseError> {
761        let mut name = first;
762        loop {
763            if !self.cursor.rest().starts_with('.') {
764                break;
765            }
766            // Look ahead: a `.ident(` is a modifier, not a name segment.
767            let mut probe = self.cursor.clone();
768            probe.eat(".");
769            let seg = probe.take_while(is_ident);
770            if seg.is_empty() || probe.rest().starts_with('(') {
771                break;
772            }
773            self.cursor.eat(".");
774            let committed = self.cursor.take_while(is_ident);
775            name.push('.');
776            name.push_str(committed);
777        }
778        Ok(name)
779    }
780}
781
782/// Operator binding power: `*`/`/` bind tighter than `+`/`-`.
783fn binding_power(op: ArithOp) -> (u8, u8) {
784    match op {
785        ArithOp::Add | ArithOp::Sub => (1, 2),
786        ArithOp::Mul | ArithOp::Div => (3, 4),
787    }
788}
789
790/// Classify a raw filter token into a [`Filter`].
791fn classify_filter(token: &str) -> Filter {
792    if token == "*" {
793        return Filter::All;
794    }
795    let (negated, body) = match token.strip_prefix('!') {
796        Some(rest) => (true, rest),
797        None => (false, token),
798    };
799    match body.split_once(':') {
800        Some((key, value)) => Filter::Tag {
801            negated,
802            key: key.to_string(),
803            value: value.to_string(),
804        },
805        None => Filter::Glob(token.to_string()),
806    }
807}
808
809/// Interpret a bareword parameter value (`true`/`false` → bool, else string).
810fn bareword_value(word: &str) -> ParamValue {
811    match word {
812        "true" => ParamValue::Bool(true),
813        "false" => ParamValue::Bool(false),
814        other => ParamValue::Str(other.to_string()),
815    }
816}
817
818/// Convert a parsed `f64` to a `u32` if it is a non-negative whole number.
819fn checked_u32(value: f64) -> Option<u32> {
820    if value.is_finite() && value.fract() == 0.0 && (0.0..=f64::from(u32::MAX)).contains(&value) {
821        Some(value as u32)
822    } else {
823        None
824    }
825}