Skip to main content

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}