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;
6use cratestack_core::schema::attribute_text::{escape_for_diagnostic, substitute_for_display};
7
8use crate::diagnostics_related::RelatedSpan;
9
10/// A schema error, identified by which file it came from (cratestack#916).
11///
12/// `file`/`source_text` start out empty: every constructor below (`new`,
13/// `span_error`) fires deep inside parsing/validation, dozens of call sites
14/// that have no path in scope and share one property — every error a single
15/// parse produces always belongs to the one file that parse was given.
16/// Rather than thread a path through every one of those call sites for no
17/// behavioral gain, [`SchemaError::with_file`] is applied exactly once, at
18/// the boundary where a path *is* known (`parse_schema_named`,
19/// `parse_schema_diagnostics`, `parse_schema_file`, `parse_schema_unvalidated`
20/// in `entry.rs`). Entry points that take no path tag the error with
21/// [`crate::ANONYMOUS_SCHEMA`] and the real source, so a rendered diagnostic
22/// always has a code frame — but only the `*_named`/`*_file` paths can name
23/// the actual file. Prefer those.
24#[derive(Clone, thiserror::Error)]
25#[error("{message}")]
26pub struct SchemaError {
27    pub(crate) message: String,
28    pub(crate) span: Range<usize>,
29    pub(crate) line: usize,
30    pub(crate) file: Arc<str>,
31    pub(crate) source_text: Arc<str>,
32    pub(crate) related: Vec<RelatedSpan>,
33}
34
35impl std::fmt::Debug for SchemaError {
36    /// Deliberately omits `source_text`. A derived `Debug` prints the entire
37    /// schema file, so every `.unwrap()` panic on a `Result<_, SchemaError>`
38    /// and every `{:?}` log line would dump the whole `.cstack` — a silent
39    /// regression with no compile error to catch it. The byte length is
40    /// enough to tell "source attached" from "source missing".
41    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42        f.debug_struct("SchemaError")
43            .field("message", &self.message)
44            .field("span", &self.span)
45            .field("line", &self.line)
46            .field("file", &self.file)
47            .field("source_text_len", &self.source_text.len())
48            .field("related", &self.related)
49            .finish()
50    }
51}
52
53impl SchemaError {
54    /// Every error is built here, so this is where a message that quotes
55    /// attribute text or a source line has its control and invisible
56    /// characters escaped (`\u{7}`), never written raw to a terminal
57    /// (GHSA-69g4-xvcm-vm2j; `cratestack_core::schema::attribute_text::
58    /// escape_for_diagnostic`).
59    pub(crate) fn new(message: impl Into<String>, span: Range<usize>, line: usize) -> Self {
60        Self {
61            message: escape_for_diagnostic(&message.into()),
62            span,
63            line,
64            file: Arc::from(""),
65            source_text: Arc::from(""),
66            related: Vec::new(),
67        }
68    }
69
70    /// Add a second location, in the same address space as the primary span
71    /// until the loader attributes both.
72    pub(crate) fn with_related(mut self, message: impl Into<String>, span: SourceSpan) -> Self {
73        self.related
74            .push(RelatedSpan::new(message, span.start..span.end, span.line));
75        self
76    }
77
78    /// Attach this error's file identity and that file's source text.
79    ///
80    /// `source` is an `Arc<str>` the caller already holds (not a fresh
81    /// `String`) so tagging every error collected from one parse — several,
82    /// with [`crate::parse_schema_diagnostics`] — is a refcount bump each,
83    /// not a copy of the whole schema per error.
84    pub(crate) fn with_file(mut self, file: &Arc<str>, source: &Arc<str>) -> Self {
85        self.file = Arc::clone(file);
86        self.source_text = Arc::clone(source);
87        for related in self.related.iter_mut().filter(|r| r.file.is_empty()) {
88            related.file = Arc::clone(file);
89            related.source_text = Arc::clone(source);
90        }
91        self
92    }
93
94    pub fn message(&self) -> &str {
95        &self.message
96    }
97
98    pub fn span(&self) -> Range<usize> {
99        self.span.clone()
100    }
101
102    pub fn line(&self) -> usize {
103        self.line
104    }
105
106    /// Other locations this error points at, usually in another file.
107    pub fn related(&self) -> &[RelatedSpan] {
108        &self.related
109    }
110
111    /// The file this error belongs to. Errors from the `*_named`/`*_file`
112    /// entry points carry the real path; those from the path-less entry
113    /// points carry [`crate::ANONYMOUS_SCHEMA`]. Empty only for an error
114    /// constructed internally and never passed through [`Self::with_file`],
115    /// which no public entry point returns.
116    pub fn file(&self) -> &str {
117        &self.file
118    }
119
120    /// Render this error as a human-readable diagnostic.
121    ///
122    /// Takes no arguments: the error already knows both its file (`self.file`)
123    /// and that file's source (`self.source_text`), attached once at the
124    /// parse/diagnostics boundary. Before cratestack#916 this took a `(path,
125    /// source)` pair supplied by the caller, which had to happen to match the
126    /// file the error actually came from — nothing enforced that once more
127    /// than one file was involved.
128    ///
129    /// The code frame quotes the source with each control or invisible
130    /// character replaced by one visible stand-in (`␇`, U+FFFD), so the
131    /// spans still line up and nothing the schema carries reaches the
132    /// terminal raw (`substitute_for_display`).
133    pub fn render(&self) -> String {
134        if !self.related.is_empty() {
135            return self.render_with_related();
136        }
137        let mut output = Vec::new();
138        let file = self.file.to_string();
139        let shown = substitute_for_display(&self.source_text);
140        let span = char_span(&self.source_text, &self.span);
141        Report::build(ReportKind::Error, (file.clone(), span.clone()))
142            .with_message(&self.message)
143            .with_label(
144                Label::new((file.clone(), span))
145                    .with_message(&self.message)
146                    .with_color(Color::Red),
147            )
148            .finish()
149            .write((file, Source::from(shown)), &mut output)
150            .expect("diagnostic rendering should succeed");
151
152        String::from_utf8(output).expect("ariadne should emit utf-8")
153    }
154}
155
156/// Converts a byte span into `source` to the character span ariadne counts
157/// in (cratestack#1157). This is the only place the unit changes: `span()`
158/// stays in bytes for slicing the source and for the JSON output, and
159/// `substitute_for_display` keeps the source's length in characters, so the
160/// converted span lands on the same place in the code frame.
161///
162/// Counts the characters that start before each byte offset, so an offset
163/// past the end of `source` clamps instead of panicking.
164pub(crate) fn char_span(source: &str, span: &Range<usize>) -> Range<usize> {
165    let chars_before = |byte: usize| {
166        source.as_bytes()[..byte.min(source.len())]
167            .iter()
168            .filter(|&&b| b & 0xC0 != 0x80)
169            .count()
170    };
171    chars_before(span.start)..chars_before(span.end)
172}
173
174pub(crate) fn span_error(message: impl Into<String>, span: SourceSpan) -> SchemaError {
175    SchemaError::new(message, span.start..span.end, span.line)
176}
177
178// Kept beside the renderer it pins, not in `lib.rs`, which is at the file-length ceiling.
179#[cfg(test)]
180#[path = "tests_render_char_offsets.rs"]
181mod tests_render_char_offsets;