use miette::Diagnostic as _;
use serde::Serialize;
use strsim::jaro;
use wasm_bindgen::prelude::*;
use crate::errors::Suggestion;
use crate::{CompileError, GroupError, IfdefError, ParseError, TypeError, compile};
use super::Span;
use super::completions::{
ALIGN_FN_NAMES, BUCKET_FN_NAMES, COMPUTE_FN_NAMES, GROUP_FN_NAMES, MAP_FN_NAMES,
};
#[derive(Clone, Copy, Serialize)]
#[serde(rename_all = "lowercase")]
pub(super) enum Severity {
Error,
Warning,
Info,
Hint,
}
#[derive(Clone, Serialize)]
pub(super) struct DiagnosticAction {
pub(super) name: String,
#[serde(flatten)]
pub(super) span: Span,
pub(super) insert: String,
}
impl DiagnosticAction {
fn replace_with(span: Span, suggestion: &str) -> DiagnosticAction {
DiagnosticAction {
name: format!("Replace with `{suggestion}`"),
span,
insert: suggestion.to_string(),
}
}
}
#[derive(Serialize)]
pub(super) struct DiagnosticItem {
#[serde(flatten)]
pub(super) span: Span,
pub(super) severity: Severity,
pub(super) message: String,
pub(super) help: Option<String>,
pub(super) actions: Vec<DiagnosticAction>,
}
#[must_use]
#[wasm_bindgen]
pub fn diagnostics(query: &str) -> JsValue {
let items = match compile(query) {
Ok(_) => super::lints::detect_hints(query),
Err(CompileError::Parse(error)) => {
let items = error.diagnostic_items();
maybe_rewrite_escaped_dataset_error(query, items)
}
Err(CompileError::Type(error)) => error.diagnostic_items(),
Err(CompileError::Group(error)) => error.diagnostic_items(),
Err(CompileError::Ifdef(error)) => error.diagnostic_items(),
};
super::to_js_value(&items)
}
pub(crate) fn maybe_rewrite_escaped_dataset_error(
query: &str,
items: Vec<DiagnosticItem>,
) -> Vec<DiagnosticItem> {
if items.len() != 1 || !matches!(items[0].severity, Severity::Error) {
return items;
}
let Some(ident_end) = find_escaped_ident_end(query, 0) else {
return items;
};
let inner = &query[1..ident_end - 1];
let rest = query[ident_end..].trim_start();
if rest.starts_with(':') {
return items;
}
let Some(dot_pos) = inner.find('.') else {
return items;
};
let dataset_part = &inner[..dot_pos];
let metric_part = &inner[dot_pos + 1..];
vec![DiagnosticItem {
span: Span::new(ident_end, ident_end),
severity: Severity::Error,
message: "expected ':' and a metric name after the dataset".to_string(),
help: Some(format!(
"MPL uses ':' to separate dataset and metric, e.g. `{dataset_part}`:`{metric_part}`"
)),
actions: vec![],
}]
}
fn find_escaped_ident_end(s: &str, start: usize) -> Option<usize> {
let bytes = s.as_bytes();
if bytes.get(start) != Some(&b'`') {
return None;
}
let mut i = start + 1;
while i < bytes.len() {
if bytes[i] == b'\\' {
i += 2;
} else if bytes[i] == b'`' {
return Some(i + 1);
} else {
i += 1;
}
}
None
}
impl TypeError {
pub(super) fn diagnostic_items(&self) -> Vec<DiagnosticItem> {
let message = self.to_string();
let help = self.help().map(|h| h.to_string());
if let Some(labels) = self.labels() {
let items: Vec<_> = labels
.map(|label| {
let src = label.inner();
let span = Span::new(src.offset(), src.offset() + src.len());
let is_declaration = label.label().is_some_and(|l| l.contains("declaration"));
if is_declaration {
DiagnosticItem {
span,
severity: Severity::Info,
message: label.label().unwrap_or("declared here").to_string(),
help: None,
actions: vec![],
}
} else {
DiagnosticItem {
span,
severity: Severity::Error,
message: message.clone(),
help: help.clone(),
actions: vec![],
}
}
})
.collect();
if items.is_empty() {
vec![DiagnosticItem {
span: Span::new(0, 0),
severity: Severity::Error,
message,
help,
actions: vec![],
}]
} else {
items
}
} else {
vec![DiagnosticItem {
span: Span::new(0, 0),
severity: Severity::Error,
message,
help,
actions: vec![],
}]
}
}
}
impl IfdefError {
pub(super) fn diagnostic_items(&self) -> Vec<DiagnosticItem> {
let message = self.to_string();
let help = self.help().map(|h| h.to_string());
let span = match self {
IfdefError::OptionalOutsideOfIfdef { span, .. }
| IfdefError::OptionalNotUsed { span, .. } => {
Span::new(span.offset(), span.offset() + span.len())
}
};
vec![DiagnosticItem {
span,
severity: Severity::Error,
message,
help,
actions: vec![],
}]
}
}
impl GroupError {
pub(super) fn diagnostic_items(&self) -> Vec<DiagnosticItem> {
let message = self.to_string();
let help = self.help().map(|h| h.to_string());
let (prev_span, next_span) = match self {
GroupError::InvalidGroups {
prev_span,
next_span,
..
} => (
Span::new(prev_span.offset(), prev_span.offset() + prev_span.len()),
Span::new(next_span.offset(), next_span.offset() + next_span.len()),
),
};
vec![
DiagnosticItem {
span: prev_span,
severity: Severity::Info,
message: "previous groups declared here".to_string(),
help: None,
actions: vec![],
},
DiagnosticItem {
span: next_span,
severity: Severity::Error,
message,
help,
actions: vec![],
},
]
}
}
impl ParseError {
pub(super) fn diagnostic_items(&self) -> Vec<DiagnosticItem> {
let message = self.to_string();
let help = self.help().map(|h| h.to_string());
let actions = self.diagnostic_actions();
if let Some(labels) = self.labels() {
let items: Vec<_> = labels
.map(|label| {
let src = label.inner();
DiagnosticItem {
span: Span::new(src.offset(), src.offset() + src.len()),
severity: Severity::Error,
message: message.clone(),
help: help.clone(),
actions: actions.clone(),
}
})
.collect();
if items.is_empty() {
vec![DiagnosticItem {
span: Span::new(0, 0),
severity: Severity::Error,
message,
help,
actions,
}]
} else {
items
}
} else {
vec![DiagnosticItem {
span: Span::new(0, 0),
severity: Severity::Error,
message,
help,
actions,
}]
}
}
fn diagnostic_actions(&self) -> Vec<DiagnosticAction> {
match self {
ParseError::SyntaxError {
span,
suggestion: Some(suggestion),
..
} => {
vec![suggestion.to_diagnostic(Span::new(span.offset(), span.offset() + span.len()))]
}
ParseError::UnsupportedMapFunction { span, name }
| ParseError::UnsupportedMapEvaluation { span, name } => {
suggest_function_replacements(name, span.offset(), &MAP_FN_NAMES)
}
ParseError::UnsupportedAlignFunction { span, name } => {
suggest_function_replacements(name, span.offset(), &ALIGN_FN_NAMES)
}
ParseError::UnsupportedGroupFunction { span, name } => {
suggest_function_replacements(name, span.offset(), &GROUP_FN_NAMES)
}
ParseError::UnsupportedComputeFunction { span, name } => {
suggest_function_replacements(name, span.offset(), &COMPUTE_FN_NAMES)
}
ParseError::UnsupportedBucketFunction { span, name } => {
suggest_function_replacements(name, span.offset(), &BUCKET_FN_NAMES)
}
_ => vec![],
}
}
}
impl Suggestion {
fn to_diagnostic(&self, span: Span) -> DiagnosticAction {
DiagnosticAction::replace_with(span, self.suggestion())
}
}
fn suggest_function_replacements(
input: &str,
from: usize,
candidates: &[String],
) -> Vec<DiagnosticAction> {
let input_lc = input.to_lowercase();
let span = Span::new(from, from + input.len());
let threshold = 0.8;
let mut scored: Vec<_> = candidates
.iter()
.filter_map(|c| {
let score = jaro(&input_lc, &c.to_lowercase());
(score >= threshold).then(|| (c.clone(), score))
})
.collect();
scored.sort_by(|a, b| b.1.total_cmp(&a.1));
scored.truncate(3);
scored
.into_iter()
.map(|(name, _)| DiagnosticAction::replace_with(span, &name))
.collect()
}
#[cfg(test)]
mod tests;