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;