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;
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;
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("{message}")]
24        span: SourceSpan,
25        /// The detailed error message
26        message: String,
27        /// Optional suggestion for fixing the error
28        #[help]
29        suggestion: Option<Suggestion>,
30    },
31
32    #[error("This feature is not supported at the moment: {rule:?}")]
33    /// Rule for a unsupported feature
34    #[diagnostic(
35        code(mpl_lang::not_supported),
36        help("This feature may be added in a future version")
37    )]
38    NotSupported {
39        /// The source location of the unsupported feature
40        #[label("unsupported: {rule:?}")]
41        span: SourceSpan,
42        /// The rule that is not supported
43        rule: Rule,
44    },
45
46    /// Unexpected rule
47    #[error("Unexpected rule: {rule:?} expected one of {expected:?}")]
48    #[diagnostic(code(mpl_lang::unexpected_rule))]
49    Unexpected {
50        /// The source location of the unexpected rule
51        #[label("unexpected {rule:?}")]
52        span: SourceSpan,
53        /// The rule that was unexpected
54        rule: Rule,
55        /// Expected rules
56        expected: Vec<Rule>,
57    },
58
59    /// Unexpected Token
60    #[error("Found unexpected tokens: {rules:?}")]
61    #[diagnostic(code(mpl_lang::unexpected_tokens))]
62    UnexpectedTokens {
63        /// The source location of the unexpected tokens
64        #[label("unexpected tokens")]
65        span: SourceSpan,
66        /// The unexpected rules
67        rules: Vec<Rule>,
68    },
69
70    /// Unexpected EOF
71    #[error("Unexpected end of input")]
72    #[diagnostic(
73        code(mpl_lang::unexpected_eof),
74        help("The query appears to be incomplete")
75    )]
76    EOF {
77        /// The source location where more input was expected
78        #[label("expected more input here")]
79        span: SourceSpan,
80    },
81
82    /// Invalid Floating point number
83    #[error("Invalid float: {0}")]
84    #[diagnostic(code(mpl_lang::invalid_float))]
85    InvalidFloat(#[from] std::num::ParseFloatError),
86
87    /// Invalid Integer
88    #[error("Invalid integer: {0}")]
89    #[diagnostic(code(mpl_lang::invalid_integer))]
90    InvalidInteger(#[from] std::num::ParseIntError),
91
92    /// Invalid bool
93    #[error("Invalid bool: {0}")]
94    #[diagnostic(code(mpl_lang::invalid_bool))]
95    InvalidBool(#[from] std::str::ParseBoolError),
96
97    /// Invalid date
98    #[error("Invalid date: {0}")]
99    #[diagnostic(code(mpl_lang::invalid_date))]
100    InvalidDate(#[from] chrono::ParseError),
101
102    /// Invalid Regex
103    #[error("Invalid Regex: {0}")]
104    #[diagnostic(code(mpl_lang::invalid_regex))]
105    InvalidRegex(#[from] regex::Error),
106
107    /// Unsupported align function
108    #[error("Unsupported align function: {name}")]
109    #[diagnostic(
110        code(mpl_lang::unsupported_align_function),
111        help("Check the documentation for available align functions")
112    )]
113    UnsupportedAlignFunction {
114        /// The source location of the unsupported function
115        #[label("unknown function")]
116        span: SourceSpan,
117        /// The name of the unsupported function
118        name: String,
119    },
120
121    /// Unsupported group function
122    #[error("Unsupported group function: {name}")]
123    #[diagnostic(
124        code(mpl_lang::unsupported_group_function),
125        help("Check the documentation for available group functions")
126    )]
127    UnsupportedGroupFunction {
128        /// The source location of the unsupported function
129        #[label("unknown function")]
130        span: SourceSpan,
131        /// The name of the unsupported function
132        name: String,
133    },
134
135    /// Unsupported compute function
136    #[error("Unsupported compute function: {name}")]
137    #[diagnostic(
138        code(mpl_lang::unsupported_compute_function),
139        help("Check the documentation for available compute functions")
140    )]
141    UnsupportedComputeFunction {
142        /// The source location of the unsupported function
143        #[label("unknown function")]
144        span: SourceSpan,
145        /// The name of the unsupported function
146        name: String,
147    },
148
149    /// Unsupported bucketing function
150    #[error("Unsupported bucket function: {name}")]
151    #[diagnostic(
152        code(mpl_lang::unsupported_bucket_function),
153        help(
154            "Available functions: histogram, interpolate_delta_histogram, interpolate_cumulative_histogram"
155        )
156    )]
157    UnsupportedBucketFunction {
158        /// The source location of the unsupported function
159        #[label("unknown function")]
160        span: SourceSpan,
161        /// The name of the unsupported function
162        name: String,
163    },
164
165    /// Unsupported map evaluation
166    #[error("Unsupported map evaluation: {name}")]
167    #[diagnostic(
168        code(mpl_lang::unsupported_map_evaluation),
169        help("Check the documentation for available map operations")
170    )]
171    UnsupportedMapEvaluation {
172        /// The source location of the unsupported operation
173        #[label("unknown operation")]
174        span: SourceSpan,
175        /// The name of the unsupported operation
176        name: String,
177    },
178
179    /// Unsupported map function
180    #[error("Unsupported map function: {name}")]
181    #[diagnostic(
182        code(mpl_lang::unsupported_map_function),
183        help("Check the documentation for available map functions")
184    )]
185    UnsupportedMapFunction {
186        /// The source location of the unsupported function
187        #[label("unknown function")]
188        span: SourceSpan,
189        /// The name of the unsupported function
190        name: String,
191    },
192
193    /// Unsupported regexp comparison
194    #[error("Unsupported regexp comparison: {op}")]
195    #[diagnostic(
196        code(mpl_lang::unsupported_regexp_comparison),
197        help("Use '==' or '!=' for regex comparisons")
198    )]
199    UnsupportedRegexpComparison {
200        /// The source location of the unsupported operator
201        #[label("invalid operator")]
202        span: SourceSpan,
203        /// The unsupported operator
204        op: String,
205    },
206
207    /// Unsupported comparison against tag value
208    #[error("Unsupported tag comparison: {op}")]
209    #[diagnostic(
210        code(mpl_lang::unsupported_tag_comparison),
211        help("Supported operators: ==, !=, >, >=, <, <=")
212    )]
213    UnsupportedTagComparison {
214        /// The source location of the unsupported operator
215        #[label("invalid operator")]
216        span: SourceSpan,
217        /// The unsupported operator
218        op: String,
219    },
220
221    /// The feature is not implemented yet
222    #[error("Not implemented: {0}")]
223    #[diagnostic(
224        code(mpl_lang::not_implemented),
225        help("This feature is planned but not yet implemented")
226    )]
227    NotImplemented(&'static str),
228
229    /// Strumbra error
230    #[error("String construction error: {0}")]
231    #[diagnostic(code(mpl_lang::strumbra_error))]
232    StrumbraError(#[from] strumbra::Error),
233
234    /// Unreachable error
235    #[error("Unreachable error: {0}")]
236    #[diagnostic(
237        code(mpl_lang::unreachable),
238        help("This error should never be reached")
239    )]
240    Unreachable(&'static str),
241
242    /// Param is defined multiple times
243    #[error("The param ${param} is defined multiple times")]
244    #[diagnostic(
245        code(mpl_lang::param_defined_multiple_times),
246        help("This param has been defined more than once")
247    )]
248    ParamDefinedMultipleTimes {
249        /// The source location of the duplicate definition
250        #[label("duplicate definition")]
251        span: SourceSpan,
252        /// The param
253        param: String,
254    },
255
256    /// Param is not defined
257    #[error("The param ${param} is not defined")]
258    #[diagnostic(code(mpl_lang::undefined_param))]
259    UndefinedParam {
260        /// The source location of the undefine param
261        #[label("undefined param")]
262        span: SourceSpan,
263        /// The param
264        param: String,
265    },
266    /// Invalid tag type
267    #[error("The type {tpe} is not a valid type for tags")]
268    #[diagnostic(code(mpl_lang::invalid_tag_type))]
269    InvalidTagType {
270        /// The source location of the invalid type
271        #[label("invalid type")]
272        span: miette::SourceSpan,
273        /// The invalid type
274        tpe: String,
275    },
276}
277
278impl From<PestError<Rule>> for ParseError {
279    fn from(err: PestError<Rule>) -> Self {
280        let (start, mut len) = match err.location {
281            InputLocation::Pos(pos) => (pos, 0),
282            InputLocation::Span((start, end)) => (start, end - start),
283        };
284
285        let (message, suggestion) = match &err.variant {
286            ErrorVariant::ParsingError {
287                positives,
288                negatives,
289            } => {
290                let mut msg = String::new();
291                if !positives.is_empty() {
292                    msg.push_str("expected ");
293                    msg.push_str(&friendly_rules(positives));
294                }
295                if !negatives.is_empty() {
296                    if !msg.is_empty() {
297                        msg.push_str(", ");
298                    }
299                    msg.push_str("but found ");
300                    msg.push_str(&friendly_rules(negatives));
301                }
302
303                let line_pos = match &err.line_col {
304                    LineColLocation::Pos((_, col)) | LineColLocation::Span((_, col), _) => {
305                        col.saturating_sub(1)
306                    }
307                };
308                let suggestion = generate_suggestion(err.line(), line_pos, positives);
309
310                // If the span is a single position, try to expand it to cover the full token
311                if len == 0 {
312                    len = token_length(err.line(), line_pos);
313                }
314
315                (msg, suggestion)
316            }
317            ErrorVariant::CustomError { message } => (message.clone(), None),
318        };
319
320        ParseError::SyntaxError {
321            span: SourceSpan::new(start.into(), len),
322            message,
323            suggestion,
324        }
325    }
326}
327
328/// Convert a Pest `Pair` span to a miette `SourceSpan`
329pub(crate) fn pair_to_source_span(pair: &Pair<Rule>) -> SourceSpan {
330    let span = pair.as_span();
331    let start = span.start();
332    let len = span.end() - start;
333    SourceSpan::new(start.into(), len)
334}
335
336/// Convert a list of rules to a friendly name
337fn friendly_rules(rules: &[Rule]) -> String {
338    let names: Vec<_> = rules.iter().copied().map(friendly_rule).collect();
339
340    match names.len() {
341        0 => String::new(),
342        1 => names[0].clone(),
343        2 => format!("{} or {}", names[0], names[1]),
344        _ => {
345            let last = &names[names.len() - 1];
346            let rest = &names[..names.len() - 1];
347            format!("{}, or {last}", rest.join(", "))
348        }
349    }
350}
351
352/// Convert a rule to a friendly name
353fn friendly_rule(rule: Rule) -> String {
354    match rule {
355        // Control
356        Rule::EOI => "end of query".to_string(),
357        Rule::pipe_keyword => "\"|\" (pipe)".to_string(),
358
359        // Time
360        Rule::time_range => "time range (e.g.,  [1h..])".to_string(),
361        Rule::time_relative => "relative time (e.g., 5m, 1h, 7d)".to_string(),
362        Rule::time_timestamp => "timestamp".to_string(),
363        Rule::time_rfc_3339 => "RFC3339 timestamp".to_string(),
364        Rule::time_modifier => "time modifier".to_string(),
365
366        // Keywords
367        Rule::filter_keyword => "\"where\" keyword".to_string(),
368        Rule::r#as => "\"as\" keyword".to_string(),
369
370        // Ops
371        Rule::cmp => "comparison operator (==, !=, <, >, <=, >=)".to_string(),
372        Rule::cmp_re => "regex operator (==, !=)".to_string(),
373        Rule::regex => "regex pattern (e.g., /pattern/)".to_string(),
374
375        // Values
376        Rule::value => "value (string, number, or bool)".to_string(),
377        Rule::string => "string value".to_string(),
378        Rule::number => "number".to_string(),
379        Rule::bool => "bool (true or false)".to_string(),
380
381        // Idents
382        Rule::plain_ident => "identifier".to_string(),
383        Rule::escaped_ident => "escaped identifier".to_string(),
384        Rule::source => "source metric".to_string(),
385        Rule::metric_name => "metric name".to_string(),
386        Rule::metric_id => "metric identifier (e.g., dataset:metric)".to_string(),
387        Rule::dataset => "dataset name".to_string(),
388
389        // Aggrs
390        Rule::align => "\"align\" operation".to_string(),
391        Rule::group_by => "\"group by\" operation".to_string(),
392        Rule::bucket_by => "\"bucket by\" operation".to_string(),
393        Rule::map => "\"map\" operation".to_string(),
394        Rule::replace => "\"replace\" operation".to_string(),
395        Rule::join => "\"join\" operation".to_string(),
396
397        // Query types
398        Rule::simple_query => "simple query".to_string(),
399        Rule::compute_query => "compute query".to_string(),
400
401        // Directives
402        Rule::directive => "directive".to_string(),
403
404        // Params
405        Rule::param => "param".to_string(),
406        Rule::param_ident => "param identifier".to_string(),
407        Rule::param_type => "param type".to_string(),
408
409        // Funs
410        Rule::func => "function".to_string(),
411        Rule::compute_fn => "compute function".to_string(),
412        Rule::bucket_by_fn => {
413            "bucket function (histogram, interpolate_delta_histogram)".to_string()
414        }
415        Rule::bucket_by_with_conversion_fn => {
416            "bucket function (interpolate_cumulative_histogram)".to_string()
417        }
418        Rule::bucket_conversion => "conversion method (rate, increase)".to_string(),
419        Rule::bucket_specs => "bucket specifications".to_string(),
420        Rule::bucket_fn_call | Rule::bucket_fn_call_simple => "bucket function call".to_string(),
421        Rule::bucket_fn_call_with_conversion => "bucket function call with conversion".to_string(),
422
423        // Filters
424        Rule::filter_rule => "filter rule".to_string(),
425        Rule::filter_expr => "filter expression".to_string(),
426        Rule::sample_expr => "sample expression".to_string(),
427        Rule::value_filter => "value filter".to_string(),
428        Rule::regex_filter => "regex filter".to_string(),
429        Rule::kw_is => "\"is\" keyword".to_string(),
430        Rule::is_filter => "type filter (e.g., is string)".to_string(),
431        Rule::tag_type => "tag type (string, int, float, or bool)".to_string(),
432
433        // Tags
434        Rule::tags => "tags (comma-separated field names)".to_string(),
435        Rule::tag => "tag name".to_string(),
436
437        // Fallback for any other rules
438        _ => {
439            let name = format!("{rule:?}");
440            name.to_lowercase().replace('_', " ")
441        }
442    }
443}
444
445/// Suggestion for typos / corrections
446#[derive(Debug, Clone)]
447pub struct Suggestion(String);
448
449impl Suggestion {
450    /// The suggested text
451    #[must_use]
452    pub fn suggestion(&self) -> &str {
453        &self.0
454    }
455}
456
457impl fmt::Display for Suggestion {
458    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
459        write!(f, "Did you mean \"{}\"?", self.0)
460    }
461}
462
463/// Generate a suggestion for a typo based on the expected rules
464fn generate_suggestion(
465    line: &str,
466    error_pos: usize,
467    expected_rules: &[Rule],
468) -> Option<Suggestion> {
469    let actual_token = extract_token(line, error_pos)?;
470
471    if actual_token.len() < 2 {
472        return None;
473    }
474
475    let possible_keywords = rules_keywords(expected_rules);
476
477    let mut best_match: Option<(&str, f64)> = None;
478
479    for keyword in &possible_keywords {
480        let similarity = jaro(&actual_token.to_lowercase(), &keyword.to_lowercase());
481
482        if similarity > 0.8 {
483            if let Some((_, best_score)) = best_match {
484                if similarity > best_score {
485                    best_match = Some((keyword, similarity));
486                }
487            } else {
488                best_match = Some((keyword, similarity));
489            }
490        }
491    }
492
493    best_match.map(|(keyword, _)| Suggestion(keyword.to_string()))
494}
495
496/// Extract the token at the given position from the line
497fn extract_token(line: &str, pos: usize) -> Option<String> {
498    let chars: Vec<char> = line.chars().collect();
499
500    if pos >= chars.len() {
501        return None;
502    }
503
504    // Find the start of the token (go backwards)
505    let mut start = pos;
506    while start > 0 && chars[start - 1].is_alphanumeric() {
507        start -= 1;
508    }
509
510    // Find the end of the token (go forwards)
511    let mut end = pos;
512    while end < chars.len() && chars[end].is_alphanumeric() {
513        end += 1;
514    }
515
516    if start < end {
517        Some(chars[start..end].iter().collect())
518    } else {
519        None
520    }
521}
522
523/// Extract the length of the token at the given position
524fn token_length(line: &str, pos: usize) -> usize {
525    let chars: Vec<char> = line.chars().collect();
526
527    if pos >= chars.len() {
528        return 0;
529    }
530
531    if !chars[pos].is_alphanumeric() {
532        return 1;
533    }
534
535    let mut end = pos;
536    while end < chars.len() && chars[end].is_alphanumeric() {
537        end += 1;
538    }
539
540    end - pos
541}
542
543/// Get a list of common keywords that correspond to a list of rules
544fn rules_keywords(rules: &[Rule]) -> Vec<&'static str> {
545    let mut keywords = Vec::new();
546
547    for rule in rules {
548        match rule {
549            Rule::filter_keyword => {
550                keywords.push("where");
551                keywords.push("filter");
552            }
553            Rule::r#as => keywords.push("as"),
554            Rule::align => keywords.push("align"),
555            Rule::group_by => keywords.push("group"),
556            Rule::bucket_by => keywords.push("bucket"),
557            Rule::map => keywords.push("map"),
558            Rule::replace => keywords.push("replace"),
559            Rule::join => keywords.push("join"),
560            Rule::kw_is => keywords.push("is"),
561            Rule::tag_type => {
562                keywords.push("string");
563                keywords.push("int");
564                keywords.push("float");
565                keywords.push("bool");
566            }
567            _ => {}
568        }
569    }
570
571    keywords
572}