1use ariadne::{Color, Config, Label, Report, ReportKind, Source};
4use styx_parse::Span;
5
6fn ariadne_config() -> Config {
8 let no_color = std::env::var("NO_COLOR").is_ok();
9 if no_color {
10 Config::default().with_color(false)
11 } else {
12 Config::default()
13 }
14}
15
16#[derive(Debug, Clone)]
18pub struct ValidationResult {
19 pub errors: Vec<ValidationError>,
21 pub warnings: Vec<ValidationWarning>,
23}
24
25impl ValidationResult {
26 pub fn ok() -> Self {
28 Self {
29 errors: Vec::new(),
30 warnings: Vec::new(),
31 }
32 }
33
34 pub fn is_valid(&self) -> bool {
36 self.errors.is_empty()
37 }
38
39 pub fn error(&mut self, error: ValidationError) {
41 self.errors.push(error);
42 }
43
44 pub fn warning(&mut self, warning: ValidationWarning) {
46 self.warnings.push(warning);
47 }
48
49 pub fn merge(&mut self, other: ValidationResult) {
51 self.errors.extend(other.errors);
52 self.warnings.extend(other.warnings);
53 }
54
55 pub fn render(&self, filename: &str, source: &str) -> String {
57 let mut output = Vec::new();
58 self.write_report(filename, source, &mut output);
59 String::from_utf8(output).unwrap_or_else(|_| {
60 self.errors
61 .iter()
62 .map(|e| e.to_string())
63 .collect::<Vec<_>>()
64 .join("\n")
65 })
66 }
67
68 pub fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, mut writer: W) {
70 for error in &self.errors {
71 error.write_report(filename, source, &mut writer);
72 }
73 for warning in &self.warnings {
74 warning.write_report(filename, source, &mut writer);
75 }
76 }
77}
78
79#[derive(Debug, Clone)]
81pub struct ValidationError {
82 pub path: String,
84 pub span: Option<Span>,
86 pub kind: ValidationErrorKind,
88 pub message: String,
90}
91
92impl ValidationError {
93 pub fn new(
95 path: impl Into<String>,
96 kind: ValidationErrorKind,
97 message: impl Into<String>,
98 ) -> Self {
99 Self {
100 path: path.into(),
101 span: None,
102 kind,
103 message: message.into(),
104 }
105 }
106
107 pub fn with_span(mut self, span: Option<Span>) -> Self {
109 self.span = span;
110 self
111 }
112
113 pub fn quickfix_data(&self) -> Option<serde_json::Value> {
116 match &self.kind {
117 ValidationErrorKind::UnknownField {
118 field, suggestion, ..
119 } => suggestion.as_ref().map(|suggestion| {
120 serde_json::json!({
121 "type": "rename_field",
122 "from": field,
123 "to": suggestion
124 })
125 }),
126 _ => None,
127 }
128 }
129
130 pub fn diagnostic_message(&self) -> String {
132 match &self.kind {
133 ValidationErrorKind::UnknownField {
134 field,
135 valid_fields,
136 suggestion,
137 } => {
138 let mut msg = format!("unknown field '{}'", field);
139 if let Some(suggestion) = suggestion {
140 msg.push_str(&format!(" — did you mean '{}'?", suggestion));
141 }
142 if !valid_fields.is_empty() && valid_fields.len() <= 10 {
143 msg.push_str(&format!("\nvalid: {}", valid_fields.join(", ")));
144 }
145 msg
146 }
147 ValidationErrorKind::MissingField { field } => {
148 format!("missing required field '{}'", field)
149 }
150 ValidationErrorKind::TypeMismatch { expected, got } => {
151 format!("type mismatch: expected {}, got {}", expected, got)
152 }
153 _ => self.message.clone(),
154 }
155 }
156
157 pub fn render(&self, filename: &str, source: &str) -> String {
159 let mut output = Vec::new();
160 self.write_report(filename, source, &mut output);
161 String::from_utf8(output).unwrap_or_else(|_| format!("{}", self))
162 }
163
164 pub fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, writer: W) {
166 let report = self.build_report(filename);
167 let _ = report
168 .with_config(ariadne_config())
169 .finish()
170 .write((filename, Source::from(source)), writer);
171 }
172
173 fn build_report<'a>(
175 &self,
176 filename: &'a str,
177 ) -> ariadne::ReportBuilder<'static, (&'a str, std::ops::Range<usize>)> {
178 let range = self
179 .span
180 .map(|s| s.start as usize..s.end as usize)
181 .unwrap_or(0..1);
182
183 let path_info = if self.path.is_empty() {
184 String::new()
185 } else {
186 format!(" at '{}'", self.path)
187 };
188
189 match &self.kind {
190 ValidationErrorKind::MissingField { field } => {
191 Report::build(ReportKind::Error, (filename, range.clone()))
192 .with_message(format!("missing required field '{}'", field))
193 .with_label(
194 Label::new((filename, range))
195 .with_message(format!("add field '{}' here", field))
196 .with_color(Color::Red),
197 )
198 .with_help(format!("{} <value>", field))
199 }
200
201 ValidationErrorKind::UnknownField {
202 field,
203 valid_fields,
204 suggestion,
205 } => {
206 let mut builder = Report::build(ReportKind::Error, (filename, range.clone()))
207 .with_message(format!("unknown field '{}'", field))
208 .with_label(
209 Label::new((filename, range.clone()))
210 .with_message("not defined in schema")
211 .with_color(Color::Red),
212 );
213
214 if let Some(suggestion) = suggestion {
215 builder = builder.with_help(format!("did you mean '{}'?", suggestion));
216 }
217
218 if !valid_fields.is_empty() {
219 builder =
220 builder.with_note(format!("valid fields: {}", valid_fields.join(", ")));
221 }
222
223 builder
224 }
225
226 ValidationErrorKind::TypeMismatch { expected, got } => {
227 Report::build(ReportKind::Error, (filename, range.clone()))
228 .with_message(format!("type mismatch{}", path_info))
229 .with_label(
230 Label::new((filename, range))
231 .with_message(format!("expected {}, got {}", expected, got))
232 .with_color(Color::Red),
233 )
234 }
235
236 ValidationErrorKind::InvalidValue { reason } => {
237 Report::build(ReportKind::Error, (filename, range.clone()))
238 .with_message(format!("invalid value{}", path_info))
239 .with_label(
240 Label::new((filename, range))
241 .with_message(reason)
242 .with_color(Color::Red),
243 )
244 }
245
246 ValidationErrorKind::UnknownType { name } => {
247 Report::build(ReportKind::Error, (filename, range.clone()))
248 .with_message(format!("unknown type '{}'", name))
249 .with_label(
250 Label::new((filename, range))
251 .with_message("type not defined in schema")
252 .with_color(Color::Red),
253 )
254 }
255
256 ValidationErrorKind::InvalidVariant { expected, got } => {
257 let expected_list = expected.join(", ");
258 Report::build(ReportKind::Error, (filename, range.clone()))
259 .with_message(format!("invalid enum variant '@{}'", got))
260 .with_label(
261 Label::new((filename, range))
262 .with_message(format!("expected one of: {}", expected_list))
263 .with_color(Color::Red),
264 )
265 }
266
267 ValidationErrorKind::UnionMismatch { tried } => {
268 let tried_list = tried.join(", ");
269 Report::build(ReportKind::Error, (filename, range.clone()))
270 .with_message(format!(
271 "value doesn't match any union variant{}",
272 path_info
273 ))
274 .with_label(
275 Label::new((filename, range))
276 .with_message(format!("tried: {}", tried_list))
277 .with_color(Color::Red),
278 )
279 }
280
281 ValidationErrorKind::ExpectedObject => {
282 Report::build(ReportKind::Error, (filename, range.clone()))
283 .with_message(format!("expected object{}", path_info))
284 .with_label(
285 Label::new((filename, range))
286 .with_message("expected { ... }")
287 .with_color(Color::Red),
288 )
289 }
290
291 ValidationErrorKind::ExpectedSequence => {
292 Report::build(ReportKind::Error, (filename, range.clone()))
293 .with_message(format!("expected sequence{}", path_info))
294 .with_label(
295 Label::new((filename, range))
296 .with_message("expected ( ... )")
297 .with_color(Color::Red),
298 )
299 }
300
301 ValidationErrorKind::ExpectedScalar => {
302 Report::build(ReportKind::Error, (filename, range.clone()))
303 .with_message(format!("expected scalar value{}", path_info))
304 .with_label(
305 Label::new((filename, range))
306 .with_message("expected a simple value")
307 .with_color(Color::Red),
308 )
309 }
310
311 ValidationErrorKind::ExpectedTagged => {
312 Report::build(ReportKind::Error, (filename, range.clone()))
313 .with_message(format!("expected tagged value{}", path_info))
314 .with_label(
315 Label::new((filename, range))
316 .with_message("expected @tag or @tag{...}")
317 .with_color(Color::Red),
318 )
319 }
320
321 ValidationErrorKind::WrongTag { expected, got } => {
322 Report::build(ReportKind::Error, (filename, range.clone()))
323 .with_message(format!("wrong tag{}", path_info))
324 .with_label(
325 Label::new((filename, range))
326 .with_message(format!("expected @{}, got @{}", expected, got))
327 .with_color(Color::Red),
328 )
329 }
330
331 ValidationErrorKind::SchemaError { reason } => {
332 Report::build(ReportKind::Error, (filename, range.clone()))
333 .with_message("schema error")
334 .with_label(
335 Label::new((filename, range))
336 .with_message(reason)
337 .with_color(Color::Red),
338 )
339 }
340 }
341 }
342}
343
344impl std::fmt::Display for ValidationError {
345 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
346 if self.path.is_empty() {
347 write!(f, "{}", self.message)
348 } else {
349 write!(f, "{}: {}", self.path, self.message)
350 }
351 }
352}
353
354impl std::error::Error for ValidationError {}
355
356#[derive(Debug, Clone, PartialEq, Eq)]
358pub enum ValidationErrorKind {
359 MissingField { field: String },
361 UnknownField {
363 field: String,
364 valid_fields: Vec<String>,
365 suggestion: Option<String>,
366 },
367 TypeMismatch { expected: String, got: String },
369 InvalidValue { reason: String },
371 UnknownType { name: String },
373 InvalidVariant { expected: Vec<String>, got: String },
375 UnionMismatch { tried: Vec<String> },
377 ExpectedObject,
379 ExpectedSequence,
381 ExpectedScalar,
383 ExpectedTagged,
385 WrongTag { expected: String, got: String },
387 SchemaError { reason: String },
389}
390
391#[derive(Debug, Clone)]
393pub struct ValidationWarning {
394 pub path: String,
396 pub span: Option<Span>,
398 pub kind: ValidationWarningKind,
400 pub message: String,
402}
403
404impl ValidationWarning {
405 pub fn new(
407 path: impl Into<String>,
408 kind: ValidationWarningKind,
409 message: impl Into<String>,
410 ) -> Self {
411 Self {
412 path: path.into(),
413 span: None,
414 kind,
415 message: message.into(),
416 }
417 }
418
419 pub fn with_span(mut self, span: Option<Span>) -> Self {
421 self.span = span;
422 self
423 }
424
425 pub fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, writer: W) {
427 let range = self
428 .span
429 .map(|s| s.start as usize..s.end as usize)
430 .unwrap_or(0..1);
431
432 let report = match &self.kind {
433 ValidationWarningKind::Deprecated { reason } => {
434 Report::build(ReportKind::Warning, (filename, range.clone()))
435 .with_message("deprecated")
436 .with_label(
437 Label::new((filename, range))
438 .with_message(reason)
439 .with_color(Color::Yellow),
440 )
441 }
442 ValidationWarningKind::IgnoredField { field } => {
443 Report::build(ReportKind::Warning, (filename, range.clone()))
444 .with_message(format!("field '{}' will be ignored", field))
445 .with_label(
446 Label::new((filename, range))
447 .with_message("ignored")
448 .with_color(Color::Yellow),
449 )
450 }
451 };
452
453 let _ = report
454 .with_config(ariadne_config())
455 .finish()
456 .write((filename, Source::from(source)), writer);
457 }
458}
459
460#[derive(Debug, Clone, PartialEq, Eq)]
462pub enum ValidationWarningKind {
463 Deprecated { reason: String },
465 IgnoredField { field: String },
467}