Skip to main content

mpl_lang/
errors.rs

1//! Error types and diagnostics for `MPL` parsing.
2#![allow(unused_assignments)] // We need this for the parse error
3
4use std::fmt::{self, Write as _};
5
6use miette::{Diagnostic, SourceSpan};
7use pest::{
8    error::{Error as PestError, ErrorVariant, InputLocation, LineColLocation},
9    iterators::Pair,
10};
11use strsim::jaro;
12
13use crate::{parser::Rule, query::ParamDeclaration};
14
15/// `MPL` parsing error
16#[derive(thiserror::Error, Debug, Diagnostic)]
17pub enum ParseError {
18    /// Syntax error with source location.
19    #[error("MPL syntax error: {message}")]
20    #[diagnostic(code(mpl_lang::syntax_error))]
21    SyntaxError {
22        /// The source location of the error with detailed message
23        #[label("{label}")]
24        span: SourceSpan,
25        /// Short label for the inline source annotation
26        label: String,
27        /// The detailed error message
28        message: String,
29        /// Optional suggestion for fixing the error
30        #[help]
31        suggestion: Option<Suggestion>,
32    },
33
34    #[error("This feature is not supported at the moment: {rule:?}")]
35    /// Rule for a unsupported feature
36    #[diagnostic(
37        code(mpl_lang::not_supported),
38        help("This feature may be added in a future version")
39    )]
40    NotSupported {
41        /// The source location of the unsupported feature
42        #[label("unsupported: {rule:?}")]
43        span: SourceSpan,
44        /// The rule that is not supported
45        rule: Rule,
46    },
47
48    /// Unexpected rule
49    #[error("Unexpected rule: {rule:?} expected one of {expected:?}")]
50    #[diagnostic(code(mpl_lang::unexpected_rule))]
51    Unexpected {
52        /// The source location of the unexpected rule
53        #[label("unexpected {rule:?}")]
54        span: SourceSpan,
55        /// The rule that was unexpected
56        rule: Rule,
57        /// Expected rules
58        expected: Vec<Rule>,
59    },
60
61    /// Unexpected Token
62    #[error("Found unexpected tokens: {rules:?}")]
63    #[diagnostic(code(mpl_lang::unexpected_tokens))]
64    UnexpectedTokens {
65        /// The source location of the unexpected tokens
66        #[label("unexpected tokens")]
67        span: SourceSpan,
68        /// The unexpected rules
69        rules: Vec<Rule>,
70    },
71
72    /// Unexpected EOF
73    #[error("Unexpected end of input")]
74    #[diagnostic(
75        code(mpl_lang::unexpected_eof),
76        help("The query appears to be incomplete")
77    )]
78    EOF {
79        /// The source location where more input was expected
80        #[label("expected more input here")]
81        span: SourceSpan,
82    },
83
84    /// Invalid Floating point number
85    #[error("Invalid float: {0}")]
86    #[diagnostic(code(mpl_lang::invalid_float))]
87    InvalidFloat(#[from] std::num::ParseFloatError),
88
89    /// Invalid Integer
90    #[error("Invalid integer: {0}")]
91    #[diagnostic(code(mpl_lang::invalid_integer))]
92    InvalidInteger(#[from] std::num::ParseIntError),
93
94    /// Invalid bool
95    #[error("Invalid bool: {0}")]
96    #[diagnostic(code(mpl_lang::invalid_bool))]
97    InvalidBool(#[from] std::str::ParseBoolError),
98
99    /// Invalid date
100    #[error("Invalid date: {0}")]
101    #[diagnostic(code(mpl_lang::invalid_date))]
102    InvalidDate(#[from] chrono::ParseError),
103
104    /// Invalid Regex
105    #[error("Invalid Regex: {0}")]
106    #[diagnostic(code(mpl_lang::invalid_regex))]
107    InvalidRegex(#[from] regex::Error),
108
109    /// Unsupported align function
110    #[error("Unsupported align function: {name}")]
111    #[diagnostic(
112        code(mpl_lang::unsupported_align_function),
113        help("Check the documentation for available align functions")
114    )]
115    UnsupportedAlignFunction {
116        /// The source location of the unsupported function
117        #[label("unknown function")]
118        span: SourceSpan,
119        /// The name of the unsupported function
120        name: String,
121    },
122
123    /// Unsupported group function
124    #[error("Unsupported group function: {name}")]
125    #[diagnostic(
126        code(mpl_lang::unsupported_group_function),
127        help("Check the documentation for available group functions")
128    )]
129    UnsupportedGroupFunction {
130        /// The source location of the unsupported function
131        #[label("unknown function")]
132        span: SourceSpan,
133        /// The name of the unsupported function
134        name: String,
135    },
136
137    /// Unsupported compute function
138    #[error("Unsupported compute function: {name}")]
139    #[diagnostic(
140        code(mpl_lang::unsupported_compute_function),
141        help("Check the documentation for available compute functions")
142    )]
143    UnsupportedComputeFunction {
144        /// The source location of the unsupported function
145        #[label("unknown function")]
146        span: SourceSpan,
147        /// The name of the unsupported function
148        name: String,
149    },
150
151    /// Unsupported bucketing function
152    #[error("Unsupported bucket function: {name}")]
153    #[diagnostic(
154        code(mpl_lang::unsupported_bucket_function),
155        help(
156            "Available functions: histogram, interpolate_delta_histogram, interpolate_cumulative_histogram"
157        )
158    )]
159    UnsupportedBucketFunction {
160        /// The source location of the unsupported function
161        #[label("unknown function")]
162        span: SourceSpan,
163        /// The name of the unsupported function
164        name: String,
165    },
166
167    /// Unsupported map evaluation
168    #[error("Unsupported map evaluation: {name}")]
169    #[diagnostic(
170        code(mpl_lang::unsupported_map_evaluation),
171        help("Check the documentation for available map operations")
172    )]
173    UnsupportedMapEvaluation {
174        /// The source location of the unsupported operation
175        #[label("unknown operation")]
176        span: SourceSpan,
177        /// The name of the unsupported operation
178        name: String,
179    },
180
181    /// Unsupported map function
182    #[error("Unsupported map function: {name}")]
183    #[diagnostic(
184        code(mpl_lang::unsupported_map_function),
185        help("Check the documentation for available map functions")
186    )]
187    UnsupportedMapFunction {
188        /// The source location of the unsupported function
189        #[label("unknown function")]
190        span: SourceSpan,
191        /// The name of the unsupported function
192        name: String,
193    },
194
195    /// Unsupported regexp comparison
196    #[error("Unsupported regexp comparison: {op}")]
197    #[diagnostic(
198        code(mpl_lang::unsupported_regexp_comparison),
199        help("Use '==' or '!=' for regex comparisons")
200    )]
201    UnsupportedRegexpComparison {
202        /// The source location of the unsupported operator
203        #[label("invalid operator")]
204        span: SourceSpan,
205        /// The unsupported operator
206        op: String,
207    },
208
209    /// Unsupported comparison against tag value
210    #[error("Unsupported tag comparison: {op}")]
211    #[diagnostic(
212        code(mpl_lang::unsupported_tag_comparison),
213        help("Supported operators: ==, !=, >, >=, <, <=, in")
214    )]
215    UnsupportedTagComparison {
216        /// The source location of the unsupported operator
217        #[label("invalid operator")]
218        span: SourceSpan,
219        /// The unsupported operator
220        op: String,
221    },
222
223    /// `in` used with a non-array right-hand side
224    #[error("`in` requires an array on the right-hand side")]
225    #[diagnostic(
226        code(mpl_lang::in_requires_array),
227        help("Use an array literal (e.g. `in [200, 201]`) or an array-typed param")
228    )]
229    InRequiresArray {
230        /// The source location of the offending right-hand side
231        #[label("expected an array here")]
232        span: SourceSpan,
233    },
234
235    /// The feature is not implemented yet
236    #[error("Not implemented: {0}")]
237    #[diagnostic(
238        code(mpl_lang::not_implemented),
239        help("This feature is planned but not yet implemented")
240    )]
241    NotImplemented(&'static str),
242
243    /// Strumbra error
244    #[error("String construction error: {0}")]
245    #[diagnostic(code(mpl_lang::strumbra_error))]
246    StrumbraError(#[from] strumbra::Error),
247
248    /// Unreachable error
249    #[error("Unreachable error: {0}")]
250    #[diagnostic(
251        code(mpl_lang::unreachable),
252        help("This error should never be reached")
253    )]
254    Unreachable(&'static str),
255
256    /// Param is defined multiple times
257    #[error("The param ${param} is defined multiple times")]
258    #[diagnostic(
259        code(mpl_lang::param_defined_multiple_times),
260        help("This param has been defined more than once")
261    )]
262    ParamDefinedMultipleTimes {
263        /// The source location of the duplicate definition
264        #[label("duplicate definition")]
265        span: SourceSpan,
266        /// The param
267        param: String,
268    },
269
270    // commented out until this becomes an error, for now it's a warning
271    // /// Param is using the prefix reserved for system params
272    // #[error("The param ${param} is using a prefix reserved for system params")]
273    // #[diagnostic(
274    //     code(mpl_lang::param_reserved_prefix),
275    //     help("The prefix `__` is reserved for system parameters")
276    // )]
277    // ParamUsingSystemPrefix {
278    //     /// The source location of the param
279    //     #[label("invalid prefix")]
280    //     span: SourceSpan,
281    //     /// The param
282    //     param: String,
283    // },
284    /// The system param is not using the prefix
285    #[error("The system param ${param} is missing the system prefix")]
286    #[diagnostic(
287        code(mpl_lan::system_param_missing_prefix),
288        help("The system param is missing the `__` prefix")
289    )]
290    SystemParamMissingPrefix {
291        /// The param
292        param: String,
293    },
294
295    /// Param is not defined
296    #[error("The param ${param} is not defined")]
297    #[diagnostic(code(mpl_lang::undefined_param))]
298    UndefinedParam {
299        /// The source location of the undefine param
300        #[label("undefined param")]
301        span: SourceSpan,
302        /// The param
303        param: String,
304    },
305    /// Invalid tag type
306    #[error("The type {tpe} is not a valid type for tags")]
307    #[diagnostic(code(mpl_lang::invalid_tag_type))]
308    InvalidTagType {
309        /// The source location of the invalid type
310        #[label("invalid type")]
311        span: miette::SourceSpan,
312        /// The invalid type
313        tpe: String,
314    },
315    /// `ifdef()` was used on a parameter that wasn't declared optional
316    #[error("The parameter {} is not declared as optional", param.name)]
317    #[diagnostic(code(mpl_lang::ifdef_not_optional))]
318    IfdefNotOptional {
319        /// The source location of the param declaration
320        #[label("param declaration")]
321        span: miette::SourceSpan,
322        /// The param type
323        param: ParamDeclaration,
324    },
325}
326
327impl From<PestError<Rule>> for ParseError {
328    fn from(err: PestError<Rule>) -> Self {
329        let (start, mut len) = match err.location {
330            InputLocation::Pos(pos) => (pos, 0),
331            InputLocation::Span((start, end)) => (start, end - start),
332        };
333
334        let (label, message, suggestion) = match &err.variant {
335            ErrorVariant::ParsingError {
336                positives,
337                negatives,
338            } => {
339                let mut keywords = Vec::new();
340                let mut operations = Vec::new();
341                let mut other = Vec::new();
342
343                for rule in positives {
344                    let name = friendly_rule(*rule);
345                    if name.contains("keyword") {
346                        keywords.push(name);
347                    } else if name.contains("operation") {
348                        operations.push(name);
349                    } else {
350                        other.push(name);
351                    }
352                }
353
354                let mut label = String::new();
355                if keywords.is_empty() && operations.is_empty() && other.is_empty() {
356                    label.push_str("unexpected token");
357                } else {
358                    label.push_str("expected one of:\n");
359                    if !keywords.is_empty() {
360                        let kws: Vec<_> = keywords
361                            .iter()
362                            .map(|k| k.trim_end_matches(" keyword"))
363                            .collect();
364                        let _ = writeln!(label, "  keywords: {}", join_with_or(&kws));
365                    }
366                    if !operations.is_empty() {
367                        let ops: Vec<_> = operations
368                            .iter()
369                            .map(|o| {
370                                o.trim_start_matches("a ")
371                                    .trim_start_matches("an ")
372                                    .trim_end_matches(" operation")
373                            })
374                            .collect();
375                        let _ = writeln!(label, "  operations: {}", join_with_or(&ops));
376                    }
377                    if !other.is_empty() {
378                        for name in &other {
379                            let _ = writeln!(label, "  - {name}");
380                        }
381                    }
382                }
383
384                let mut msg = "unexpected token or operation".to_string();
385                if !negatives.is_empty() {
386                    if !msg.is_empty() {
387                        msg.push_str("  ");
388                    }
389                    msg.push_str("but found ");
390                    msg.push_str(&friendly_rules(negatives));
391                }
392
393                let line_pos = match &err.line_col {
394                    LineColLocation::Pos((_, col)) | LineColLocation::Span((_, col), _) => {
395                        col.saturating_sub(1)
396                    }
397                };
398                let suggestion = generate_suggestion(err.line(), line_pos, positives);
399
400                // If the span is a single position, try to expand it to cover the full token
401                if len == 0 {
402                    len = token_length(err.line(), line_pos);
403                }
404
405                let label = label.trim_end().to_string();
406                (label, msg, suggestion)
407            }
408            ErrorVariant::CustomError { message } => (message.clone(), message.clone(), None),
409        };
410
411        ParseError::SyntaxError {
412            span: SourceSpan::new(start.into(), len),
413            label,
414            message,
415            suggestion,
416        }
417    }
418}
419
420/// Join a list of items with commas and "or" before the last item
421fn join_with_or(items: &[&str]) -> String {
422    match items.len() {
423        0 => String::new(),
424        1 => items[0].to_string(),
425        2 => format!("{} or {}", items[0], items[1]),
426        _ => {
427            let last = items[items.len() - 1];
428            let rest = &items[..items.len() - 1];
429            format!("{}, or {last}", rest.join(", "))
430        }
431    }
432}
433
434/// Convert a Pest `Pair` span to a miette `SourceSpan`
435pub(crate) fn pair_to_source_span(pair: &Pair<Rule>) -> SourceSpan {
436    let span = pair.as_span();
437    let start = span.start();
438    let len = span.end() - start;
439    SourceSpan::new(start.into(), len)
440}
441
442/// Convert a list of rules to a friendly name
443fn friendly_rules(rules: &[Rule]) -> String {
444    let names: Vec<_> = rules.iter().copied().map(friendly_rule).collect();
445
446    match names.len() {
447        0 => String::new(),
448        1 => names[0].clone(),
449        2 => format!("{} or {}", names[0], names[1]),
450        _ => {
451            let last = &names[names.len() - 1];
452            let rest = &names[..names.len() - 1];
453            format!("{}, or {last}", rest.join(", "))
454        }
455    }
456}
457
458/// Convert a rule to a friendly name
459fn friendly_rule(rule: Rule) -> String {
460    match rule {
461        // Control
462        Rule::EOI => "end of query".to_string(),
463        Rule::pipe_keyword => "`|` (pipe)".to_string(),
464
465        // Time
466        Rule::time_range => "time range (e.g.,  [1h..])".to_string(),
467        Rule::time_relative => "relative time (e.g., 5m, 1h, 7d)".to_string(),
468        Rule::time_timestamp => "timestamp".to_string(),
469        Rule::time_rfc_3339 => "RFC3339 timestamp".to_string(),
470        Rule::time_modifier => "time modifier".to_string(),
471
472        // Keywords
473        Rule::filter_keyword | Rule::kw_filter => "`filter` keyword".to_string(),
474        Rule::kw_where => "`where` keyword".to_string(),
475        Rule::r#as => "`as` keyword".to_string(),
476
477        // Ops
478        Rule::cmp => "a comparison operator (==, !=, <, >, <=, >=)".to_string(),
479        Rule::cmp_re => "a regex operator (==, !=)".to_string(),
480        Rule::regex => "a regex pattern (e.g., #/pattern/)".to_string(),
481
482        // Values
483        Rule::r#const => "value (string, number, bool or array)".to_string(),
484        Rule::string => "string value".to_string(),
485        Rule::number => "number".to_string(),
486        Rule::bool => "bool (true or false)".to_string(),
487
488        // Idents
489        Rule::plain_ident => "identifier".to_string(),
490        Rule::escaped_ident => "escaped identifier".to_string(),
491        Rule::source => "source metric".to_string(),
492        Rule::metric_name => "metric name".to_string(),
493        Rule::metric_id => "metric identifier (e.g., dataset:metric)".to_string(),
494        Rule::dataset => "dataset name".to_string(),
495
496        // Aggrs
497        Rule::align => "an align operation".to_string(),
498        Rule::group_by => "a group by operation".to_string(),
499        Rule::bucket_by => "a bucket by operation".to_string(),
500        Rule::map => "a map operation".to_string(),
501        Rule::replace => "a replace operation".to_string(),
502        Rule::join => "a join operation".to_string(),
503        Rule::kw_align => "align keyword".to_string(),
504        Rule::kw_using => "using keyword".to_string(),
505        Rule::kw_over => "over keyword".to_string(),
506        Rule::kw_to => "to keyword".to_string(),
507
508        // Query types
509        Rule::simple_query => "simple query".to_string(),
510        Rule::compute_query => "compute query".to_string(),
511
512        // Directives
513        Rule::directive => "directive".to_string(),
514
515        // Params
516        Rule::param => "param".to_string(),
517        Rule::param_ident => "param identifier".to_string(),
518        Rule::param_type => {
519            "param type (Duration, Dataset, Regex, string, int, float, bool)".to_string()
520        }
521
522        // Funs
523        Rule::func => "function".to_string(),
524        Rule::compute_fn => "compute function".to_string(),
525        Rule::bucket_by_fn => {
526            "bucket function (histogram, interpolate_delta_histogram)".to_string()
527        }
528        Rule::bucket_by_with_conversion_fn => {
529            "bucket function (interpolate_cumulative_histogram)".to_string()
530        }
531        Rule::bucket_conversion => "conversion method (rate, increase)".to_string(),
532        Rule::bucket_specs => "bucket specifications".to_string(),
533        Rule::bucket_fn_call | Rule::bucket_fn_call_simple => "bucket function call".to_string(),
534        Rule::bucket_fn_call_with_conversion => "bucket function call with conversion".to_string(),
535
536        // Filters
537        Rule::filter_rule => "filter rule".to_string(),
538        Rule::filter_expr => "filter expression".to_string(),
539        Rule::sample_expr => "sample expression".to_string(),
540        Rule::value_filter => "value filter".to_string(),
541        Rule::regex_filter => "regex filter".to_string(),
542        Rule::kw_is => "`is` keyword".to_string(),
543        Rule::is_filter => "type filter (e.g., is string)".to_string(),
544        Rule::tag_type => "tag type (array, string, int, float, or bool)".to_string(),
545
546        // Tags
547        Rule::tags => "tags (comma-separated field names)".to_string(),
548        Rule::tag => "tag name".to_string(),
549
550        // Fallback for any other rules
551        _ => {
552            let name = format!("{rule:?}");
553            name.to_lowercase().replace('_', " ")
554        }
555    }
556}
557
558/// Suggestion for typos / corrections
559#[derive(Debug, Clone)]
560pub struct Suggestion(String);
561
562impl Suggestion {
563    /// The suggested text
564    #[must_use]
565    pub fn suggestion(&self) -> &str {
566        &self.0
567    }
568}
569
570impl fmt::Display for Suggestion {
571    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
572        write!(f, "Did you mean \"{}\"?", self.0)
573    }
574}
575
576/// Generate a suggestion for a typo based on the expected rules
577fn generate_suggestion(
578    line: &str,
579    error_pos: usize,
580    expected_rules: &[Rule],
581) -> Option<Suggestion> {
582    let actual_token = extract_token(line, error_pos)?;
583
584    if actual_token.len() < 2 {
585        return None;
586    }
587
588    let possible_keywords = rules_keywords(expected_rules);
589
590    let mut best_match: Option<(&str, f64)> = None;
591
592    for keyword in &possible_keywords {
593        let similarity = jaro(&actual_token.to_lowercase(), &keyword.to_lowercase());
594
595        if similarity > 0.8 {
596            if let Some((_, best_score)) = best_match {
597                if similarity > best_score {
598                    best_match = Some((keyword, similarity));
599                }
600            } else {
601                best_match = Some((keyword, similarity));
602            }
603        }
604    }
605
606    best_match.map(|(keyword, _)| Suggestion(keyword.to_string()))
607}
608
609/// Extract the token at the given position from the line
610fn extract_token(line: &str, pos: usize) -> Option<String> {
611    let chars: Vec<char> = line.chars().collect();
612
613    if pos >= chars.len() {
614        return None;
615    }
616
617    // Skip whitespace forward to find the next token
618    let mut pos = pos;
619    while pos < chars.len() && chars[pos].is_whitespace() {
620        pos += 1;
621    }
622
623    if pos >= chars.len() {
624        return None;
625    }
626
627    // Find the start of the token (go backwards)
628    let mut start = pos;
629    while start > 0 && chars[start - 1].is_alphanumeric() {
630        start -= 1;
631    }
632
633    // Find the end of the token (go forwards)
634    let mut end = pos;
635    while end < chars.len() && chars[end].is_alphanumeric() {
636        end += 1;
637    }
638
639    if start < end {
640        Some(chars[start..end].iter().collect())
641    } else {
642        None
643    }
644}
645
646/// Extract the length of the token at the given position
647fn token_length(line: &str, pos: usize) -> usize {
648    let chars: Vec<char> = line.chars().collect();
649
650    if pos >= chars.len() {
651        return 0;
652    }
653
654    if !chars[pos].is_alphanumeric() {
655        return 1;
656    }
657
658    let mut end = pos;
659    while end < chars.len() && chars[end].is_alphanumeric() {
660        end += 1;
661    }
662
663    end - pos
664}
665
666/// Get a list of common keywords that correspond to a list of rules
667fn rules_keywords(rules: &[Rule]) -> Vec<&'static str> {
668    let mut keywords = Vec::new();
669
670    for rule in rules {
671        match rule {
672            Rule::filter_keyword | Rule::kw_filter | Rule::kw_where => {
673                keywords.push("where");
674                keywords.push("filter");
675            }
676            Rule::r#as => keywords.push("as"),
677            Rule::align => keywords.push("align"),
678            Rule::group_by => keywords.push("group"),
679            Rule::bucket_by => keywords.push("bucket"),
680            Rule::map => keywords.push("map"),
681            Rule::replace => keywords.push("replace"),
682            Rule::join => keywords.push("join"),
683            Rule::kw_is => keywords.push("is"),
684            Rule::tag_type => {
685                keywords.push("array");
686                keywords.push("string");
687                keywords.push("int");
688                keywords.push("float");
689                keywords.push("bool");
690            }
691            Rule::kw_else => keywords.push("else"),
692            Rule::kw_to => keywords.push("to"),
693            Rule::kw_using => keywords.push("using"),
694            Rule::kw_over => keywords.push("over"),
695
696            _ => {}
697        }
698    }
699
700    keywords
701}