use pest::error::{ErrorVariant, InputLocation};
use ruprizzle_core::diagnostic::SchemaError;
use ruprizzle_core::span::Span;
use crate::grammar::Rule;
struct Phrasing {
message: String,
context: String,
advice: String,
}
pub(crate) fn from_pest(err: &pest::error::Error<Rule>, source: &str) -> SchemaError {
let start = match err.location {
InputLocation::Pos(p) => p,
InputLocation::Span((s, _)) => s,
};
let found = found_token(source, start);
let end = match err.location {
InputLocation::Span((_, e)) => e.max(start + 1),
InputLocation::Pos(_) if found.is_empty() => start + 1,
InputLocation::Pos(_) => start + found.len(),
};
let span = Span::new(start.min(source.len()), end.min(source.len().max(1)));
let phrasing = match &err.variant {
ErrorVariant::ParsingError { positives, .. } => phrase(positives, &found),
ErrorVariant::CustomError { message } => Phrasing {
message: message.clone(),
context: "here".to_owned(),
advice: "check the syntax of this declaration".to_owned(),
},
};
SchemaError::Syntax {
message: phrasing.message,
advice: Some(phrasing.advice),
span: span.into(),
context: phrasing.context,
}
}
fn found_token(source: &str, offset: usize) -> String {
let rest = source.get(offset..).unwrap_or("");
let trimmed = rest.trim_start();
if trimmed.is_empty() {
return String::new();
}
if trimmed.len() != rest.len() {
return String::new();
}
let token: String = rest
.chars()
.take_while(|c| !c.is_whitespace())
.take(24)
.collect();
token
}
fn phrase(positives: &[Rule], found: &str) -> Phrasing {
let found_desc = if found.is_empty() {
"end of input".to_owned()
} else {
format!("`{found}`")
};
inside_a_declaration(positives, &found_desc)
.or_else(|| at_top_level(positives, found, &found_desc))
.or_else(|| inside_an_attribute(positives, &found_desc))
.unwrap_or_else(|| Phrasing {
message: format!("unexpected {found_desc}"),
context: "this is not valid here".to_owned(),
advice: "check for a missing `{`, `}`, or `)` earlier in the file".to_owned(),
})
}
fn inside_a_declaration(positives: &[Rule], found_desc: &str) -> Option<Phrasing> {
let has = |r: Rule| positives.contains(&r);
if has(Rule::field_type) {
return Some(Phrasing {
message: "expected a field type".to_owned(),
context: format!("expected a type here, found {found_desc}"),
advice: "fields are written `name Type @attrs`, e.g. `email String @unique`".to_owned(),
});
}
if has(Rule::env_call) || has(Rule::boolean) && has(Rule::string) && !has(Rule::arg) {
return Some(Phrasing {
message: "expected a configuration value".to_owned(),
context: format!("expected a value here, found {found_desc}"),
advice: "configuration values are quoted strings, numbers, `true`/`false`, \
or `env(\"VAR\")` — e.g. `provider = \"postgres\"`"
.to_owned(),
});
}
if has(Rule::field) || has(Rule::block_attr) {
return Some(Phrasing {
message: "expected a field or the end of the model".to_owned(),
context: format!("expected a field, `@@`-attribute, or `}}`, found {found_desc}"),
advice: "each model member is either `name Type @attrs` or a block attribute \
such as `@@index([email])`"
.to_owned(),
});
}
if has(Rule::config_kv) {
return Some(Phrasing {
message: "expected a configuration entry".to_owned(),
context: format!("expected `key = value` or `}}`, found {found_desc}"),
advice: "blocks contain `key = value` entries, e.g. `provider = \"postgres\"`"
.to_owned(),
});
}
if has(Rule::enum_variant) {
return Some(Phrasing {
message: "expected an enum variant".to_owned(),
context: format!("expected a variant name or `}}`, found {found_desc}"),
advice: "enum variants are bare names, one per line, e.g. `ADMIN`".to_owned(),
});
}
None
}
fn at_top_level(positives: &[Rule], found: &str, found_desc: &str) -> Option<Phrasing> {
let has = |r: Rule| positives.contains(&r);
if !(has(Rule::schema)
|| has(Rule::EOI)
|| has(Rule::kw_model)
|| has(Rule::kw_enum)
|| has(Rule::kw_datasource)
|| has(Rule::model_def)
|| has(Rule::enum_def)
|| has(Rule::datasource))
{
return None;
}
let keywords = ["datasource", "generator", "enum", "model"];
let advice = ruprizzle_core::suggest::closest(found, keywords.iter()).map_or_else(
|| {
"a schema contains only top-level `datasource`, `generator`, `enum`, and `model` blocks"
.to_owned()
},
|k| format!("did you mean `{k}`?"),
);
Some(Phrasing {
message: "expected a declaration".to_owned(),
context: format!(
"expected `datasource`, `generator`, `enum`, or `model`, found {found_desc}"
),
advice,
})
}
fn inside_an_attribute(positives: &[Rule], found_desc: &str) -> Option<Phrasing> {
let has = |r: Rule| positives.contains(&r);
if has(Rule::arg) || has(Rule::value) || has(Rule::named_arg) {
return Some(Phrasing {
message: "expected an attribute argument".to_owned(),
context: format!("expected an argument or `)`, found {found_desc}"),
advice: "arguments are positional (`@default(now())`) or named \
(`@relation(fields: [authorId], references: [id])`)"
.to_owned(),
});
}
if has(Rule::attr_path) {
return Some(Phrasing {
message: "expected an attribute name".to_owned(),
context: format!("expected a name after `@`, found {found_desc}"),
advice: "write the attribute name directly after `@`, e.g. `@id` or `@db.VarChar(200)`"
.to_owned(),
});
}
if has(Rule::ident) {
return Some(Phrasing {
message: "expected a name".to_owned(),
context: format!("expected an identifier here, found {found_desc}"),
advice: "names start with a letter or `_` and contain letters, digits, and `_`"
.to_owned(),
});
}
None
}
#[cfg(test)]
mod tests {
use miette::Diagnostic;
use crate::parse;
#[test]
fn common_mistakes_get_tailored_messages() {
let cases: &[(&str, &str)] = &[
(
"model User {\n email @unique\n}\n",
"expected a field type",
),
(
"datasource db {\n provider = postgres\n}\n",
"expected a configuration value",
),
("modle User {\n}\n", "expected a declaration"),
(
"model User {\n id Uuid @id\n",
"expected a field or the end of the model",
),
("model User {\n id Uuid @\n}\n", "expected"),
];
for (src, expected) in cases {
let err = parse("schema.ruprizzle", src).expect_err("fixture is malformed");
let rendered = format!("{:?}", miette::Report::new(*err));
assert!(
rendered.contains(expected),
"expected {expected:?} in:\n{rendered}"
);
assert!(
!rendered.contains("field_type") && !rendered.contains("model_member"),
"raw grammar rule leaked into:\n{rendered}"
);
}
}
#[test]
fn syntax_errors_carry_help_and_a_span() {
let err = parse("schema.ruprizzle", "model User {\n email @unique\n}\n")
.expect_err("fixture is malformed");
let first = &err.errors[0];
assert!(first.help().is_some(), "syntax errors must suggest a fix");
}
}