#![allow(unused_assignments)]
use std::fmt::{self, Write as _};
use miette::{Diagnostic, SourceSpan};
use pest::{
error::{Error as PestError, ErrorVariant, InputLocation, LineColLocation},
iterators::Pair,
};
use strsim::jaro;
use crate::{parser::Rule, query::ParamDeclaration};
#[derive(thiserror::Error, Debug, Diagnostic)]
pub enum ParseError {
#[error("MPL syntax error: {message}")]
#[diagnostic(code(mpl_lang::syntax_error))]
SyntaxError {
#[label("{label}")]
span: SourceSpan,
label: String,
message: String,
#[help]
suggestion: Option<Suggestion>,
},
#[error("This feature is not supported at the moment: {rule:?}")]
#[diagnostic(
code(mpl_lang::not_supported),
help("This feature may be added in a future version")
)]
NotSupported {
#[label("unsupported: {rule:?}")]
span: SourceSpan,
rule: Rule,
},
#[error("Unexpected rule: {rule:?} expected one of {expected:?}")]
#[diagnostic(code(mpl_lang::unexpected_rule))]
Unexpected {
#[label("unexpected {rule:?}")]
span: SourceSpan,
rule: Rule,
expected: Vec<Rule>,
},
#[error("Found unexpected tokens: {rules:?}")]
#[diagnostic(code(mpl_lang::unexpected_tokens))]
UnexpectedTokens {
#[label("unexpected tokens")]
span: SourceSpan,
rules: Vec<Rule>,
},
#[error("Unexpected end of input")]
#[diagnostic(
code(mpl_lang::unexpected_eof),
help("The query appears to be incomplete")
)]
EOF {
#[label("expected more input here")]
span: SourceSpan,
},
#[error("Invalid float: {0}")]
#[diagnostic(code(mpl_lang::invalid_float))]
InvalidFloat(#[from] std::num::ParseFloatError),
#[error("Invalid integer: {0}")]
#[diagnostic(code(mpl_lang::invalid_integer))]
InvalidInteger(#[from] std::num::ParseIntError),
#[error("Invalid bool: {0}")]
#[diagnostic(code(mpl_lang::invalid_bool))]
InvalidBool(#[from] std::str::ParseBoolError),
#[error("Invalid date: {0}")]
#[diagnostic(code(mpl_lang::invalid_date))]
InvalidDate(#[from] chrono::ParseError),
#[error("Invalid Regex: {0}")]
#[diagnostic(code(mpl_lang::invalid_regex))]
InvalidRegex(#[from] regex::Error),
#[error("Unsupported align function: {name}")]
#[diagnostic(
code(mpl_lang::unsupported_align_function),
help("Check the documentation for available align functions")
)]
UnsupportedAlignFunction {
#[label("unknown function")]
span: SourceSpan,
name: String,
},
#[error("Unsupported group function: {name}")]
#[diagnostic(
code(mpl_lang::unsupported_group_function),
help("Check the documentation for available group functions")
)]
UnsupportedGroupFunction {
#[label("unknown function")]
span: SourceSpan,
name: String,
},
#[error("Unsupported compute function: {name}")]
#[diagnostic(
code(mpl_lang::unsupported_compute_function),
help("Check the documentation for available compute functions")
)]
UnsupportedComputeFunction {
#[label("unknown function")]
span: SourceSpan,
name: String,
},
#[error("Unsupported bucket function: {name}")]
#[diagnostic(
code(mpl_lang::unsupported_bucket_function),
help(
"Available functions: histogram, interpolate_delta_histogram, interpolate_cumulative_histogram"
)
)]
UnsupportedBucketFunction {
#[label("unknown function")]
span: SourceSpan,
name: String,
},
#[error("Unsupported map evaluation: {name}")]
#[diagnostic(
code(mpl_lang::unsupported_map_evaluation),
help("Check the documentation for available map operations")
)]
UnsupportedMapEvaluation {
#[label("unknown operation")]
span: SourceSpan,
name: String,
},
#[error("Unsupported map function: {name}")]
#[diagnostic(
code(mpl_lang::unsupported_map_function),
help("Check the documentation for available map functions")
)]
UnsupportedMapFunction {
#[label("unknown function")]
span: SourceSpan,
name: String,
},
#[error("Unsupported regexp comparison: {op}")]
#[diagnostic(
code(mpl_lang::unsupported_regexp_comparison),
help("Use '==' or '!=' for regex comparisons")
)]
UnsupportedRegexpComparison {
#[label("invalid operator")]
span: SourceSpan,
op: String,
},
#[error("Unsupported tag comparison: {op}")]
#[diagnostic(
code(mpl_lang::unsupported_tag_comparison),
help("Supported operators: ==, !=, >, >=, <, <=")
)]
UnsupportedTagComparison {
#[label("invalid operator")]
span: SourceSpan,
op: String,
},
#[error("Not implemented: {0}")]
#[diagnostic(
code(mpl_lang::not_implemented),
help("This feature is planned but not yet implemented")
)]
NotImplemented(&'static str),
#[error("String construction error: {0}")]
#[diagnostic(code(mpl_lang::strumbra_error))]
StrumbraError(#[from] strumbra::Error),
#[error("Unreachable error: {0}")]
#[diagnostic(
code(mpl_lang::unreachable),
help("This error should never be reached")
)]
Unreachable(&'static str),
#[error("The param ${param} is defined multiple times")]
#[diagnostic(
code(mpl_lang::param_defined_multiple_times),
help("This param has been defined more than once")
)]
ParamDefinedMultipleTimes {
#[label("duplicate definition")]
span: SourceSpan,
param: String,
},
#[error("The param ${param} is not defined")]
#[diagnostic(code(mpl_lang::undefined_param))]
UndefinedParam {
#[label("undefined param")]
span: SourceSpan,
param: String,
},
#[error("The type {tpe} is not a valid type for tags")]
#[diagnostic(code(mpl_lang::invalid_tag_type))]
InvalidTagType {
#[label("invalid type")]
span: miette::SourceSpan,
tpe: String,
},
#[error("The parameter {} is not declared as optional", param.name)]
#[diagnostic(code(mpl_lang::ifdef_not_optional))]
IfdefNotOptional {
#[label("param declaration")]
span: miette::SourceSpan,
param: ParamDeclaration,
},
}
impl From<PestError<Rule>> for ParseError {
fn from(err: PestError<Rule>) -> Self {
let (start, mut len) = match err.location {
InputLocation::Pos(pos) => (pos, 0),
InputLocation::Span((start, end)) => (start, end - start),
};
let (label, message, suggestion) = match &err.variant {
ErrorVariant::ParsingError {
positives,
negatives,
} => {
let mut keywords = Vec::new();
let mut operations = Vec::new();
let mut other = Vec::new();
for rule in positives {
let name = friendly_rule(*rule);
if name.contains("keyword") {
keywords.push(name);
} else if name.contains("operation") {
operations.push(name);
} else {
other.push(name);
}
}
let mut label = String::new();
if keywords.is_empty() && operations.is_empty() && other.is_empty() {
label.push_str("unexpected token");
} else {
label.push_str("expected one of:\n");
if !keywords.is_empty() {
let kws: Vec<_> = keywords
.iter()
.map(|k| k.trim_end_matches(" keyword"))
.collect();
let _ = writeln!(label, " keywords: {}", join_with_or(&kws));
}
if !operations.is_empty() {
let ops: Vec<_> = operations
.iter()
.map(|o| {
o.trim_start_matches("a ")
.trim_start_matches("an ")
.trim_end_matches(" operation")
})
.collect();
let _ = writeln!(label, " operations: {}", join_with_or(&ops));
}
if !other.is_empty() {
for name in &other {
let _ = writeln!(label, " - {name}");
}
}
}
let mut msg = "unexpected token or operation".to_string();
if !negatives.is_empty() {
if !msg.is_empty() {
msg.push_str(" ");
}
msg.push_str("but found ");
msg.push_str(&friendly_rules(negatives));
}
let line_pos = match &err.line_col {
LineColLocation::Pos((_, col)) | LineColLocation::Span((_, col), _) => {
col.saturating_sub(1)
}
};
let suggestion = generate_suggestion(err.line(), line_pos, positives);
if len == 0 {
len = token_length(err.line(), line_pos);
}
let label = label.trim_end().to_string();
(label, msg, suggestion)
}
ErrorVariant::CustomError { message } => (message.clone(), message.clone(), None),
};
ParseError::SyntaxError {
span: SourceSpan::new(start.into(), len),
label,
message,
suggestion,
}
}
}
fn join_with_or(items: &[&str]) -> String {
match items.len() {
0 => String::new(),
1 => items[0].to_string(),
2 => format!("{} or {}", items[0], items[1]),
_ => {
let last = items[items.len() - 1];
let rest = &items[..items.len() - 1];
format!("{}, or {last}", rest.join(", "))
}
}
}
pub(crate) fn pair_to_source_span(pair: &Pair<Rule>) -> SourceSpan {
let span = pair.as_span();
let start = span.start();
let len = span.end() - start;
SourceSpan::new(start.into(), len)
}
fn friendly_rules(rules: &[Rule]) -> String {
let names: Vec<_> = rules.iter().copied().map(friendly_rule).collect();
match names.len() {
0 => String::new(),
1 => names[0].clone(),
2 => format!("{} or {}", names[0], names[1]),
_ => {
let last = &names[names.len() - 1];
let rest = &names[..names.len() - 1];
format!("{}, or {last}", rest.join(", "))
}
}
}
fn friendly_rule(rule: Rule) -> String {
match rule {
Rule::EOI => "end of query".to_string(),
Rule::pipe_keyword => "`|` (pipe)".to_string(),
Rule::time_range => "time range (e.g., [1h..])".to_string(),
Rule::time_relative => "relative time (e.g., 5m, 1h, 7d)".to_string(),
Rule::time_timestamp => "timestamp".to_string(),
Rule::time_rfc_3339 => "RFC3339 timestamp".to_string(),
Rule::time_modifier => "time modifier".to_string(),
Rule::filter_keyword | Rule::kw_filter => "`filter` keyword".to_string(),
Rule::kw_where => "`where` keyword".to_string(),
Rule::r#as => "`as` keyword".to_string(),
Rule::cmp => "a comparison operator (==, !=, <, >, <=, >=)".to_string(),
Rule::cmp_re => "a regex operator (==, !=)".to_string(),
Rule::regex => "a regex pattern (e.g., /pattern/)".to_string(),
Rule::value => "value (string, number, or bool)".to_string(),
Rule::string => "string value".to_string(),
Rule::number => "number".to_string(),
Rule::bool => "bool (true or false)".to_string(),
Rule::plain_ident => "identifier".to_string(),
Rule::escaped_ident => "escaped identifier".to_string(),
Rule::source => "source metric".to_string(),
Rule::metric_name => "metric name".to_string(),
Rule::metric_id => "metric identifier (e.g., dataset:metric)".to_string(),
Rule::dataset => "dataset name".to_string(),
Rule::align => "an align operation".to_string(),
Rule::group_by => "a group by operation".to_string(),
Rule::bucket_by => "a bucket by operation".to_string(),
Rule::map => "a map operation".to_string(),
Rule::replace => "a replace operation".to_string(),
Rule::join => "a join operation".to_string(),
Rule::simple_query => "simple query".to_string(),
Rule::compute_query => "compute query".to_string(),
Rule::directive => "directive".to_string(),
Rule::param => "param".to_string(),
Rule::param_ident => "param identifier".to_string(),
Rule::param_type => {
"param type (Duration, Dataset, Regex, string, int, float, bool)".to_string()
}
Rule::func => "function".to_string(),
Rule::compute_fn => "compute function".to_string(),
Rule::bucket_by_fn => {
"bucket function (histogram, interpolate_delta_histogram)".to_string()
}
Rule::bucket_by_with_conversion_fn => {
"bucket function (interpolate_cumulative_histogram)".to_string()
}
Rule::bucket_conversion => "conversion method (rate, increase)".to_string(),
Rule::bucket_specs => "bucket specifications".to_string(),
Rule::bucket_fn_call | Rule::bucket_fn_call_simple => "bucket function call".to_string(),
Rule::bucket_fn_call_with_conversion => "bucket function call with conversion".to_string(),
Rule::filter_rule => "filter rule".to_string(),
Rule::filter_expr => "filter expression".to_string(),
Rule::sample_expr => "sample expression".to_string(),
Rule::value_filter => "value filter".to_string(),
Rule::regex_filter => "regex filter".to_string(),
Rule::kw_is => "`is` keyword".to_string(),
Rule::is_filter => "type filter (e.g., is string)".to_string(),
Rule::tag_type => "tag type (string, int, float, or bool)".to_string(),
Rule::tags => "tags (comma-separated field names)".to_string(),
Rule::tag => "tag name".to_string(),
_ => {
let name = format!("{rule:?}");
name.to_lowercase().replace('_', " ")
}
}
}
#[derive(Debug, Clone)]
pub struct Suggestion(String);
impl Suggestion {
#[must_use]
pub fn suggestion(&self) -> &str {
&self.0
}
}
impl fmt::Display for Suggestion {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "Did you mean \"{}\"?", self.0)
}
}
fn generate_suggestion(
line: &str,
error_pos: usize,
expected_rules: &[Rule],
) -> Option<Suggestion> {
let actual_token = extract_token(line, error_pos)?;
if actual_token.len() < 2 {
return None;
}
let possible_keywords = rules_keywords(expected_rules);
let mut best_match: Option<(&str, f64)> = None;
for keyword in &possible_keywords {
let similarity = jaro(&actual_token.to_lowercase(), &keyword.to_lowercase());
if similarity > 0.8 {
if let Some((_, best_score)) = best_match {
if similarity > best_score {
best_match = Some((keyword, similarity));
}
} else {
best_match = Some((keyword, similarity));
}
}
}
best_match.map(|(keyword, _)| Suggestion(keyword.to_string()))
}
fn extract_token(line: &str, pos: usize) -> Option<String> {
let chars: Vec<char> = line.chars().collect();
if pos >= chars.len() {
return None;
}
let mut pos = pos;
while pos < chars.len() && chars[pos].is_whitespace() {
pos += 1;
}
if pos >= chars.len() {
return None;
}
let mut start = pos;
while start > 0 && chars[start - 1].is_alphanumeric() {
start -= 1;
}
let mut end = pos;
while end < chars.len() && chars[end].is_alphanumeric() {
end += 1;
}
if start < end {
Some(chars[start..end].iter().collect())
} else {
None
}
}
fn token_length(line: &str, pos: usize) -> usize {
let chars: Vec<char> = line.chars().collect();
if pos >= chars.len() {
return 0;
}
if !chars[pos].is_alphanumeric() {
return 1;
}
let mut end = pos;
while end < chars.len() && chars[end].is_alphanumeric() {
end += 1;
}
end - pos
}
fn rules_keywords(rules: &[Rule]) -> Vec<&'static str> {
let mut keywords = Vec::new();
for rule in rules {
match rule {
Rule::filter_keyword | Rule::kw_filter | Rule::kw_where => {
keywords.push("where");
keywords.push("filter");
}
Rule::r#as => keywords.push("as"),
Rule::align => keywords.push("align"),
Rule::group_by => keywords.push("group"),
Rule::bucket_by => keywords.push("bucket"),
Rule::map => keywords.push("map"),
Rule::replace => keywords.push("replace"),
Rule::join => keywords.push("join"),
Rule::kw_is => keywords.push("is"),
Rule::tag_type => {
keywords.push("string");
keywords.push("int");
keywords.push("float");
keywords.push("bool");
}
_ => {}
}
}
keywords
}