Skip to main content

cratestack_parser/
diagnostics.rs

1use std::ops::Range;
2use std::sync::Arc;
3
4use ariadne::{Color, Label, Report, ReportKind, Source};
5use cratestack_core::SourceSpan;
6
7/// A schema error, identified by which file it came from (cratestack#916).
8///
9/// `file`/`source_text` start out empty: every constructor below (`new`,
10/// `span_error`) fires deep inside parsing/validation, dozens of call sites
11/// that have no path in scope and share one property — every error a single
12/// parse produces always belongs to the one file that parse was given.
13/// Rather than thread a path through every one of those call sites for no
14/// behavioral gain, [`SchemaError::with_file`] is applied exactly once, at
15/// the boundary where a path *is* known (`parse_schema_named`,
16/// `parse_schema_diagnostics`, `parse_schema_file`, `parse_schema_unvalidated`
17/// in `entry.rs`). Entry points that take no path tag the error with
18/// [`crate::ANONYMOUS_SCHEMA`] and the real source, so a rendered diagnostic
19/// always has a code frame — but only the `*_named`/`*_file` paths can name
20/// the actual file. Prefer those.
21#[derive(Clone, thiserror::Error)]
22#[error("{message}")]
23pub struct SchemaError {
24    message: String,
25    span: Range<usize>,
26    line: usize,
27    file: Arc<str>,
28    source_text: Arc<str>,
29}
30
31impl std::fmt::Debug for SchemaError {
32    /// Deliberately omits `source_text`. A derived `Debug` prints the entire
33    /// schema file, so every `.unwrap()` panic on a `Result<_, SchemaError>`
34    /// and every `{:?}` log line would dump the whole `.cstack` — a silent
35    /// regression with no compile error to catch it. The byte length is
36    /// enough to tell "source attached" from "source missing".
37    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38        f.debug_struct("SchemaError")
39            .field("message", &self.message)
40            .field("span", &self.span)
41            .field("line", &self.line)
42            .field("file", &self.file)
43            .field("source_text_len", &self.source_text.len())
44            .finish()
45    }
46}
47
48impl SchemaError {
49    pub(crate) fn new(message: impl Into<String>, span: Range<usize>, line: usize) -> Self {
50        Self {
51            message: message.into(),
52            span,
53            line,
54            file: Arc::from(""),
55            source_text: Arc::from(""),
56        }
57    }
58
59    /// Attach this error's file identity and that file's source text.
60    ///
61    /// `source` is an `Arc<str>` the caller already holds (not a fresh
62    /// `String`) so tagging every error collected from one parse — several,
63    /// with [`crate::parse_schema_diagnostics`] — is a refcount bump each,
64    /// not a copy of the whole schema per error.
65    pub(crate) fn with_file(mut self, file: &Arc<str>, source: &Arc<str>) -> Self {
66        self.file = Arc::clone(file);
67        self.source_text = Arc::clone(source);
68        self
69    }
70
71    pub fn message(&self) -> &str {
72        &self.message
73    }
74
75    pub fn span(&self) -> Range<usize> {
76        self.span.clone()
77    }
78
79    pub fn line(&self) -> usize {
80        self.line
81    }
82
83    /// The file this error belongs to. Errors from the `*_named`/`*_file`
84    /// entry points carry the real path; those from the path-less entry
85    /// points carry [`crate::ANONYMOUS_SCHEMA`]. Empty only for an error
86    /// constructed internally and never passed through [`Self::with_file`],
87    /// which no public entry point returns.
88    pub fn file(&self) -> &str {
89        &self.file
90    }
91
92    /// Render this error as a human-readable diagnostic.
93    ///
94    /// Takes no arguments: the error already knows both its file (`self.file`)
95    /// and that file's source (`self.source_text`), attached once at the
96    /// parse/diagnostics boundary. Before cratestack#916 this took a `(path,
97    /// source)` pair supplied by the caller, which had to happen to match the
98    /// file the error actually came from — nothing enforced that once more
99    /// than one file was involved.
100    pub fn render(&self) -> String {
101        let mut output = Vec::new();
102        let file = self.file.to_string();
103        Report::build(ReportKind::Error, (file.clone(), self.span.clone()))
104            .with_message(&self.message)
105            .with_label(
106                Label::new((file.clone(), self.span.clone()))
107                    .with_message(&self.message)
108                    .with_color(Color::Red),
109            )
110            .finish()
111            .write((file, Source::from(self.source_text.as_ref())), &mut output)
112            .expect("diagnostic rendering should succeed");
113
114        String::from_utf8(output).expect("ariadne should emit utf-8")
115    }
116}
117
118pub(crate) fn span_error(message: impl Into<String>, span: SourceSpan) -> SchemaError {
119    SchemaError::new(message, span.start..span.end, span.line)
120}