facet_styx/
error.rs

1//! Error types for Styx parsing.
2
3use std::fmt;
4
5use ariadne::{Color, Config, Label, Report, ReportKind, Source};
6use facet_format::DeserializeError;
7use styx_parse::Span;
8
9/// Get ariadne config, respecting NO_COLOR env var.
10fn ariadne_config() -> Config {
11    let no_color = std::env::var("NO_COLOR").is_ok();
12    if no_color {
13        Config::default().with_color(false)
14    } else {
15        Config::default()
16    }
17}
18
19/// Error that can occur during Styx parsing.
20#[derive(Debug, Clone, PartialEq)]
21pub struct StyxError {
22    pub kind: StyxErrorKind,
23    pub span: Option<Span>,
24}
25
26impl StyxError {
27    pub fn new(kind: StyxErrorKind, span: Option<Span>) -> Self {
28        Self { kind, span }
29    }
30
31    /// Render this error with ariadne.
32    ///
33    /// Returns a string containing the formatted error message with source context.
34    pub fn render(&self, filename: &str, source: &str) -> String {
35        let mut output = Vec::new();
36        self.write_report(filename, source, &mut output);
37        String::from_utf8(output).unwrap_or_else(|_| format!("{}", self))
38    }
39
40    /// Write the error report to a writer.
41    pub fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, writer: W) {
42        let report = self.build_report(filename, source);
43        let _ = report
44            .with_config(ariadne_config())
45            .finish()
46            .write((filename, Source::from(source)), writer);
47    }
48
49    /// Build an ariadne report for this error.
50    pub fn build_report<'a>(
51        &self,
52        filename: &'a str,
53        _source: &str,
54    ) -> ariadne::ReportBuilder<'static, (&'a str, std::ops::Range<usize>)> {
55        let range = self
56            .span
57            .map(|s| s.start as usize..s.end as usize)
58            .unwrap_or(0..1);
59
60        match &self.kind {
61            // diag[impl diagnostic.deser.invalid-value]
62            StyxErrorKind::InvalidScalar { value, expected } => {
63                Report::build(ReportKind::Error, (filename, range.clone()))
64                    .with_message(format!("invalid value '{}'", value))
65                    .with_label(
66                        Label::new((filename, range))
67                            .with_message(format!("expected {}", expected))
68                            .with_color(Color::Red),
69                    )
70            }
71
72            // diag[impl diagnostic.deser.missing-field]
73            StyxErrorKind::MissingField { name } => {
74                Report::build(ReportKind::Error, (filename, range.clone()))
75                    .with_message(format!("missing required field '{}'", name))
76                    .with_label(
77                        Label::new((filename, range))
78                            .with_message("in this object")
79                            .with_color(Color::Red),
80                    )
81                    .with_help(format!("add the required field: {} <value>", name))
82            }
83
84            // diag[impl diagnostic.deser.unknown-field]
85            StyxErrorKind::UnknownField { name } => {
86                Report::build(ReportKind::Error, (filename, range.clone()))
87                    .with_message(format!("unknown field '{}'", name))
88                    .with_label(
89                        Label::new((filename, range))
90                            .with_message("unknown field")
91                            .with_color(Color::Red),
92                    )
93            }
94
95            StyxErrorKind::UnexpectedToken { got, expected } => {
96                Report::build(ReportKind::Error, (filename, range.clone()))
97                    .with_message(format!("unexpected token '{}'", got))
98                    .with_label(
99                        Label::new((filename, range))
100                            .with_message(format!("expected {}", expected))
101                            .with_color(Color::Red),
102                    )
103            }
104
105            StyxErrorKind::UnexpectedEof { expected } => {
106                Report::build(ReportKind::Error, (filename, range.clone()))
107                    .with_message("unexpected end of input")
108                    .with_label(
109                        Label::new((filename, range))
110                            .with_message(format!("expected {}", expected))
111                            .with_color(Color::Red),
112                    )
113            }
114
115            StyxErrorKind::InvalidEscape { sequence } => {
116                Report::build(ReportKind::Error, (filename, range.clone()))
117                    .with_message(format!("invalid escape sequence '{}'", sequence))
118                    .with_label(
119                        Label::new((filename, range))
120                            .with_message("invalid escape")
121                            .with_color(Color::Red),
122                    )
123                    .with_help("valid escapes are: \\\\, \\\", \\n, \\r, \\t, \\uXXXX, \\u{X...}")
124            }
125        }
126    }
127}
128
129impl fmt::Display for StyxError {
130    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
131        write!(f, "{}", self.kind)?;
132        if let Some(span) = &self.span {
133            write!(f, " at offset {}", span.start)?;
134        }
135        Ok(())
136    }
137}
138
139impl std::error::Error for StyxError {}
140
141/// Kind of Styx error.
142#[derive(Debug, Clone, PartialEq)]
143pub enum StyxErrorKind {
144    /// Unexpected token.
145    UnexpectedToken { got: String, expected: &'static str },
146    /// Unexpected end of input.
147    UnexpectedEof { expected: &'static str },
148    /// Invalid scalar value for target type.
149    InvalidScalar {
150        value: String,
151        expected: &'static str,
152    },
153    /// Missing required field.
154    MissingField { name: String },
155    /// Unknown field.
156    UnknownField { name: String },
157    /// Invalid escape sequence.
158    InvalidEscape { sequence: String },
159}
160
161impl fmt::Display for StyxErrorKind {
162    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
163        match self {
164            StyxErrorKind::UnexpectedToken { got, expected } => {
165                write!(f, "unexpected token '{}', expected {}", got, expected)
166            }
167            StyxErrorKind::UnexpectedEof { expected } => {
168                write!(f, "unexpected end of input, expected {}", expected)
169            }
170            StyxErrorKind::InvalidScalar { value, expected } => {
171                write!(f, "invalid value '{}', expected {}", value, expected)
172            }
173            StyxErrorKind::MissingField { name } => {
174                write!(f, "missing required field '{}'", name)
175            }
176            StyxErrorKind::UnknownField { name } => {
177                write!(f, "unknown field '{}'", name)
178            }
179            StyxErrorKind::InvalidEscape { sequence } => {
180                write!(f, "invalid escape sequence '{}'", sequence)
181            }
182        }
183    }
184}
185
186/// Convert a facet_reflect::Span to a Range<usize>.
187#[allow(dead_code)]
188fn reflect_span_to_range(span: &facet_reflect::Span) -> std::ops::Range<usize> {
189    let start = span.offset;
190    let end = start + span.len;
191    start..end
192}
193
194/// Trait for rendering errors with ariadne diagnostics.
195#[allow(dead_code)]
196pub trait RenderError {
197    /// Render this error with ariadne.
198    ///
199    /// Returns a string containing the formatted error message with source context.
200    fn render(&self, filename: &str, source: &str) -> String;
201
202    /// Write the error report to a writer.
203    fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, writer: W);
204}
205
206/// Rendering support for `DeserializeError<StyxError>`.
207///
208/// This allows rendering the full deserialize error (which may come from the parser
209/// or from facet-format's deserializer) with ariadne diagnostics.
210impl RenderError for DeserializeError<StyxError> {
211    fn render(&self, filename: &str, source: &str) -> String {
212        let mut output = Vec::new();
213        self.write_report(filename, source, &mut output);
214        String::from_utf8(output).unwrap_or_else(|_| format!("{}", self))
215    }
216
217    fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, writer: W) {
218        // IMPORTANT: Config must be applied BEFORE adding labels, because ariadne
219        // applies filter_color when labels are added, not when the report is written.
220        let report = build_deserialize_error_report(self, filename, source, ariadne_config());
221        let _ = report
222            .finish()
223            .write((filename, Source::from(source)), writer);
224    }
225}
226
227#[allow(dead_code)]
228fn build_deserialize_error_report<'a>(
229    err: &DeserializeError<StyxError>,
230    filename: &'a str,
231    source: &str,
232    config: Config,
233) -> ariadne::ReportBuilder<'static, (&'a str, std::ops::Range<usize>)> {
234    match err {
235        // Parser errors - delegate to StyxError's rendering
236        // Note: StyxError::build_report doesn't take config, so we need to add it after
237        DeserializeError::Parser(styx_err) => {
238            styx_err.build_report(filename, source).with_config(config)
239        }
240
241        // Missing field from facet-format
242        DeserializeError::MissingField {
243            field,
244            type_name,
245            span,
246            ..
247        } => {
248            let range = span
249                .as_ref()
250                .map(reflect_span_to_range)
251                .unwrap_or(0..source.len().max(1));
252            Report::build(ReportKind::Error, (filename, range.clone()))
253                .with_config(config)
254                .with_message(format!("missing required field '{}'", field))
255                .with_label(
256                    Label::new((filename, range))
257                        .with_message(format!("in {}", type_name))
258                        .with_color(Color::Red),
259                )
260                .with_help(format!("add the required field: {} <value>", field))
261        }
262
263        // Unknown field from facet-format
264        DeserializeError::UnknownField { field, span, .. } => {
265            let range = span.as_ref().map(reflect_span_to_range).unwrap_or(0..1);
266            Report::build(ReportKind::Error, (filename, range.clone()))
267                .with_config(config)
268                .with_message(format!("unknown field '{}'", field))
269                .with_label(
270                    Label::new((filename, range))
271                        .with_message("unknown field")
272                        .with_color(Color::Red),
273                )
274        }
275
276        // Type mismatch from facet-format
277        DeserializeError::TypeMismatch {
278            expected,
279            got,
280            span,
281            ..
282        } => {
283            let range = span.as_ref().map(reflect_span_to_range).unwrap_or(0..1);
284            Report::build(ReportKind::Error, (filename, range.clone()))
285                .with_config(config)
286                .with_message(format!("type mismatch: expected {}", expected))
287                .with_label(
288                    Label::new((filename, range))
289                        .with_message(format!("got {}", got))
290                        .with_color(Color::Red),
291                )
292        }
293
294        // Reflect errors from facet-format
295        DeserializeError::Reflect { error, span, .. } => {
296            let range = span.as_ref().map(reflect_span_to_range).unwrap_or(0..1);
297            Report::build(ReportKind::Error, (filename, range.clone()))
298                .with_config(config)
299                .with_message(format!("{}", error))
300                .with_label(
301                    Label::new((filename, range))
302                        .with_message("error here")
303                        .with_color(Color::Red),
304                )
305        }
306
307        // Unexpected EOF
308        DeserializeError::UnexpectedEof { expected } => {
309            let range = source.len().saturating_sub(1)..source.len().max(1);
310            Report::build(ReportKind::Error, (filename, range.clone()))
311                .with_config(config)
312                .with_message("unexpected end of input")
313                .with_label(
314                    Label::new((filename, range))
315                        .with_message(format!("expected {}", expected))
316                        .with_color(Color::Red),
317                )
318        }
319
320        // Unsupported operation
321        DeserializeError::Unsupported(msg) => Report::build(ReportKind::Error, (filename, 0..1))
322            .with_config(config)
323            .with_message(format!("unsupported: {}", msg)),
324
325        // Cannot borrow
326        DeserializeError::CannotBorrow { message } => {
327            Report::build(ReportKind::Error, (filename, 0..1))
328                .with_config(config)
329                .with_message(message.clone())
330        }
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337    use facet::Facet;
338
339    #[test]
340    fn test_ariadne_no_color() {
341        // Verify that ariadne respects our config
342        let config = Config::default().with_color(false);
343
344        let source = "test input";
345        let report =
346            Report::<(&str, std::ops::Range<usize>)>::build(ReportKind::Error, ("test.styx", 0..4))
347                .with_config(config)
348                .with_message("test error")
349                .with_label(
350                    Label::new(("test.styx", 0..4))
351                        .with_message("here")
352                        .with_color(Color::Red),
353                )
354                .finish();
355
356        let mut output = Vec::new();
357        report
358            .write(("test.styx", Source::from(source)), &mut output)
359            .unwrap();
360        let s = String::from_utf8(output).unwrap();
361
362        // Check for ANSI escape codes
363        assert!(
364            !s.contains("\x1b["),
365            "Output should not contain ANSI escape codes when color is disabled:\n{:?}",
366            s
367        );
368    }
369
370    #[test]
371    fn test_ariadne_config_respects_no_color_env() {
372        // Test that ariadne_config() returns correct config based on NO_COLOR
373        let no_color = std::env::var("NO_COLOR").is_ok();
374        eprintln!("NO_COLOR is set: {}", no_color);
375
376        let config = ariadne_config();
377
378        let source = "test input";
379        let report =
380            Report::<(&str, std::ops::Range<usize>)>::build(ReportKind::Error, ("test.styx", 0..4))
381                .with_config(config)
382                .with_message("test error")
383                .with_label(
384                    Label::new(("test.styx", 0..4))
385                        .with_message("here")
386                        .with_color(Color::Red),
387                )
388                .finish();
389
390        let mut output = Vec::new();
391        report
392            .write(("test.styx", Source::from(source)), &mut output)
393            .unwrap();
394        let s = String::from_utf8(output).unwrap();
395        eprintln!("Output: {:?}", s);
396
397        // Always assert - NO_COLOR should be set by nextest setup script
398        assert!(no_color, "NO_COLOR should be set by nextest setup script");
399        assert!(
400            !s.contains("\x1b["),
401            "With NO_COLOR set, output should not contain ANSI escape codes:\n{:?}",
402            s
403        );
404    }
405
406    #[derive(Facet, Debug)]
407    struct Person {
408        name: String,
409        age: u32,
410    }
411
412    #[test]
413    fn test_missing_field_diagnostic() {
414        let source = "name Alice";
415        let result: Result<Person, _> = crate::from_str(source);
416        let err = result.unwrap_err();
417
418        // Use RenderError trait - config is applied internally
419        let rendered = RenderError::render(&err, "test.styx", source);
420
421        // Check no ANSI codes when NO_COLOR is set
422        let no_color = std::env::var("NO_COLOR").is_ok();
423        if no_color {
424            assert!(
425                !rendered.contains("\x1b["),
426                "Output should not contain ANSI escape codes:\n{:?}",
427                rendered
428            );
429        }
430
431        insta::assert_snapshot!(rendered);
432    }
433
434    #[test]
435    fn test_invalid_scalar_diagnostic() {
436        let source = "name Alice\nage notanumber";
437        let result: Result<Person, _> = crate::from_str(source);
438        let err = result.unwrap_err();
439
440        let rendered = err.render("test.styx", source);
441        insta::assert_snapshot!(rendered);
442    }
443
444    #[test]
445    fn test_unknown_field_diagnostic() {
446        #[derive(Facet, Debug)]
447        #[facet(deny_unknown_fields)]
448        struct Strict {
449            name: String,
450        }
451
452        let source = "name Alice\nunknown_field value";
453        let result: Result<Strict, _> = crate::from_str(source);
454        let err = result.unwrap_err();
455
456        let rendered = err.render("test.styx", source);
457        insta::assert_snapshot!(rendered);
458    }
459}