Skip to main content

tatara_lisp_eval/
error.rs

1//! Runtime evaluator errors.
2//!
3//! Every variant carries a `Span` pointing back to the offending source
4//! subform (or `Span::synthetic()` when the error originated in macro-
5//! generated code or native fn). No panics from the evaluator itself —
6//! panics from registered native fns are caught at the FFI boundary and
7//! surfaced here as `EvalError::NativeFn`.
8
9use std::sync::Arc;
10
11use tatara_lisp::{caret_run, line_at, span_width_chars, Span};
12use thiserror::Error;
13
14use crate::env::Seal;
15use crate::ffi::Arity;
16
17pub type Result<T> = std::result::Result<T, EvalError>;
18
19#[derive(Debug, Error)]
20pub enum EvalError {
21    #[error("unbound symbol: {name} at {at}")]
22    UnboundSymbol { name: Arc<str>, at: Span },
23
24    #[error("arity mismatch in {fn_name}: expected {expected:?}, got {got} at {at}")]
25    ArityMismatch {
26        fn_name: Arc<str>,
27        expected: Arity,
28        got: usize,
29        at: Span,
30    },
31
32    #[error("type mismatch: expected {expected}, got {got} at {at}")]
33    TypeMismatch {
34        expected: &'static str,
35        got: &'static str,
36        at: Span,
37    },
38
39    #[error("division by zero at {at}")]
40    DivisionByZero { at: Span },
41
42    /// An integer operation whose exact result does not fit `i64`.
43    ///
44    /// Before this existed the same operation wrapped in a release build
45    /// (`9223372036854775807 * 2` was `-2`) and panicked in a debug one, so
46    /// the answer depended on the build profile. Overflow is an error by
47    /// default; a program that wants two's-complement wrapping asks for it
48    /// by name (`wrapping-add`, `wrapping-sub`, `wrapping-mul`,
49    /// `wrapping-neg`).
50    #[error("integer overflow in `{op}` at {at}")]
51    IntegerOverflow { op: &'static str, at: Span },
52
53    /// A macro rewrite chain exceeded its ceiling.
54    ///
55    /// Names the macro that was rewriting, because that is the one to look at.
56    /// Before this existed the same condition was `fatal runtime error: stack
57    /// overflow, aborting` — uncatchable, and at build time.
58    #[error("macro `{macro_name}` exceeded the expansion limit of {limit} rewrites at {at} — it likely expands to a call to itself")]
59    MacroExpansionLimit {
60        macro_name: Arc<str>,
61        limit: usize,
62        at: Span,
63    },
64
65    #[error("not callable: value of type {value_kind} at {at}")]
66    NotCallable { value_kind: &'static str, at: Span },
67
68    /// A `set!` reached a binding in a frame sealed against writes.
69    ///
70    /// Distinct from `UnboundSymbol`: the name *is* bound, and the write was
71    /// refused. Carrying the [`Seal`] rather than a prebuilt sentence is
72    /// deliberate — two callers seal for different reasons, and the wording
73    /// hard-coded for the first (macro expansion) actively misdirects a reader
74    /// who hit the second.
75    #[error("cannot `set!` `{name}`: {} at {at}", seal.refusal())]
76    SetSealed {
77        name: Arc<str>,
78        seal: Seal,
79        at: Span,
80    },
81
82    #[error("bad special form `{form}`: {reason} at {at}")]
83    BadSpecialForm {
84        form: Arc<str>,
85        reason: String,
86        at: Span,
87    },
88
89    #[error("in native fn {name}: {reason} at {at}")]
90    NativeFn {
91        name: Arc<str>,
92        reason: String,
93        at: Span,
94    },
95
96    #[error("reader error: {0}")]
97    Reader(#[from] tatara_lisp::LispError),
98
99    #[error("halted (host-initiated interrupt)")]
100    Halted,
101
102    /// A run exceeded one dimension of its [`crate::vm::Budget`].
103    ///
104    /// Before this existed, exceeding the depth the tree-walker could hold was
105    /// `fatal runtime error: stack overflow, aborting` — rc 134 at 4-6k frames,
106    /// with no `catch` able to see it because the OS process was already gone —
107    /// and a loop with no exit ran until something outside killed it.
108    ///
109    /// A `catch` observes it like any runtime error (tag `depth-exceeded` or
110    /// `fuel-exhausted`). The two differ in what that buys: a depth refusal has
111    /// unwound the frames it counted, so the handler runs with room to spare;
112    /// exhausted fuel stays exhausted, so the handler's own first step refuses
113    /// again and the error leaves the `try`. A budget cannot be spent past by
114    /// catching it.
115    #[error("{} budget of {limit} exceeded{} at {at}", dimension.noun(), in_function(function.as_deref()))]
116    BudgetExceeded {
117        dimension: BudgetDimension,
118        limit: usize,
119        /// The innermost named function running when the bound fired, when
120        /// one was.
121        function: Option<Arc<str>>,
122        at: Span,
123    },
124
125    #[error("not yet implemented: {0} (Phase 2.3+)")]
126    NotImplemented(&'static str),
127
128    /// A Lisp-side error raised via `(throw ...)`. Caught by
129    /// `(try ... (catch (e) ...))`. The carried `Value` is whatever
130    /// the user threw — conventionally a `Value::Error` produced by
131    /// `(error ...)` / `(ex-info ...)`, but any Value is allowed.
132    #[error("user error: {value}")]
133    User {
134        value: crate::value::Value,
135        at: Span,
136    },
137}
138
139/// Which dimension of a [`crate::vm::Budget`] a run exceeded.
140///
141/// Fuel bounds TIME and depth bounds SPACE; neither implies the other, so a
142/// refusal names which one fired.
143#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum BudgetDimension {
145    /// Nested (non-tail) closure calls alive at once. Tail calls do not count:
146    /// the trampoline reuses the frame.
147    Depth,
148    /// Evaluation steps since the budget was last set.
149    Fuel,
150}
151
152impl BudgetDimension {
153    /// The `catch`-visible tag.
154    #[must_use]
155    pub const fn tag(self) -> &'static str {
156        match self {
157            Self::Depth => "depth-exceeded",
158            Self::Fuel => "fuel-exhausted",
159        }
160    }
161
162    /// What the budget bounds, as the error message names it.
163    #[must_use]
164    pub const fn noun(self) -> &'static str {
165        match self {
166            Self::Depth => "call depth",
167            Self::Fuel => "fuel",
168        }
169    }
170}
171
172fn in_function(function: Option<&str>) -> String {
173    function.map_or_else(String::new, |f| format!(" in `{f}`"))
174}
175
176impl EvalError {
177    /// Name the function a budget refusal fired in, unless an inner frame
178    /// already did. Every other error passes through untouched.
179    #[must_use]
180    pub(crate) fn in_function(self, name: Option<&Arc<str>>) -> Self {
181        match (self, name) {
182            (
183                Self::BudgetExceeded {
184                    dimension,
185                    limit,
186                    function: None,
187                    at,
188                },
189                Some(n),
190            ) => Self::BudgetExceeded {
191                dimension,
192                limit,
193                function: Some(Arc::clone(n)),
194                at,
195            },
196            (other, _) => other,
197        }
198    }
199
200    pub fn unbound(name: impl Into<Arc<str>>, at: Span) -> Self {
201        Self::UnboundSymbol {
202            name: name.into(),
203            at,
204        }
205    }
206
207    pub fn type_mismatch(expected: &'static str, got: &'static str, at: Span) -> Self {
208        Self::TypeMismatch { expected, got, at }
209    }
210
211    pub fn native_fn(name: impl Into<Arc<str>>, reason: impl Into<String>, at: Span) -> Self {
212        Self::NativeFn {
213            name: name.into(),
214            reason: reason.into(),
215            at,
216        }
217    }
218
219    pub fn bad_form(form: impl Into<Arc<str>>, reason: impl Into<String>, at: Span) -> Self {
220        Self::BadSpecialForm {
221            form: form.into(),
222            reason: reason.into(),
223            at,
224        }
225    }
226
227    /// The Lisp-visible tag this error surfaces as when a `(catch ...)`
228    /// handler observes it.
229    ///
230    /// One table. It used to be two — the tree-walker and the VM each carried
231    /// a copy, so `EvalError` had three edit sites per variant and no compiler
232    /// pressure keeping the two tags equal. A `catch` that matched under one
233    /// executor and not the other would have been silent.
234    #[must_use]
235    pub const fn tag(&self) -> &'static str {
236        match self {
237            Self::UnboundSymbol { .. } => "unbound-symbol",
238            Self::ArityMismatch { .. } => "arity-mismatch",
239            Self::TypeMismatch { .. } => "type-mismatch",
240            Self::DivisionByZero { .. } => "division-by-zero",
241            Self::IntegerOverflow { .. } => "integer-overflow",
242            Self::MacroExpansionLimit { .. } => "macro-expansion-limit",
243            Self::NotCallable { .. } => "not-callable",
244            Self::BadSpecialForm { .. } => "bad-special-form",
245            Self::NativeFn { .. } => "native-fn",
246            Self::SetSealed { .. } => "set-sealed",
247            Self::Reader(_) => "reader",
248            Self::Halted => "halted",
249            Self::BudgetExceeded { dimension, .. } => dimension.tag(),
250            Self::NotImplemented(_) => "not-implemented",
251            Self::User { .. } => "user",
252        }
253    }
254
255    /// The span this error is attached to, if any.
256    pub fn span(&self) -> Option<Span> {
257        match self {
258            Self::UnboundSymbol { at, .. }
259            | Self::ArityMismatch { at, .. }
260            | Self::TypeMismatch { at, .. }
261            | Self::MacroExpansionLimit { at, .. }
262            | Self::DivisionByZero { at }
263            | Self::IntegerOverflow { at, .. }
264            | Self::NotCallable { at, .. }
265            | Self::BadSpecialForm { at, .. }
266            | Self::NativeFn { at, .. }
267            | Self::SetSealed { at, .. }
268            | Self::BudgetExceeded { at, .. }
269            | Self::User { at, .. } => Some(*at),
270            Self::Reader(_) | Self::Halted | Self::NotImplemented(_) => None,
271        }
272    }
273
274    /// Render this error with source context — finds the line containing
275    /// the error's span in `src`, prints that line, and underlines the
276    /// span with `^` markers. Produces a multi-line string suitable for
277    /// CLI / REPL output.
278    ///
279    /// If the error has no span, or its span is synthetic, renders just
280    /// the error message without source context.
281    ///
282    /// The caret line comes from [`tatara_lisp::caret_run`] — the fleet's
283    /// ONE caret renderer — rather than the hand-rolled pad this method
284    /// used to carry. That lift fixed two bugs the local copy had drifted
285    /// into, both of them mis-placing the underline relative to the span
286    /// it names:
287    ///
288    /// * it padded with `" ".repeat(col - 1)`, so a tab-indented source
289    ///   line put one space where the source spent a whole tab-stop and
290    ///   the underline slid left of its span;
291    /// * it sized the underline as `span.end - span.start`, a BYTE count,
292    ///   while padding to a CHAR column — so a multi-byte subform drew
293    ///   more carets than it occupies columns.
294    ///
295    /// `pending-caret-multiline`: a span covering more than one line still
296    /// draws its full char-width of carets under the single line rendered
297    /// above it, overflowing that line's end. Both pre-lift copies did
298    /// this and the shared renderer preserves it deliberately — clamping
299    /// the run to the rendered line is a real output change for every
300    /// whole-form span (which is most of them), so it wants its own
301    /// measured pass rather than riding along inside this consolidation.
302    pub fn render(&self, src: &str) -> String {
303        let Some(span) = self.span() else {
304            return self.to_string();
305        };
306        if span.is_synthetic() || span.end > src.len() {
307            return self.to_string();
308        }
309
310        let (line_no, col) = Span::line_col(src, span.start);
311        let line = line_at(src, span.start);
312        let line_num_str = format!("{line_no}");
313        let gutter = " ".repeat(line_num_str.len());
314
315        // CHARS, not bytes — `caret_run` pads to a char column, so the
316        // width must be counted in the same unit or the two disagree on
317        // any non-ASCII source. That conversion is
318        // [`tatara_lisp::span_width_chars`], the same one
319        // `format_diagnostic` goes through, rather than a second copy of
320        // the expression here: two hand-rolls of a unit conversion are
321        // exactly how the byte-vs-char drift this comment describes got
322        // in. It also carries the off-char-boundary degrade-to-1 guard,
323        // so a diagnostic renderer never panics.
324        let caret_line = format!(
325            "{gutter} | {run}",
326            run = caret_run(line, col, span_width_chars(src, span))
327        );
328
329        let summary = self.short_message();
330        format!(
331            "error: {summary}\n  at line {line_no}, column {col}\n{line_num_str} | {line}\n{caret_line}",
332        )
333    }
334
335    /// Short, one-line summary of the error kind — no source context.
336    pub fn short_message(&self) -> String {
337        match self {
338            Self::UnboundSymbol { name, .. } => format!("unbound symbol `{name}`"),
339            Self::SetSealed { name, seal, .. } => {
340                format!("`set!` of sealed binding `{name}` ({seal:?})")
341            }
342            Self::MacroExpansionLimit {
343                macro_name, limit, ..
344            } => format!("macro `{macro_name}` exceeded {limit} expansion steps"),
345            Self::ArityMismatch {
346                fn_name,
347                expected,
348                got,
349                ..
350            } => format!("`{fn_name}` expected {expected:?}, got {got}"),
351            Self::TypeMismatch { expected, got, .. } => {
352                format!("type mismatch: expected {expected}, got {got}")
353            }
354            Self::DivisionByZero { .. } => "division by zero".into(),
355            Self::IntegerOverflow { op, .. } => format!("integer overflow in `{op}`"),
356            Self::NotCallable { value_kind, .. } => {
357                format!("value of type {value_kind} is not callable")
358            }
359            Self::BadSpecialForm { form, reason, .. } => {
360                format!("bad `{form}`: {reason}")
361            }
362            Self::NativeFn { name, reason, .. } => format!("in native `{name}`: {reason}"),
363            Self::Reader(e) => format!("reader: {e}"),
364            Self::Halted => "halted".into(),
365            Self::BudgetExceeded {
366                dimension,
367                limit,
368                function,
369                ..
370            } => format!(
371                "{} budget of {limit} exceeded{}",
372                dimension.noun(),
373                in_function(function.as_deref())
374            ),
375            Self::NotImplemented(what) => format!("not yet implemented: {what}"),
376            Self::User { value, .. } => format!("uncaught: {value}"),
377        }
378    }
379}
380
381#[cfg(test)]
382mod tests {
383    use super::*;
384
385    #[test]
386    fn render_slices_the_span_s_own_line_via_the_shared_slicer() {
387        // Replaces the deleted local `find_line`, which duplicated
388        // `tatara_lisp::line_at`. Pinned through `render` rather than by
389        // calling the slicer directly, so the assertion is about the
390        // artifact operators actually read.
391        let src = "aaa\nbbb\nccc";
392        let rendered = EvalError::unbound("bbb", Span::new(4, 7)).render(src);
393        assert!(
394            rendered.contains("2 | bbb"),
395            "line 2 must be the rendered snippet, got:\n{rendered}"
396        );
397        assert!(
398            !rendered.contains("aaa") && !rendered.contains("ccc"),
399            "only the span's own line may be rendered, got:\n{rendered}"
400        );
401    }
402
403    #[test]
404    fn render_mirrors_a_tab_indent_into_the_caret_pad() {
405        // BUG FIX PIN. Pre-lift this method padded with
406        // `" ".repeat(col - 1)`, so `\tfoo` rendered its underline after
407        // ONE space while the source line spent a whole tab-stop — the
408        // carets landed left of `foo` on every real terminal. Going
409        // through the shared `caret_run` mirrors the tab through.
410        let src = "\tfoo";
411        let rendered = EvalError::unbound("foo", Span::new(1, 4)).render(src);
412        assert!(
413            rendered.contains("\t^^^"),
414            "caret pad must mirror the source tab, got:\n{rendered:?}"
415        );
416        assert!(
417            !rendered.contains(" ^^^"),
418            "a space-padded run is the pre-lift drift, got:\n{rendered:?}"
419        );
420    }
421
422    #[test]
423    fn render_sizes_the_caret_run_in_chars_not_bytes() {
424        // BUG FIX PIN. `éé` is 2 chars but 4 bytes. Pre-lift the width
425        // was `span.end - span.start` (bytes) while the pad counted
426        // chars, so this drew FOUR carets under a two-column symbol.
427        let src = "(+ éé 1)";
428        let start = src.find("éé").expect("fixture contains the symbol");
429        let span = Span::new(start, start + "éé".len());
430        let rendered = EvalError::unbound("éé", span).render(src);
431        assert!(
432            rendered.contains("   ^^\n") || rendered.ends_with("   ^^"),
433            "two chars must draw exactly two carets, got:\n{rendered:?}"
434        );
435        assert!(
436            !rendered.contains("^^^"),
437            "a byte-sized run over-underlines multi-byte source, got:\n{rendered:?}"
438        );
439    }
440
441    #[test]
442    fn render_includes_line_col_and_caret() {
443        let err = EvalError::unbound("foo", Span::new(4, 7));
444        let src = "(+ x foo y)";
445        let rendered = err.render(src);
446        assert!(rendered.contains("unbound symbol `foo`"));
447        assert!(rendered.contains("line 1, column 5"));
448        assert!(rendered.contains("(+ x foo y)"));
449        assert!(rendered.contains("^^^"));
450    }
451
452    #[test]
453    fn render_without_span_falls_back_to_display() {
454        let err = EvalError::Halted;
455        assert!(!err.render("ignored").is_empty());
456    }
457
458    #[test]
459    fn render_synthetic_span_falls_back() {
460        let err = EvalError::unbound("x", Span::synthetic());
461        let rendered = err.render("some source");
462        // No source context when span is synthetic.
463        assert!(!rendered.contains("line"));
464    }
465
466    #[test]
467    fn short_message_for_each_variant() {
468        use crate::ffi::Arity;
469
470        assert!(EvalError::DivisionByZero {
471            at: Span::synthetic(),
472        }
473        .short_message()
474        .contains("division"));
475
476        assert!(EvalError::unbound("foo", Span::synthetic())
477            .short_message()
478            .contains("foo"));
479
480        assert!(EvalError::ArityMismatch {
481            fn_name: "+".into(),
482            expected: Arity::Exact(2),
483            got: 3,
484            at: Span::synthetic(),
485        }
486        .short_message()
487        .contains("got 3"));
488    }
489}