Skip to main content

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}