bop/error.rs
1//! Error type for the Bop interpreter.
2
3#[cfg(feature = "no_std")]
4use alloc::{format, string::String};
5
6#[derive(Debug, Clone)]
7pub struct BopError {
8 pub line: Option<u32>,
9 pub column: Option<u32>,
10 pub message: String,
11 pub friendly_hint: Option<String>,
12 /// Fatal errors can't be caught by `try_call`; they always
13 /// unwind to the engine boundary. This is the load-bearing
14 /// property that makes `BopLimits` a real sandbox — a
15 /// script can't wrap an infinite loop in `try_call` and
16 /// loop forever by swallowing the step-limit error.
17 ///
18 /// Non-fatal errors (the default) describe ordinary runtime
19 /// problems — type mismatches, missing fields, index out of
20 /// bounds, "function not found". Those can be caught.
21 ///
22 /// Currently set only on resource-limit errors
23 /// (`Your code took too many steps`, `Memory limit
24 /// exceeded`). Any new fatal case must explicitly construct
25 /// `BopError::fatal` rather than `BopError::runtime`.
26 pub is_fatal: bool,
27 /// True only for the sentinel error the walker uses to
28 /// unwind a `try`-driven early-return out of an enclosing
29 /// fn. When set, the `message` / `line` / `friendly_hint`
30 /// fields are unused — the return value lives on the
31 /// evaluator's `pending_try_return` slot. `call_bop_fn`
32 /// traps errors with this flag and converts them into a
33 /// normal `Signal::Return`.
34 ///
35 /// Always `false` outside that narrow window. Users and
36 /// host code should never construct a `BopError` with this
37 /// flag set; use [`BopError::runtime`] / [`BopError::fatal`]
38 /// for real errors.
39 ///
40 /// Replaces the older `"__bop_try_return_signal__"` message
41 /// sentinel — a field lookup is cheaper than a string
42 /// compare, and a flag can never collide with a user
43 /// message that happens to spell the same bytes.
44 pub is_try_return: bool,
45}
46
47impl BopError {
48 /// Create a runtime error at the given source line.
49 pub fn runtime(message: impl Into<String>, line: u32) -> Self {
50 Self {
51 line: Some(line),
52 column: None,
53 message: message.into(),
54 friendly_hint: None,
55 is_fatal: false,
56 is_try_return: false,
57 }
58 }
59
60 /// Create a runtime error at the given line *and* column.
61 /// Callers that have an AST node handy (`expr.line`,
62 /// `expr.column`) should prefer this over
63 /// [`Self::runtime`] so the error renderer can point a
64 /// carat at the offending character.
65 pub fn runtime_at(
66 message: impl Into<String>,
67 line: u32,
68 column: Option<core::num::NonZeroU32>,
69 ) -> Self {
70 Self {
71 line: Some(line),
72 column: column.map(|c| c.get()),
73 message: message.into(),
74 friendly_hint: None,
75 is_fatal: false,
76 is_try_return: false,
77 }
78 }
79
80 /// Create a **fatal** runtime error at the given source line.
81 /// Used for resource-limit violations (`too many steps`,
82 /// `Memory limit exceeded`) — see [`BopError::is_fatal`]
83 /// for why those must never be swallowed by `try_call`.
84 pub fn fatal(message: impl Into<String>, line: u32) -> Self {
85 Self {
86 line: Some(line),
87 column: None,
88 message: message.into(),
89 friendly_hint: None,
90 is_fatal: true,
91 is_try_return: false,
92 }
93 }
94
95 /// Build the sentinel error the walker uses to unwind a
96 /// `try`-driven early-return. Private to the crate because
97 /// no one outside the walker's fn-call boundary should be
98 /// constructing one of these — they'd leak a "phantom"
99 /// error to user code. The return value itself travels on
100 /// the evaluator's `pending_try_return` slot (see
101 /// `Evaluator::eval_try`).
102 pub(crate) fn try_return_signal(line: u32) -> Self {
103 Self {
104 line: Some(line),
105 column: None,
106 message: String::new(),
107 friendly_hint: None,
108 is_fatal: false,
109 is_try_return: true,
110 }
111 }
112}
113
114impl core::fmt::Display for BopError {
115 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
116 if let Some(line) = self.line {
117 write!(f, "[line {}] {}", line, self.message)
118 } else {
119 write!(f, "{}", self.message)
120 }
121 }
122}
123
124impl BopError {
125 /// Render the error with an inline source snippet and a
126 /// `^` carat under the offending position. Needs the full
127 /// program source that produced the error — pass the same
128 /// string you handed to `bop::run` / `bop::parse`.
129 ///
130 /// Falls back gracefully:
131 /// - No line set → just the message.
132 /// - Line set but out of range (e.g. source was truncated)
133 /// → message + "[line N]" without the snippet.
134 /// - No column set → message + snippet, no carat.
135 /// - Column set → message + snippet + carat.
136 ///
137 /// Appends the `friendly_hint` as a `hint:` line when
138 /// present. Used by `bop-cli` to render program failures;
139 /// embedders can call it from their own error path.
140 pub fn render(&self, source: &str) -> String {
141 let mut out = String::new();
142 match self.line {
143 Some(line) if line > 0 => {
144 out.push_str(&format!("error: {}\n", self.message));
145 let line_str = format!(" --> line {}", line);
146 if let Some(col) = self.column {
147 out.push_str(&format!("{}:{}\n", line_str, col));
148 } else {
149 out.push_str(&format!("{}\n", line_str));
150 }
151 if let Some(src_line) = source.lines().nth((line - 1) as usize) {
152 let gutter_width = digits_of(line);
153 let gutter_pad = " ".repeat(gutter_width);
154 out.push_str(&format!("{} |\n", gutter_pad));
155 out.push_str(&format!("{} | {}\n", line, src_line));
156 out.push_str(&format!("{} | ", gutter_pad));
157 if let Some(col) = self.column {
158 // `column` is 1-indexed; characters up to
159 // `col - 1` get a padding space each.
160 let col_idx = col.saturating_sub(1) as usize;
161 let mut pad = String::new();
162 for (i, ch) in src_line.chars().enumerate() {
163 if i >= col_idx {
164 break;
165 }
166 // Preserve tab alignment so the carat
167 // lands under the right column even
168 // in tab-indented source.
169 pad.push(if ch == '\t' { '\t' } else { ' ' });
170 }
171 out.push_str(&pad);
172 out.push_str("^\n");
173 } else {
174 out.push('\n');
175 }
176 }
177 }
178 _ => {
179 out.push_str(&format!("error: {}\n", self.message));
180 }
181 }
182 if let Some(hint) = &self.friendly_hint {
183 out.push_str(&format!("hint: {}\n", hint));
184 }
185 out
186 }
187}
188
189/// Count decimal digits in a positive integer — used for
190/// gutter width in `render`.
191fn digits_of(mut n: u32) -> usize {
192 let mut d = 0usize;
193 if n == 0 {
194 return 1;
195 }
196 while n > 0 {
197 d += 1;
198 n /= 10;
199 }
200 d
201}
202
203/// Non-fatal diagnostic surfaced by static checks that run
204/// after parsing (currently: match-exhaustiveness analysis in
205/// [`crate::check`]). Shape mirrors `BopError` so the same
206/// source-snippet rendering works; the only divergence is the
207/// leading header, which says `warning:` instead of `error:`.
208///
209/// Warnings never halt execution — they're informational. The
210/// CLI prints them and then runs the program anyway. Embedders
211/// that want to treat them as errors can call
212/// [`BopWarning::into_error`].
213#[derive(Debug, Clone)]
214pub struct BopWarning {
215 pub line: Option<u32>,
216 pub column: Option<u32>,
217 pub message: String,
218 pub friendly_hint: Option<String>,
219}
220
221impl BopWarning {
222 /// Convenience constructor that matches `BopError::runtime`'s
223 /// shape so check passes can build warnings at a single
224 /// source line.
225 pub fn at(message: impl Into<String>, line: u32) -> Self {
226 Self {
227 line: Some(line),
228 column: None,
229 message: message.into(),
230 friendly_hint: None,
231 }
232 }
233
234 /// Attach a "hint:" line to the rendered output. Chained
235 /// from the constructor so call sites stay tidy.
236 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
237 self.friendly_hint = Some(hint.into());
238 self
239 }
240
241 /// Promote the warning to a fatal [`BopError`] with the same
242 /// fields. Useful for `-Werror`-style embedders.
243 pub fn into_error(self) -> BopError {
244 BopError {
245 line: self.line,
246 column: self.column,
247 message: self.message,
248 friendly_hint: self.friendly_hint,
249 is_fatal: false,
250 is_try_return: false,
251 }
252 }
253
254 /// Render the warning with a source snippet. Mirrors
255 /// [`BopError::render`] but leads with `warning:` rather
256 /// than `error:`.
257 pub fn render(&self, source: &str) -> String {
258 let err = BopError {
259 line: self.line,
260 column: self.column,
261 message: self.message.clone(),
262 friendly_hint: self.friendly_hint.clone(),
263 is_fatal: false,
264 is_try_return: false,
265 };
266 // Swap the leading `error:` for `warning:` so the
267 // output is visually distinct. The rest of the carat /
268 // snippet logic is identical to `BopError::render`.
269 err.render(source).replacen("error:", "warning:", 1)
270 }
271}
272
273#[cfg(not(feature = "no_std"))]
274impl std::error::Error for BopError {}
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn runtime_error_sets_message_and_line() {
282 let err = BopError::runtime("boom", 7);
283
284 assert_eq!(err.message, "boom");
285 assert_eq!(err.line, Some(7));
286 assert_eq!(err.column, None);
287 assert_eq!(err.friendly_hint, None);
288 assert!(!err.is_fatal);
289 }
290
291 #[test]
292 fn fatal_error_marks_is_fatal_true() {
293 let err = BopError::fatal("step limit", 0);
294 assert!(err.is_fatal);
295 assert!(!BopError::runtime("nope", 0).is_fatal);
296 }
297
298 #[test]
299 fn render_without_source_falls_back_to_message_only() {
300 let err = BopError::runtime("boom", 0);
301 let rendered = err.render("");
302 assert!(rendered.contains("error: boom"));
303 }
304
305 #[test]
306 fn render_with_line_shows_snippet() {
307 let src = "let x = 1\nlet y = 2\nlet z = 3";
308 let err = BopError {
309 line: Some(2),
310 column: None,
311 message: "something broke".into(),
312 friendly_hint: None,
313 is_fatal: false,
314
315 is_try_return: false,
316 };
317 let rendered = err.render(src);
318 assert!(rendered.contains("error: something broke"));
319 assert!(rendered.contains("--> line 2"));
320 assert!(rendered.contains("let y = 2"));
321 }
322
323 #[test]
324 fn render_with_line_and_column_places_carat() {
325 let src = "let x = 1\nlet abc = foo()\nlet z = 3";
326 let err = BopError {
327 line: Some(2),
328 column: Some(11),
329 message: "undefined".into(),
330 friendly_hint: Some("did you mean `bar`?".into()),
331 is_fatal: false,
332
333 is_try_return: false,
334 };
335 let rendered = err.render(src);
336 assert!(rendered.contains("--> line 2:11"));
337 assert!(rendered.contains("let abc = foo()"));
338 // Carat at column 11 → 10 spaces of padding before `^`.
339 assert!(
340 rendered.contains(&format!("{}^", " ".repeat(10))),
341 "rendered:\n{}",
342 rendered
343 );
344 assert!(rendered.contains("hint: did you mean `bar`?"));
345 }
346
347 #[test]
348 fn render_handles_out_of_range_line_gracefully() {
349 let src = "let x = 1";
350 let err = BopError {
351 line: Some(99),
352 column: Some(3),
353 message: "off the end".into(),
354 friendly_hint: None,
355 is_fatal: false,
356
357 is_try_return: false,
358 };
359 // Shouldn't panic; just produces the header without a
360 // snippet.
361 let rendered = err.render(src);
362 assert!(rendered.contains("--> line 99:3"));
363 assert!(rendered.contains("error: off the end"));
364 }
365
366 #[test]
367 fn render_preserves_tab_alignment_in_carat() {
368 // Source has a leading tab. Carat padding should use a
369 // tab too so it lines up under the offending char.
370 let src = "\tlet x = bad_call()";
371 let err = BopError {
372 line: Some(1),
373 column: Some(10),
374 message: "undefined".into(),
375 friendly_hint: None,
376 is_fatal: false,
377
378 is_try_return: false,
379 };
380 let rendered = err.render(src);
381 // The carat line has one tab (from column 1's tab in
382 // source) plus 8 spaces for columns 2–9, then `^`.
383 assert!(rendered.contains("\t ^"), "rendered:\n{}", rendered);
384 }
385}