alba_core/error.rs
1//! [`CoreError`]: the single error type produced while evaluating a
2//! Beamfile's AST into a [`crate::Project`].
3
4use std::cell::Cell;
5
6use alba_syntax::Span;
7
8use crate::model::SourceId;
9
10/// The `SourceId` this crate's single-file loading (`load_str`) always
11/// assigns to its one file, and the id `loader::load_project` always
12/// assigns to the root Beamfile (the first file registered in a
13/// [`crate::loader::SourceMap`] is always id 0).
14pub(crate) const ROOT_SOURCE_ID: SourceId = SourceId(0);
15
16thread_local! {
17 /// The `SourceId` of whichever file is currently being evaluated on
18 /// this thread. [`CoreError::new`] and `Beam`'s construction
19 /// (`current_source_id`) both read this instead of taking an explicit
20 /// parameter, so that stamping the right id is a change made in one
21 /// place ([`SourceIdScope::enter`]) rather than a parameter threaded
22 /// through every one of `eval.rs`'s error-construction sites.
23 ///
24 /// Only ever set by a [`SourceIdScope`], which is `pub(crate)` — so
25 /// outside this crate (in particular, the future engine calling the
26 /// publicly re-exported [`crate::eval_expr`]/[`crate::render_template`]
27 /// at schedule time, after loading has finished) this defaults to
28 /// [`ROOT_SOURCE_ID`] regardless of which file the value being
29 /// rendered actually came from. A schedule-time caller that knows the
30 /// real answer (typically `Beam::source`) must correct it explicitly
31 /// with [`CoreError::with_source_id`] — see that method's doc comment.
32 static CURRENT_SOURCE: Cell<SourceId> = const { Cell::new(ROOT_SOURCE_ID) };
33}
34
35/// The `SourceId` every [`CoreError`] and `Beam` built right now will be
36/// stamped with. See [`SourceIdScope`].
37pub(crate) fn current_source_id() -> SourceId {
38 CURRENT_SOURCE.with(Cell::get)
39}
40
41/// RAII guard that sets this thread's "currently loading" [`SourceId`] to
42/// `id` for its lifetime, restoring the previous value when it drops
43/// (including on an early return via `?`, or a panic unwind).
44///
45/// The loader holds one of these per file it evaluates. Because imports
46/// are loaded depth-first and a recursive call's guard only drops once
47/// that file (and everything it imports) has been fully evaluated, nested
48/// guards naturally restore the importing file's id afterward — plain
49/// stack discipline, no separate stack type needed. `load_str`'s
50/// single-file front door also enters one (pinned to [`ROOT_SOURCE_ID`])
51/// so its behavior doesn't depend on whatever a previous call on the same
52/// thread left behind.
53pub(crate) struct SourceIdScope {
54 previous: SourceId,
55}
56
57impl SourceIdScope {
58 pub(crate) fn enter(id: SourceId) -> Self {
59 let previous = CURRENT_SOURCE.with(|cell| cell.replace(id));
60 Self { previous }
61 }
62}
63
64impl Drop for SourceIdScope {
65 fn drop(&mut self) {
66 CURRENT_SOURCE.with(|cell| cell.set(self.previous));
67 }
68}
69
70/// An error produced while evaluating a Beamfile: an unknown variable, a
71/// type mismatch, an unresolvable built-in call, and so on. Carries the
72/// span of the offending source text (and, when a nearby valid name
73/// exists, help text) so it can be rendered the same way
74/// `alba_syntax::Diagnostic` renders parse errors.
75///
76/// `span` is `None` for the rare failure that is *about* the Beamfile
77/// without being *in* it — a beam named by whoever asked for the run,
78/// which has no declaration site. Underlining an arbitrary declaration in
79/// that case asserts a source position that is not where the mistake is.
80#[derive(Debug, Clone, PartialEq, thiserror::Error)]
81#[error("{message}")]
82pub struct CoreError {
83 pub message: String,
84 pub span: Option<Span>,
85 pub help: Option<String>,
86 pub source_id: SourceId,
87}
88
89impl CoreError {
90 /// A new error with no help text, pointing at `span` and stamped with
91 /// whichever file is currently being loaded (see [`SourceIdScope`]).
92 /// The one place every error-construction site in this crate goes
93 /// through, instead of repeating the `CoreError { .. }` literal
94 /// (including its `source_id`) at each of them.
95 pub(crate) fn new(message: impl Into<String>, span: Span) -> Self {
96 Self {
97 message: message.into(),
98 span: Some(span),
99 help: None,
100 source_id: current_source_id(),
101 }
102 }
103
104 /// A new error with nowhere in the source to point at — see the
105 /// struct's doc comment.
106 pub(crate) fn unlocated(message: impl Into<String>) -> Self {
107 Self {
108 message: message.into(),
109 span: None,
110 help: None,
111 source_id: current_source_id(),
112 }
113 }
114
115 /// Attaches help text (chainable with [`CoreError::new`]).
116 pub(crate) fn with_help(mut self, help: impl Into<String>) -> Self {
117 self.help = Some(help.into());
118 self
119 }
120
121 /// Overrides this error's `source_id`. Public (unlike [`CoreError::new`]
122 /// and [`CoreError::with_help`]) because it's meant for callers outside
123 /// this crate: [`crate::eval_expr`] and [`crate::render_template`] are
124 /// re-exported so the engine can render a beam's `run`/`env` templates
125 /// at schedule time, but by then loading has finished and there is no
126 /// active [`SourceIdScope`] (that type is `pub(crate)`-only, scoped to
127 /// this crate's own loading) — every error they build defaults to
128 /// [`ROOT_SOURCE_ID`] regardless of which file the beam actually came
129 /// from. A schedule-time caller that has the beam on hand should
130 /// correct that explicitly, e.g. `render_template(&tpl,
131 /// &scope).map_err(|e| e.with_source_id(beam.source))`, rather than
132 /// reaching into the (also-public) `source_id` field directly — this
133 /// method is the one, documented, intentional place that's meant to
134 /// happen, as opposed to an ad hoc patch scattered at whichever call
135 /// site happens to need it.
136 pub fn with_source_id(mut self, source_id: SourceId) -> Self {
137 self.source_id = source_id;
138 self
139 }
140
141 /// Converts this error into a renderable [`alba_syntax::Diagnostic`],
142 /// mirroring [`alba_syntax::ParseError::into_diagnostic`]. `source_id`
143 /// is dropped here — a caller (the CLI) resolves it against a
144 /// [`crate::loader::SourceMap`] to find the path and source text
145 /// `alba_syntax::render_diagnostic` needs, which this crate has no
146 /// access to (a `CoreError` alone, without the `SourceMap` it was
147 /// produced alongside, is not enough to render anything).
148 pub fn into_diagnostic(self) -> alba_syntax::Diagnostic {
149 alba_syntax::Diagnostic::new(self.message, self.span, self.help)
150 }
151}
152
153#[cfg(test)]
154mod tests {
155 use super::*;
156 use alba_syntax::render_diagnostic;
157
158 /// Round-trips a `CoreError` through `into_diagnostic` and
159 /// `render_diagnostic`, the same way `ParseError::into_diagnostic` is
160 /// exercised in `alba-syntax`. `Diagnostic`'s fields are private, so
161 /// rendering is the only way to observe that the message, span, and
162 /// help text actually made it across.
163 #[test]
164 fn into_diagnostic_preserves_message_span_and_help() {
165 let err = CoreError::new("boom", Span::new(2, 6)).with_help("try this");
166
167 let rendered = render_diagnostic("xx boom xx", "Beamfile", &err.into_diagnostic());
168
169 assert!(rendered.contains("boom"));
170 assert!(rendered.contains("try this"));
171 }
172
173 /// An error with nothing in the source to point at keeps its message
174 /// and its help, and renders no caret.
175 #[test]
176 fn an_unlocated_error_renders_without_a_source_position() {
177 let err = CoreError::unlocated("boom").with_help("try this");
178
179 let rendered = render_diagnostic("xx boom xx", "Beamfile", &err.into_diagnostic());
180
181 assert!(rendered.contains("boom"));
182 assert!(rendered.contains("try this"));
183 assert!(!rendered.contains("Beamfile:"));
184 }
185}