Skip to main content

facet_styx/
error.rs

1//! Error types for Styx parsing.
2
3use ariadne::{Color, Config, Label, Report, ReportKind, Source};
4use facet_format::{DeserializeError, DeserializeErrorKind};
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/// Convert a facet_reflect::Span to a Range<usize>.
17fn reflect_span_to_range(span: &facet_reflect::Span) -> std::ops::Range<usize> {
18    let start = span.offset as usize;
19    let end = start + span.len as usize;
20    start..end
21}
22
23/// Trait for rendering errors with ariadne diagnostics.
24pub trait RenderError {
25    /// Render this error with ariadne.
26    ///
27    /// Returns a string containing the formatted error message with source context.
28    fn render(&self, filename: &str, source: &str) -> String;
29
30    /// Write the error report to a writer.
31    fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, writer: W);
32}
33
34/// Rendering support for `DeserializeError`.
35///
36/// This allows rendering the full deserialize error (which may come from the parser
37/// or from facet-format's deserializer) with ariadne diagnostics.
38impl RenderError for DeserializeError {
39    fn render(&self, filename: &str, source: &str) -> String {
40        let mut output = Vec::new();
41        self.write_report(filename, source, &mut output);
42        String::from_utf8(output).unwrap_or_else(|_| format!("{}", self))
43    }
44
45    fn write_report<W: std::io::Write>(&self, filename: &str, source: &str, writer: W) {
46        // IMPORTANT: Config must be applied BEFORE adding labels, because ariadne
47        // applies filter_color when labels are added, not when the report is written.
48        let report = build_deserialize_error_report(self, filename, source, ariadne_config());
49        let _ = report
50            .finish()
51            .write((filename, Source::from(source)), writer);
52    }
53}
54
55fn build_deserialize_error_report<'a>(
56    err: &DeserializeError,
57    filename: &'a str,
58    source: &str,
59    config: Config,
60) -> ariadne::ReportBuilder<'static, (&'a str, std::ops::Range<usize>)> {
61    // Get the range from err.span, with fallback
62    let range = err
63        .span
64        .as_ref()
65        .map(reflect_span_to_range)
66        .unwrap_or(0..source.len().max(1));
67
68    match &err.kind {
69        // Missing field from facet-format
70        DeserializeErrorKind::MissingField {
71            field,
72            container_shape,
73        } => Report::build(ReportKind::Error, (filename, range.clone()))
74            .with_config(config)
75            .with_message(format!("missing required field '{}'", field))
76            .with_label(
77                Label::new((filename, range))
78                    .with_message(format!("in {}", container_shape))
79                    .with_color(Color::Red),
80            )
81            .with_help(format!("{} <value>", field)),
82
83        // Unknown field from facet-format
84        DeserializeErrorKind::UnknownField { field, suggestion } => {
85            let mut report = Report::build(ReportKind::Error, (filename, range.clone()))
86                .with_config(config)
87                .with_message(format!("unknown field '{}'", field))
88                .with_label(
89                    Label::new((filename, range))
90                        .with_message("unknown field")
91                        .with_color(Color::Red),
92                );
93            if let Some(s) = suggestion {
94                report = report.with_help(format!("did you mean '{}'?", s));
95            }
96            report
97        }
98
99        // Type mismatch from facet-format
100        DeserializeErrorKind::TypeMismatch { expected, got } => {
101            Report::build(ReportKind::Error, (filename, range.clone()))
102                .with_config(config)
103                .with_message(format!("type mismatch: expected {}", expected))
104                .with_label(
105                    Label::new((filename, range))
106                        .with_message(format!("got {}", got))
107                        .with_color(Color::Red),
108                )
109        }
110
111        // Reflect errors from facet-format
112        DeserializeErrorKind::Reflect { kind, context } => {
113            let mut report = Report::build(ReportKind::Error, (filename, range.clone()))
114                .with_config(config)
115                .with_message(format!("{}", kind))
116                .with_label(
117                    Label::new((filename, range))
118                        .with_message("error here")
119                        .with_color(Color::Red),
120                );
121            if !context.is_empty() {
122                report = report.with_note(format!("while {}", context));
123            }
124            report
125        }
126
127        // Unexpected EOF
128        DeserializeErrorKind::UnexpectedEof { expected } => {
129            let eof_range = source.len().saturating_sub(1)..source.len().max(1);
130            Report::build(ReportKind::Error, (filename, eof_range.clone()))
131                .with_config(config)
132                .with_message("unexpected end of input")
133                .with_label(
134                    Label::new((filename, eof_range))
135                        .with_message(format!("expected {}", expected))
136                        .with_color(Color::Red),
137                )
138        }
139
140        // Unsupported operation
141        DeserializeErrorKind::Unsupported { message } => {
142            Report::build(ReportKind::Error, (filename, 0..1))
143                .with_config(config)
144                .with_message(format!("unsupported: {}", message))
145        }
146
147        // Cannot borrow
148        DeserializeErrorKind::CannotBorrow { reason } => {
149            Report::build(ReportKind::Error, (filename, 0..1))
150                .with_config(config)
151                .with_message(reason)
152        }
153
154        // Unexpected token (from parser)
155        DeserializeErrorKind::UnexpectedToken { got, expected } => {
156            Report::build(ReportKind::Error, (filename, range.clone()))
157                .with_config(config)
158                .with_message(format!("unexpected token '{}'", got))
159                .with_label(
160                    Label::new((filename, range))
161                        .with_message(format!("expected {}", expected))
162                        .with_color(Color::Red),
163                )
164        }
165
166        // Invalid value
167        DeserializeErrorKind::InvalidValue { message } => {
168            Report::build(ReportKind::Error, (filename, range.clone()))
169                .with_config(config)
170                .with_message(format!("invalid value: {}", message))
171                .with_label(
172                    Label::new((filename, range))
173                        .with_message("here")
174                        .with_color(Color::Red),
175                )
176        }
177
178        // Catch-all for other error kinds
179        _ => Report::build(ReportKind::Error, (filename, range.clone()))
180            .with_config(config)
181            .with_message(format!("{}", err.kind))
182            .with_label(
183                Label::new((filename, range))
184                    .with_message("error here")
185                    .with_color(Color::Red),
186            ),
187    }
188}
189
190#[cfg(test)]
191mod tests {
192    use super::*;
193    use facet::Facet;
194
195    #[test]
196    fn test_ariadne_no_color() {
197        // Verify that ariadne respects our config
198        let config = Config::default().with_color(false);
199
200        let source = "test input";
201        let report =
202            Report::<(&str, std::ops::Range<usize>)>::build(ReportKind::Error, ("test.styx", 0..4))
203                .with_config(config)
204                .with_message("test error")
205                .with_label(
206                    Label::new(("test.styx", 0..4))
207                        .with_message("here")
208                        .with_color(Color::Red),
209                )
210                .finish();
211
212        let mut output = Vec::new();
213        report
214            .write(("test.styx", Source::from(source)), &mut output)
215            .unwrap();
216        let s = String::from_utf8(output).unwrap();
217
218        // Check for ANSI escape codes
219        assert!(
220            !s.contains("\x1b["),
221            "Output should not contain ANSI escape codes when color is disabled:\n{:?}",
222            s
223        );
224    }
225
226    #[test]
227    fn test_ariadne_config_respects_no_color_env() {
228        // Test that ariadne_config() returns correct config based on NO_COLOR
229        let no_color = std::env::var("NO_COLOR").is_ok();
230        eprintln!("NO_COLOR is set: {}", no_color);
231
232        let config = ariadne_config();
233
234        let source = "test input";
235        let report =
236            Report::<(&str, std::ops::Range<usize>)>::build(ReportKind::Error, ("test.styx", 0..4))
237                .with_config(config)
238                .with_message("test error")
239                .with_label(
240                    Label::new(("test.styx", 0..4))
241                        .with_message("here")
242                        .with_color(Color::Red),
243                )
244                .finish();
245
246        let mut output = Vec::new();
247        report
248            .write(("test.styx", Source::from(source)), &mut output)
249            .unwrap();
250        let s = String::from_utf8(output).unwrap();
251        eprintln!("Output: {:?}", s);
252
253        // Always assert - NO_COLOR should be set by nextest setup script
254        assert!(no_color, "NO_COLOR should be set by nextest setup script");
255        assert!(
256            !s.contains("\x1b["),
257            "With NO_COLOR set, output should not contain ANSI escape codes:\n{:?}",
258            s
259        );
260    }
261
262    #[derive(Facet, Debug)]
263    struct Person {
264        name: String,
265        age: u32,
266    }
267
268    #[test]
269    fn test_missing_field_diagnostic() {
270        let source = "name Alice";
271        let result: Result<Person, _> = crate::from_str(source);
272        let err = result.unwrap_err();
273
274        crate::assert_snapshot_stripped!(RenderError::render(&err, "test.styx", source));
275    }
276
277    #[test]
278    fn test_invalid_scalar_diagnostic() {
279        let source = "name Alice\nage notanumber";
280        let result: Result<Person, _> = crate::from_str(source);
281        let err = result.unwrap_err();
282
283        crate::assert_snapshot_stripped!(err.render("test.styx", source));
284    }
285
286    #[test]
287    fn test_unknown_field_diagnostic() {
288        #[derive(Facet, Debug)]
289        #[facet(deny_unknown_fields)]
290        struct Strict {
291            name: String,
292        }
293
294        let source = "name Alice\nunknown_field value";
295        let result: Result<Strict, _> = crate::from_str(source);
296        let err = result.unwrap_err();
297
298        crate::assert_snapshot_stripped!(err.render("test.styx", source));
299    }
300}