facet_styx/
schema_error.rs

1//! Validation error types.
2
3use ariadne::{Color, Config, Label, Report, ReportKind, Source};
4use styx_parse::Span;
5
6/// Get ariadne config, respecting NO_COLOR env var.
7fn 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/// Result of validating a document against a schema.
17#[derive(Debug, Clone)]
18pub struct ValidationResult {
19    /// Validation errors (must be empty for validation to pass).
20    pub errors: Vec<ValidationError>,
21    /// Validation warnings (non-fatal issues).
22    pub warnings: Vec<ValidationWarning>,
23}
24
25impl ValidationResult {
26    /// Create an empty (passing) result.
27    pub fn ok() -> Self {
28        Self {
29            errors: Vec::new(),
30            warnings: Vec::new(),
31        }
32    }
33
34    /// Check if validation passed (no errors).
35    pub fn is_valid(&self) -> bool {
36        self.errors.is_empty()
37    }
38
39    /// Add an error.
40    pub fn error(&mut self, error: ValidationError) {
41        self.errors.push(error);
42    }
43
44    /// Add a warning.
45    pub fn warning(&mut self, warning: ValidationWarning) {
46        self.warnings.push(warning);
47    }
48
49    /// Merge another result into this one.
50    pub fn merge(&mut self, other: ValidationResult) {
51        self.errors.extend(other.errors);
52        self.warnings.extend(other.warnings);
53    }
54
55    /// Render all errors with ariadne.
56    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    /// Write all error reports to a writer.
69    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/// A validation error.
80#[derive(Debug, Clone)]
81pub struct ValidationError {
82    /// Path to the error location (e.g., "server.tls.cert").
83    pub path: String,
84    /// Source span in the document.
85    pub span: Option<Span>,
86    /// Error kind.
87    pub kind: ValidationErrorKind,
88    /// Human-readable message.
89    pub message: String,
90}
91
92impl ValidationError {
93    /// Create a new validation error.
94    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    /// Set the span.
108    pub fn with_span(mut self, span: Option<Span>) -> Self {
109        self.span = span;
110        self
111    }
112
113    /// Get quickfix data for LSP code actions.
114    /// Returns JSON data that can be used to offer quick fixes.
115    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    /// Get a rich diagnostic message suitable for LSP.
131    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    /// Render this error with ariadne.
158    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    /// Write the error report to a writer.
165    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    /// Build an ariadne report for this error.
174    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/// Kinds of validation errors.
357#[derive(Debug, Clone, PartialEq, Eq)]
358pub enum ValidationErrorKind {
359    /// Missing required field in object.
360    MissingField { field: String },
361    /// Unknown field in object (when additional fields not allowed).
362    UnknownField {
363        field: String,
364        valid_fields: Vec<String>,
365        suggestion: Option<String>,
366    },
367    /// Type mismatch.
368    TypeMismatch { expected: String, got: String },
369    /// Invalid value for type.
370    InvalidValue { reason: String },
371    /// Unknown type reference in schema.
372    UnknownType { name: String },
373    /// Invalid enum variant.
374    InvalidVariant { expected: Vec<String>, got: String },
375    /// Union match failed (value didn't match any variant).
376    UnionMismatch { tried: Vec<String> },
377    /// Expected object, got something else.
378    ExpectedObject,
379    /// Expected sequence, got something else.
380    ExpectedSequence,
381    /// Expected scalar, got something else.
382    ExpectedScalar,
383    /// Expected tagged value.
384    ExpectedTagged,
385    /// Wrong tag name.
386    WrongTag { expected: String, got: String },
387    /// Schema error (invalid schema definition).
388    SchemaError { reason: String },
389}
390
391/// A validation warning (non-fatal).
392#[derive(Debug, Clone)]
393pub struct ValidationWarning {
394    /// Path to the warning location.
395    pub path: String,
396    /// Source span in the document.
397    pub span: Option<Span>,
398    /// Warning kind.
399    pub kind: ValidationWarningKind,
400    /// Human-readable message.
401    pub message: String,
402}
403
404impl ValidationWarning {
405    /// Create a new validation warning.
406    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    /// Set the span.
420    pub fn with_span(mut self, span: Option<Span>) -> Self {
421        self.span = span;
422        self
423    }
424
425    /// Write the warning report to a writer.
426    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/// Kinds of validation warnings.
461#[derive(Debug, Clone, PartialEq, Eq)]
462pub enum ValidationWarningKind {
463    /// Deprecated field or type.
464    Deprecated { reason: String },
465    /// Field will be ignored.
466    IgnoredField { field: String },
467}