use ariadne::{Color, Config, Label, Report, ReportKind, Source};
use styx_parse::Span;
fn ariadne_config() -> Config {
let no_color = std::env::var("NO_COLOR").is_ok();
if no_color {
Config::default().with_color(false)
} else {
Config::default()
}
}
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub errors: Vec<ValidationError>,
pub warnings: Vec<ValidationWarning>,
}
impl ValidationResult {
pub fn ok() -> Self {
Self {
errors: Vec::new(),
warnings: Vec::new(),
}
}
pub fn is_valid(&self) -> bool {
self.errors.is_empty()
}
pub fn error(&mut self, error: ValidationError) {
self.errors.push(error);
}
pub fn warning(&mut self, warning: ValidationWarning) {
self.warnings.push(warning);
}
pub fn merge(&mut self, other: ValidationResult) {
self.errors.extend(other.errors);
self.warnings.extend(other.warnings);
}
pub fn render(&self, filename: &str, source: &str) -> String {
let mut output = Vec::new();
self.write_report(filename, source, &mut output);
String::from_utf8(output).unwrap_or_else(|_| {
self.errors
.iter()
.map(|e| e.to_string())
.collect::<Vec<_>>()
.join("\n")
})
}
pub fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, mut writer: W) {
for error in &self.errors {
error.write_report(filename, source, &mut writer);
}
for warning in &self.warnings {
warning.write_report(filename, source, &mut writer);
}
}
}
#[derive(Debug, Clone)]
pub struct ValidationError {
pub path: String,
pub span: Option<Span>,
pub kind: ValidationErrorKind,
pub message: String,
}
impl ValidationError {
pub fn new(
path: impl Into<String>,
kind: ValidationErrorKind,
message: impl Into<String>,
) -> Self {
Self {
path: path.into(),
span: None,
kind,
message: message.into(),
}
}
pub fn with_span(mut self, span: Option<Span>) -> Self {
self.span = span;
self
}
pub fn quickfix_data(&self) -> Option<serde_json::Value> {
match &self.kind {
ValidationErrorKind::UnknownField {
field, suggestion, ..
} => suggestion.as_ref().map(|suggestion| {
serde_json::json!({
"type": "rename_field",
"from": field,
"to": suggestion
})
}),
_ => None,
}
}
pub fn diagnostic_message(&self) -> String {
match &self.kind {
ValidationErrorKind::UnknownField {
field,
valid_fields,
suggestion,
} => {
let mut msg = format!("unknown field '{}'", field);
if let Some(suggestion) = suggestion {
msg.push_str(&format!(" — did you mean '{}'?", suggestion));
}
if !valid_fields.is_empty() && valid_fields.len() <= 10 {
msg.push_str(&format!("\nvalid: {}", valid_fields.join(", ")));
}
msg
}
ValidationErrorKind::MissingField { field } => {
format!("missing required field '{}'", field)
}
ValidationErrorKind::TypeMismatch { expected, got } => {
format!("type mismatch: expected {}, got {}", expected, got)
}
_ => self.message.clone(),
}
}
pub fn render(&self, filename: &str, source: &str) -> String {
let mut output = Vec::new();
self.write_report(filename, source, &mut output);
String::from_utf8(output).unwrap_or_else(|_| format!("{}", self))
}
pub fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, writer: W) {
let report = self.build_report(filename);
let _ = report
.with_config(ariadne_config())
.finish()
.write((filename, Source::from(source)), writer);
}
fn build_report<'a>(
&self,
filename: &'a str,
) -> ariadne::ReportBuilder<'static, (&'a str, std::ops::Range<usize>)> {
let range = self
.span
.map(|s| s.start as usize..s.end as usize)
.unwrap_or(0..1);
let path_info = if self.path.is_empty() {
String::new()
} else {
format!(" at '{}'", self.path)
};
match &self.kind {
ValidationErrorKind::MissingField { field } => {
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("missing required field '{}'", field))
.with_label(
Label::new((filename, range))
.with_message(format!("add field '{}' here", field))
.with_color(Color::Red),
)
.with_help(format!("{} <value>", field))
}
ValidationErrorKind::UnknownField {
field,
valid_fields,
suggestion,
} => {
let mut builder = Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("unknown field '{}'", field))
.with_label(
Label::new((filename, range.clone()))
.with_message("not defined in schema")
.with_color(Color::Red),
);
if let Some(suggestion) = suggestion {
builder = builder.with_help(format!("did you mean '{}'?", suggestion));
}
if !valid_fields.is_empty() {
builder =
builder.with_note(format!("valid fields: {}", valid_fields.join(", ")));
}
builder
}
ValidationErrorKind::TypeMismatch { expected, got } => {
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("type mismatch{}", path_info))
.with_label(
Label::new((filename, range))
.with_message(format!("expected {}, got {}", expected, got))
.with_color(Color::Red),
)
}
ValidationErrorKind::InvalidValue { reason } => {
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("invalid value{}", path_info))
.with_label(
Label::new((filename, range))
.with_message(reason)
.with_color(Color::Red),
)
}
ValidationErrorKind::UnknownType { name } => {
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("unknown type '{}'", name))
.with_label(
Label::new((filename, range))
.with_message("type not defined in schema")
.with_color(Color::Red),
)
}
ValidationErrorKind::InvalidVariant { expected, got } => {
let expected_list = expected.join(", ");
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("invalid enum variant '@{}'", got))
.with_label(
Label::new((filename, range))
.with_message(format!("expected one of: {}", expected_list))
.with_color(Color::Red),
)
}
ValidationErrorKind::UnionMismatch { tried } => {
let tried_list = tried.join(", ");
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!(
"value doesn't match any union variant{}",
path_info
))
.with_label(
Label::new((filename, range))
.with_message(format!("tried: {}", tried_list))
.with_color(Color::Red),
)
}
ValidationErrorKind::ExpectedObject => {
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("expected object{}", path_info))
.with_label(
Label::new((filename, range))
.with_message("expected { ... }")
.with_color(Color::Red),
)
}
ValidationErrorKind::ExpectedSequence => {
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("expected sequence{}", path_info))
.with_label(
Label::new((filename, range))
.with_message("expected ( ... )")
.with_color(Color::Red),
)
}
ValidationErrorKind::ExpectedScalar => {
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("expected scalar value{}", path_info))
.with_label(
Label::new((filename, range))
.with_message("expected a simple value")
.with_color(Color::Red),
)
}
ValidationErrorKind::ExpectedTagged => {
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("expected tagged value{}", path_info))
.with_label(
Label::new((filename, range))
.with_message("expected @tag or @tag{...}")
.with_color(Color::Red),
)
}
ValidationErrorKind::WrongTag { expected, got } => {
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message(format!("wrong tag{}", path_info))
.with_label(
Label::new((filename, range))
.with_message(format!("expected @{}, got @{}", expected, got))
.with_color(Color::Red),
)
}
ValidationErrorKind::SchemaError { reason } => {
Report::build(ReportKind::Error, (filename, range.clone()))
.with_message("schema error")
.with_label(
Label::new((filename, range))
.with_message(reason)
.with_color(Color::Red),
)
}
}
}
}
impl std::fmt::Display for ValidationError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.path.is_empty() {
write!(f, "{}", self.message)
} else {
write!(f, "{}: {}", self.path, self.message)
}
}
}
impl std::error::Error for ValidationError {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationErrorKind {
MissingField { field: String },
UnknownField {
field: String,
valid_fields: Vec<String>,
suggestion: Option<String>,
},
TypeMismatch { expected: String, got: String },
InvalidValue { reason: String },
UnknownType { name: String },
InvalidVariant { expected: Vec<String>, got: String },
UnionMismatch { tried: Vec<String> },
ExpectedObject,
ExpectedSequence,
ExpectedScalar,
ExpectedTagged,
WrongTag { expected: String, got: String },
SchemaError { reason: String },
}
#[derive(Debug, Clone)]
pub struct ValidationWarning {
pub path: String,
pub span: Option<Span>,
pub kind: ValidationWarningKind,
pub message: String,
}
impl ValidationWarning {
pub fn new(
path: impl Into<String>,
kind: ValidationWarningKind,
message: impl Into<String>,
) -> Self {
Self {
path: path.into(),
span: None,
kind,
message: message.into(),
}
}
pub fn with_span(mut self, span: Option<Span>) -> Self {
self.span = span;
self
}
pub fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, writer: W) {
let range = self
.span
.map(|s| s.start as usize..s.end as usize)
.unwrap_or(0..1);
let report = match &self.kind {
ValidationWarningKind::Deprecated { reason } => {
Report::build(ReportKind::Warning, (filename, range.clone()))
.with_message("deprecated")
.with_label(
Label::new((filename, range))
.with_message(reason)
.with_color(Color::Yellow),
)
}
ValidationWarningKind::IgnoredField { field } => {
Report::build(ReportKind::Warning, (filename, range.clone()))
.with_message(format!("field '{}' will be ignored", field))
.with_label(
Label::new((filename, range))
.with_message("ignored")
.with_color(Color::Yellow),
)
}
};
let _ = report
.with_config(ariadne_config())
.finish()
.write((filename, Source::from(source)), writer);
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum ValidationWarningKind {
Deprecated { reason: String },
IgnoredField { field: String },
}
#[cfg(test)]
mod tests {
use super::*;
use crate::assert_snapshot_stripped;
#[test]
fn test_missing_field_diagnostic() {
let source = "name Alice";
let error = ValidationError::new(
"",
ValidationErrorKind::MissingField {
field: "age".into(),
},
"missing required field 'age'",
)
.with_span(Some(Span { start: 0, end: 10 }));
assert_snapshot_stripped!(error.render("test.styx", source));
}
#[test]
fn test_unknown_field_diagnostic() {
let source = "name Alice\nunknwon_field value";
let error = ValidationError::new(
"",
ValidationErrorKind::UnknownField {
field: "unknwon_field".into(),
valid_fields: vec!["name".into(), "age".into(), "email".into()],
suggestion: Some("unknown_field".into()),
},
"unknown field 'unknwon_field'",
)
.with_span(Some(Span { start: 11, end: 24 }));
assert_snapshot_stripped!(error.render("test.styx", source));
}
#[test]
fn test_type_mismatch_diagnostic() {
let source = "age notanumber";
let error = ValidationError::new(
"age",
ValidationErrorKind::TypeMismatch {
expected: "int".into(),
got: "string".into(),
},
"expected int, got string",
)
.with_span(Some(Span { start: 4, end: 14 }));
assert_snapshot_stripped!(error.render("test.styx", source));
}
#[test]
fn test_invalid_variant_diagnostic() {
let source = "status @unknown";
let error = ValidationError::new(
"status",
ValidationErrorKind::InvalidVariant {
expected: vec!["active".into(), "inactive".into(), "pending".into()],
got: "unknown".into(),
},
"invalid enum variant",
)
.with_span(Some(Span { start: 7, end: 15 }));
assert_snapshot_stripped!(error.render("test.styx", source));
}
#[test]
fn test_expected_object_diagnostic() {
let source = "config simple_value";
let error = ValidationError::new(
"config",
ValidationErrorKind::ExpectedObject,
"expected object",
)
.with_span(Some(Span { start: 7, end: 19 }));
assert_snapshot_stripped!(error.render("test.styx", source));
}
#[test]
fn test_warning_deprecated_diagnostic() {
let source = "old_setting value";
let warning = ValidationWarning::new(
"old_setting",
ValidationWarningKind::Deprecated {
reason: "use 'new_setting' instead".into(),
},
"deprecated field",
)
.with_span(Some(Span { start: 0, end: 11 }));
let mut output = Vec::new();
warning.write_report("test.styx", source, &mut output);
assert_snapshot_stripped!(String::from_utf8(output).unwrap());
}
#[test]
fn test_validation_result_multiple_errors() {
let source = "name 123\nunknown_field value";
let mut result = ValidationResult::ok();
result.error(
ValidationError::new(
"name",
ValidationErrorKind::TypeMismatch {
expected: "string".into(),
got: "int".into(),
},
"expected string, got int",
)
.with_span(Some(Span { start: 5, end: 8 })),
);
result.error(
ValidationError::new(
"",
ValidationErrorKind::UnknownField {
field: "unknown_field".into(),
valid_fields: vec!["name".into(), "age".into()],
suggestion: None,
},
"unknown field",
)
.with_span(Some(Span { start: 9, end: 22 })),
);
assert_snapshot_stripped!(result.render("test.styx", source));
}
}