Skip to main content

orrery_cli/
error.rs

1//! Error types and miette diagnostic rendering for the Orrery CLI.
2//!
3//! [`Error`] is the top-level error type returned by [`crate::run()`]. It
4//! wraps either a parse error (with rich source-location diagnostics) or
5//! a render pipeline error.
6//!
7//! The [`Error::reportables()`] method converts an error into individually
8//! renderable miette diagnostics. For parse errors that contain multiple
9//! diagnostics, each one becomes a separate reportable item.
10//!
11//! # Multi-File Support
12//!
13//! [`SourceMapSource`] wraps a [`SourceMap`] reference and implements
14//! [`miette::SourceCode`], translating virtual-space spans back to the
15//! correct file. This enables miette to render source snippets from any
16//! file in the import tree.
17//!
18//! # Import Traces
19//!
20//! When an error occurs in an imported file, import trace diagnostics are
21//! built from primary label spans and exposed through miette's `related()`
22//! method so the import location is visible in the rendered output.
23
24use std::{fmt, iter};
25
26use miette::{
27    Diagnostic as MietteDiagnostic, LabeledSpan, MietteError, MietteSpanContents, SourceCode,
28    SourceSpan, SpanContents,
29};
30
31use orrery::RenderError;
32use orrery_parser::{
33    Span,
34    error::{Diagnostic, ParseError},
35    source_map::SourceMap,
36};
37
38/// Errors that can occur during CLI execution.
39///
40/// Separates parse errors (which carry a [`SourceMap`] for rich multi-file
41/// diagnostics) from render pipeline errors (I/O, layout, export).
42#[derive(Debug, thiserror::Error)]
43pub enum Error<'a> {
44    /// A parse error with structured diagnostics and a source map.
45    #[error("{0}")]
46    Parse(ParseError<'a>),
47    /// A render pipeline error (I/O, graph, layout, or export).
48    #[error("{0}")]
49    Render(#[from] RenderError),
50}
51
52impl<'a> From<ParseError<'a>> for Error<'a> {
53    fn from(err: ParseError<'a>) -> Self {
54        Self::Parse(err)
55    }
56}
57
58impl From<std::io::Error> for Error<'_> {
59    fn from(err: std::io::Error) -> Self {
60        Self::Render(RenderError::Io(err))
61    }
62}
63
64impl<'a> Error<'a> {
65    /// Convert this error into individually renderable miette diagnostics.
66    ///
67    /// For [`Error::Parse`], returns one reportable per [`Diagnostic`] in the
68    /// error, each backed by the shared [`SourceMap`] for multi-file snippet
69    /// rendering and import traces.
70    ///
71    /// For [`Error::Render`], returns a single reportable.
72    pub fn reportables(&'a self) -> Vec<Box<dyn MietteDiagnostic + 'a>> {
73        match self {
74            Error::Parse(parse_err) => {
75                let source_map = parse_err.source_map();
76                parse_err
77                    .diagnostics()
78                    .iter()
79                    .map(|d| {
80                        Box::new(DiagnosticAdapter::new(d, source_map)) as Box<dyn MietteDiagnostic>
81                    })
82                    .collect()
83            }
84            Error::Render(render_err) => {
85                vec![Box::new(RenderErrorAdapter(render_err)) as Box<dyn MietteDiagnostic>]
86            }
87        }
88    }
89}
90
91/// Newtype wrapper around [`SourceMap`] that implements [`miette::SourceCode`].
92///
93/// Required because the orphan rule prevents implementing a foreign trait
94/// (`miette::SourceCode`) on a foreign type (`SourceMap`) directly.
95///
96/// The implementation translates a virtual-space [`SourceSpan`] to the
97/// owning file, computes a local offset, and delegates to the file's source
98/// text wrapped in a [`miette::NamedSource`].
99#[derive(Debug, Clone, Copy)]
100struct SourceMapSource<'a>(&'a SourceMap<'a>);
101
102impl SourceCode for SourceMapSource<'_> {
103    fn read_span<'s>(
104        &'s self,
105        span: &SourceSpan,
106        context_lines_before: usize,
107        context_lines_after: usize,
108    ) -> Result<Box<dyn SpanContents<'s> + 's>, MietteError> {
109        let offset = span.offset();
110
111        let file = self.0.lookup_file(offset).ok_or(MietteError::OutOfBounds)?;
112
113        // Translate virtual offset → local offset within the file.
114        let local_offset = offset - file.start_offset();
115        let local_span = SourceSpan::new(local_offset.into(), span.len());
116
117        // Delegate context-line extraction to the existing `&str` impl.
118        let contents =
119            file.source()
120                .read_span(&local_span, context_lines_before, context_lines_after)?;
121
122        // Translate the local span back to virtual coordinates so that
123        // miette can match label offsets (which are virtual) against the
124        // returned data range.
125        let local = contents.span();
126        let virtual_span =
127            SourceSpan::new((local.offset() + file.start_offset()).into(), local.len());
128
129        // Re-wrap with the file name so miette prints it in the header.
130        Ok(Box::new(MietteSpanContents::new_named(
131            file.name().to_owned(),
132            contents.data(),
133            virtual_span,
134            contents.line(),
135            contents.column(),
136            contents.line_count(),
137        )))
138    }
139}
140
141/// Adapter for a single orrery [`Diagnostic`].
142///
143/// Wraps a [`Diagnostic`] together with the [`SourceMap`] so that miette
144/// can render source snippets from the correct file and display the import
145/// trace chain via [`related()`](MietteDiagnostic::related).
146#[derive(thiserror::Error)]
147#[error("{}", .diag.message())]
148struct DiagnosticAdapter<'a> {
149    diag: &'a Diagnostic,
150    source_code: SourceMapSource<'a>,
151    imports: Vec<ImportDiagnostic<'a>>,
152}
153
154impl<'a> DiagnosticAdapter<'a> {
155    fn new(diag: &'a Diagnostic, source_map: &'a SourceMap<'a>) -> Self {
156        let imports = Self::build_imports(diag, source_map);
157        Self {
158            diag,
159            source_code: SourceMapSource(source_map),
160            imports,
161        }
162    }
163
164    /// Build [`ImportDiagnostic`]s for primary labels that fall in imported files.
165    ///
166    /// For each primary label, looks up its file and extracts the
167    /// `first_imported_at` span — the `import "…";` declaration in the
168    /// parent file. Root-file labels (where `first_imported_at` is `None`)
169    /// are skipped.
170    fn build_imports(
171        diag: &Diagnostic,
172        source_map: &'a SourceMap<'a>,
173    ) -> Vec<ImportDiagnostic<'a>> {
174        diag.labels()
175            .iter()
176            .filter(|l| l.is_primary())
177            .filter_map(|l| {
178                source_map
179                    .lookup_file_by_span(l.span())
180                    .and_then(|f| f.first_imported_at())
181            })
182            .map(|import_span| ImportDiagnostic::new(import_span, source_map))
183            .collect()
184    }
185}
186
187impl fmt::Debug for DiagnosticAdapter<'_> {
188    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
189        f.debug_struct("DiagnosticAdapter")
190            .field("diag", &self.diag)
191            .finish()
192    }
193}
194
195impl MietteDiagnostic for DiagnosticAdapter<'_> {
196    fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
197        self.diag
198            .code()
199            .map(|c| Box::new(c) as Box<dyn fmt::Display>)
200    }
201
202    fn help<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
203        self.diag
204            .help()
205            .map(|h| Box::new(h) as Box<dyn fmt::Display>)
206    }
207
208    fn source_code(&self) -> Option<&dyn miette::SourceCode> {
209        Some(&self.source_code as &dyn miette::SourceCode)
210    }
211
212    fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
213        let labels = self.diag.labels();
214        if labels.is_empty() {
215            return None;
216        }
217
218        Some(Box::new(labels.iter().map(|label| {
219            let span = span_to_miette(label.span());
220            let message = Some(label.message().to_string());
221            if label.is_primary() {
222                LabeledSpan::new_primary_with_span(message, span)
223            } else {
224                LabeledSpan::new_with_span(message, span)
225            }
226        })))
227    }
228
229    fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn MietteDiagnostic> + 'a>> {
230        if self.imports.is_empty() {
231            return None;
232        }
233        Some(Box::new(
234            self.imports
235                .iter()
236                .map(|import| import as &dyn MietteDiagnostic),
237        ))
238    }
239}
240
241#[derive(Debug, thiserror::Error)]
242struct ImportDiagnostic<'a> {
243    span: Span,
244    source_code: SourceMapSource<'a>,
245    next: Option<Box<ImportDiagnostic<'a>>>,
246}
247
248impl<'a> ImportDiagnostic<'a> {
249    fn new(span: Span, source_map: &'a SourceMap<'a>) -> Self {
250        let next = source_map
251            .lookup_file_by_span(span)
252            .and_then(|f| f.first_imported_at())
253            .map(|import_span| Box::new(ImportDiagnostic::new(import_span, source_map)));
254        ImportDiagnostic {
255            span,
256            source_code: SourceMapSource(source_map),
257            next,
258        }
259    }
260}
261
262impl fmt::Display for ImportDiagnostic<'_> {
263    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
264        write!(f, "import trace")
265    }
266}
267
268impl MietteDiagnostic for ImportDiagnostic<'_> {
269    fn source_code(&self) -> Option<&dyn SourceCode> {
270        Some(&self.source_code as &dyn SourceCode)
271    }
272
273    fn labels(&self) -> Option<Box<dyn Iterator<Item = LabeledSpan> + '_>> {
274        let span = span_to_miette(self.span);
275        Some(Box::new(iter::once(LabeledSpan::new_with_span(
276            Some("imported here".to_string()),
277            span,
278        ))))
279    }
280
281    fn related<'a>(&'a self) -> Option<Box<dyn Iterator<Item = &'a dyn MietteDiagnostic> + 'a>> {
282        let next = self.next.as_ref()?;
283        Some(Box::new(iter::once(next.as_ref() as &dyn MietteDiagnostic)))
284    }
285}
286
287/// Adapter for [`RenderError`] variants so they can be rendered by miette.
288#[derive(Debug, thiserror::Error)]
289#[error(transparent)]
290struct RenderErrorAdapter<'a>(&'a RenderError);
291
292impl MietteDiagnostic for RenderErrorAdapter<'_> {
293    fn code<'a>(&'a self) -> Option<Box<dyn fmt::Display + 'a>> {
294        let code = match &self.0 {
295            RenderError::Io(_) => "orrery::io",
296            RenderError::Graph(_) => "orrery::graph",
297            RenderError::Layout(_) => "orrery::layout",
298            RenderError::Export(_) => "orrery::export",
299        };
300        Some(Box::new(code))
301    }
302}
303
304/// Convert an orrery [`Span`] to a miette [`SourceSpan`].
305fn span_to_miette(span: Span) -> SourceSpan {
306    SourceSpan::new(span.start().into(), span.len())
307}
308
309#[cfg(test)]
310mod tests {
311    use orrery_parser::error::ErrorCode;
312
313    use super::*;
314
315    /// Helper: build a SourceMap with a single file.
316    fn single_file_source_map<'a>(name: &str, source: &'a str) -> SourceMap<'a> {
317        let mut sm = SourceMap::new();
318        sm.add_file(name, source, None);
319        sm
320    }
321
322    #[test]
323    fn test_single_diagnostic_with_source_map() {
324        let source = "hello";
325        let sm = single_file_source_map("test.orr", source);
326        let diag = Diagnostic::error("test error")
327            .with_code(ErrorCode::E300)
328            .with_label(Span::new(0..5), "here")
329            .with_help("try this");
330        let parse_err = ParseError::new(vec![diag], sm);
331        let err = Error::Parse(parse_err);
332
333        let reportables = err.reportables();
334        assert_eq!(reportables.len(), 1);
335        assert_eq!(reportables[0].to_string(), "test error");
336    }
337
338    #[test]
339    fn test_multiple_diagnostics() {
340        let source = "source code that is long enough for spans";
341        let sm = single_file_source_map("test.orr", source);
342        let diags = vec![
343            Diagnostic::error("first error")
344                .with_code(ErrorCode::E300)
345                .with_label(Span::new(0..5), "first"),
346            Diagnostic::error("second error")
347                .with_code(ErrorCode::E301)
348                .with_label(Span::new(10..15), "second")
349                .with_help("help for second"),
350            Diagnostic::error("third error").with_label(Span::new(20..25), "third"),
351        ];
352        let parse_err = ParseError::new(diags, sm);
353        let err = Error::Parse(parse_err);
354
355        let reportables = err.reportables();
356
357        assert_eq!(reportables.len(), 3);
358        assert_eq!(reportables[0].to_string(), "first error");
359        assert_eq!(reportables[1].to_string(), "second error");
360        assert_eq!(reportables[2].to_string(), "third error");
361    }
362
363    #[test]
364    fn test_single_render_error() {
365        let err = Error::Render(RenderError::Graph("graph error".to_string()));
366
367        let reportables = err.reportables();
368
369        assert_eq!(reportables.len(), 1);
370        assert_eq!(reportables[0].to_string(), "Graph error: graph error");
371    }
372
373    #[test]
374    fn test_all_labels_returned() {
375        let source = "some source code text";
376        let sm = single_file_source_map("test.orr", source);
377        let diag = Diagnostic::error("error with labels")
378            .with_label(Span::new(0..5), "primary label")
379            .with_secondary_label(Span::new(10..15), "secondary label");
380
381        let adapter = DiagnosticAdapter::new(&diag, &sm);
382
383        let labels: Vec<_> = adapter.labels().unwrap().collect();
384        assert_eq!(labels.len(), 2);
385        assert_eq!(labels[0].label(), Some("primary label"));
386        assert_eq!(labels[1].label(), Some("secondary label"));
387    }
388
389    #[test]
390    fn test_primary_flag_on_labels() {
391        let source = "some source code text";
392        let sm = single_file_source_map("test.orr", source);
393        let diag = Diagnostic::error("error with labels")
394            .with_label(Span::new(0..5), "primary")
395            .with_secondary_label(Span::new(10..15), "secondary");
396
397        let adapter = DiagnosticAdapter::new(&diag, &sm);
398
399        let labels: Vec<_> = adapter.labels().unwrap().collect();
400        assert_eq!(labels.len(), 2);
401        assert!(labels[0].primary());
402        assert!(!labels[1].primary());
403    }
404
405    #[test]
406    fn test_source_map_as_source_code() {
407        use miette::SourceCode;
408
409        let source = "line one\nline two\nline three";
410        let sm = single_file_source_map("main.orr", source);
411        let src = SourceMapSource(&sm);
412
413        // Read a span in the middle of the source.
414        let span = SourceSpan::new(9.into(), 8); // "line two"
415        let contents = src
416            .read_span(&span, 0, 0)
417            .expect("read_span should succeed");
418        let text = std::str::from_utf8(contents.data()).unwrap();
419        assert!(text.contains("line two"));
420    }
421
422    #[test]
423    fn test_source_map_multi_file() {
424        use miette::SourceCode;
425
426        let mut sm = SourceMap::new();
427        let _base_a = sm.add_file("a.orr", "aaaa", None);
428        let base_b = sm.add_file("b.orr", "bbbb", Some(Span::new(0..4)));
429        let src = SourceMapSource(&sm);
430
431        // Read from the second file.
432        let span = SourceSpan::new(base_b.into(), 4);
433        let contents = src
434            .read_span(&span, 0, 0)
435            .expect("read_span should succeed");
436        let name = contents.name().expect("should have a file name");
437        assert_eq!(name, "b.orr");
438    }
439
440    #[test]
441    fn test_import_trace_root_file_no_related() {
442        let source = "diagram component;\nbox: Rectangle;";
443        let sm = single_file_source_map("main.orr", source);
444        let diag = Diagnostic::error("error in root").with_label(Span::new(0..7), "here");
445
446        let adapter = DiagnosticAdapter::new(&diag, &sm);
447
448        // Root file → no import trace → related() returns None.
449        assert!(adapter.related().is_none());
450    }
451
452    #[test]
453    fn test_import_trace_imported_file_has_related() {
454        let mut sm = SourceMap::new();
455        // Root file: "import \"lib\";\n" (14 bytes)
456        let _base_root = sm.add_file("main.orr", "import \"lib\";\n", None);
457        // Imported file starts after root + 1-byte gap
458        let base_lib = sm.add_file("lib.orr", "library;\ntype Bad;", Some(Span::new(0..13)));
459
460        // Error in the imported file (e.g., at offset base_lib..base_lib+7 = "library")
461        let diag = Diagnostic::error("error in lib")
462            .with_label(Span::new(base_lib..(base_lib + 7)), "here");
463
464        let adapter = DiagnosticAdapter::new(&diag, &sm);
465
466        // Should have one related diagnostic showing the import chain.
467        let related: Vec<_> = adapter.related().unwrap().collect();
468        assert_eq!(related.len(), 1);
469        assert!(related[0].labels().is_some());
470    }
471}