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}