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    /// A macro rewrite chain exceeded its ceiling.
43    ///
44    /// Names the macro that was rewriting, because that is the one to look at.
45    /// Before this existed the same condition was `fatal runtime error: stack
46    /// overflow, aborting` — uncatchable, and at build time.
47    #[error("macro `{macro_name}` exceeded the expansion limit of {limit} rewrites at {at} — it likely expands to a call to itself")]
48    MacroExpansionLimit {
49        macro_name: Arc<str>,
50        limit: usize,
51        at: Span,
52    },
53
54    #[error("not callable: value of type {value_kind} at {at}")]
55    NotCallable { value_kind: &'static str, at: Span },
56
57    /// A `set!` reached a binding in a frame sealed against writes.
58    ///
59    /// Distinct from `UnboundSymbol`: the name *is* bound, and the write was
60    /// refused. Carrying the [`Seal`] rather than a prebuilt sentence is
61    /// deliberate — two callers seal for different reasons, and the wording
62    /// hard-coded for the first (macro expansion) actively misdirects a reader
63    /// who hit the second.
64    #[error("cannot `set!` `{name}`: {} at {at}", seal.refusal())]
65    SetSealed {
66        name: Arc<str>,
67        seal: Seal,
68        at: Span,
69    },
70
71    #[error("bad special form `{form}`: {reason} at {at}")]
72    BadSpecialForm {
73        form: Arc<str>,
74        reason: String,
75        at: Span,
76    },
77
78    #[error("in native fn {name}: {reason} at {at}")]
79    NativeFn {
80        name: Arc<str>,
81        reason: String,
82        at: Span,
83    },
84
85    #[error("reader error: {0}")]
86    Reader(#[from] tatara_lisp::LispError),
87
88    #[error("halted (host-initiated interrupt)")]
89    Halted,
90
91    #[error("not yet implemented: {0} (Phase 2.3+)")]
92    NotImplemented(&'static str),
93
94    /// A Lisp-side error raised via `(throw ...)`. Caught by
95    /// `(try ... (catch (e) ...))`. The carried `Value` is whatever
96    /// the user threw — conventionally a `Value::Error` produced by
97    /// `(error ...)` / `(ex-info ...)`, but any Value is allowed.
98    #[error("user error: {value}")]
99    User {
100        value: crate::value::Value,
101        at: Span,
102    },
103}
104
105impl EvalError {
106    pub fn unbound(name: impl Into<Arc<str>>, at: Span) -> Self {
107        Self::UnboundSymbol {
108            name: name.into(),
109            at,
110        }
111    }
112
113    pub fn type_mismatch(expected: &'static str, got: &'static str, at: Span) -> Self {
114        Self::TypeMismatch { expected, got, at }
115    }
116
117    pub fn native_fn(name: impl Into<Arc<str>>, reason: impl Into<String>, at: Span) -> Self {
118        Self::NativeFn {
119            name: name.into(),
120            reason: reason.into(),
121            at,
122        }
123    }
124
125    pub fn bad_form(form: impl Into<Arc<str>>, reason: impl Into<String>, at: Span) -> Self {
126        Self::BadSpecialForm {
127            form: form.into(),
128            reason: reason.into(),
129            at,
130        }
131    }
132
133    /// The Lisp-visible tag this error surfaces as when a `(catch ...)`
134    /// handler observes it.
135    ///
136    /// One table. It used to be two — the tree-walker and the VM each carried
137    /// a copy, so `EvalError` had three edit sites per variant and no compiler
138    /// pressure keeping the two tags equal. A `catch` that matched under one
139    /// executor and not the other would have been silent.
140    #[must_use]
141    pub const fn tag(&self) -> &'static str {
142        match self {
143            Self::UnboundSymbol { .. } => "unbound-symbol",
144            Self::ArityMismatch { .. } => "arity-mismatch",
145            Self::TypeMismatch { .. } => "type-mismatch",
146            Self::DivisionByZero { .. } => "division-by-zero",
147            Self::MacroExpansionLimit { .. } => "macro-expansion-limit",
148            Self::NotCallable { .. } => "not-callable",
149            Self::BadSpecialForm { .. } => "bad-special-form",
150            Self::NativeFn { .. } => "native-fn",
151            Self::SetSealed { .. } => "set-sealed",
152            Self::Reader(_) => "reader",
153            Self::Halted => "halted",
154            Self::NotImplemented(_) => "not-implemented",
155            Self::User { .. } => "user",
156        }
157    }
158
159    /// The span this error is attached to, if any.
160    pub fn span(&self) -> Option<Span> {
161        match self {
162            Self::UnboundSymbol { at, .. }
163            | Self::ArityMismatch { at, .. }
164            | Self::TypeMismatch { at, .. }
165            | Self::MacroExpansionLimit { at, .. }
166            | Self::DivisionByZero { at }
167            | Self::NotCallable { at, .. }
168            | Self::BadSpecialForm { at, .. }
169            | Self::NativeFn { at, .. }
170            | Self::SetSealed { at, .. }
171            | Self::User { at, .. } => Some(*at),
172            Self::Reader(_) | Self::Halted | Self::NotImplemented(_) => None,
173        }
174    }
175
176    /// Render this error with source context — finds the line containing
177    /// the error's span in `src`, prints that line, and underlines the
178    /// span with `^` markers. Produces a multi-line string suitable for
179    /// CLI / REPL output.
180    ///
181    /// If the error has no span, or its span is synthetic, renders just
182    /// the error message without source context.
183    ///
184    /// The caret line comes from [`tatara_lisp::caret_run`] — the fleet's
185    /// ONE caret renderer — rather than the hand-rolled pad this method
186    /// used to carry. That lift fixed two bugs the local copy had drifted
187    /// into, both of them mis-placing the underline relative to the span
188    /// it names:
189    ///
190    /// * it padded with `" ".repeat(col - 1)`, so a tab-indented source
191    ///   line put one space where the source spent a whole tab-stop and
192    ///   the underline slid left of its span;
193    /// * it sized the underline as `span.end - span.start`, a BYTE count,
194    ///   while padding to a CHAR column — so a multi-byte subform drew
195    ///   more carets than it occupies columns.
196    ///
197    /// `pending-caret-multiline`: a span covering more than one line still
198    /// draws its full char-width of carets under the single line rendered
199    /// above it, overflowing that line's end. Both pre-lift copies did
200    /// this and the shared renderer preserves it deliberately — clamping
201    /// the run to the rendered line is a real output change for every
202    /// whole-form span (which is most of them), so it wants its own
203    /// measured pass rather than riding along inside this consolidation.
204    pub fn render(&self, src: &str) -> String {
205        let Some(span) = self.span() else {
206            return self.to_string();
207        };
208        if span.is_synthetic() || span.end > src.len() {
209            return self.to_string();
210        }
211
212        let (line_no, col) = Span::line_col(src, span.start);
213        let line = line_at(src, span.start);
214        let line_num_str = format!("{line_no}");
215        let gutter = " ".repeat(line_num_str.len());
216
217        // CHARS, not bytes — `caret_run` pads to a char column, so the
218        // width must be counted in the same unit or the two disagree on
219        // any non-ASCII source. That conversion is
220        // [`tatara_lisp::span_width_chars`], the same one
221        // `format_diagnostic` goes through, rather than a second copy of
222        // the expression here: two hand-rolls of a unit conversion are
223        // exactly how the byte-vs-char drift this comment describes got
224        // in. It also carries the off-char-boundary degrade-to-1 guard,
225        // so a diagnostic renderer never panics.
226        let caret_line = format!(
227            "{gutter} | {run}",
228            run = caret_run(line, col, span_width_chars(src, span))
229        );
230
231        let summary = self.short_message();
232        format!(
233            "error: {summary}\n  at line {line_no}, column {col}\n{line_num_str} | {line}\n{caret_line}",
234        )
235    }
236
237    /// Short, one-line summary of the error kind — no source context.
238    pub fn short_message(&self) -> String {
239        match self {
240            Self::UnboundSymbol { name, .. } => format!("unbound symbol `{name}`"),
241            Self::SetSealed { name, seal, .. } => {
242                format!("`set!` of sealed binding `{name}` ({seal:?})")
243            }
244            Self::MacroExpansionLimit {
245                macro_name, limit, ..
246            } => format!("macro `{macro_name}` exceeded {limit} expansion steps"),
247            Self::ArityMismatch {
248                fn_name,
249                expected,
250                got,
251                ..
252            } => format!("`{fn_name}` expected {expected:?}, got {got}"),
253            Self::TypeMismatch { expected, got, .. } => {
254                format!("type mismatch: expected {expected}, got {got}")
255            }
256            Self::DivisionByZero { .. } => "division by zero".into(),
257            Self::NotCallable { value_kind, .. } => {
258                format!("value of type {value_kind} is not callable")
259            }
260            Self::BadSpecialForm { form, reason, .. } => {
261                format!("bad `{form}`: {reason}")
262            }
263            Self::NativeFn { name, reason, .. } => format!("in native `{name}`: {reason}"),
264            Self::Reader(e) => format!("reader: {e}"),
265            Self::Halted => "halted".into(),
266            Self::NotImplemented(what) => format!("not yet implemented: {what}"),
267            Self::User { value, .. } => format!("uncaught: {value}"),
268        }
269    }
270}
271
272#[cfg(test)]
273mod tests {
274    use super::*;
275
276    #[test]
277    fn render_slices_the_span_s_own_line_via_the_shared_slicer() {
278        // Replaces the deleted local `find_line`, which duplicated
279        // `tatara_lisp::line_at`. Pinned through `render` rather than by
280        // calling the slicer directly, so the assertion is about the
281        // artifact operators actually read.
282        let src = "aaa\nbbb\nccc";
283        let rendered = EvalError::unbound("bbb", Span::new(4, 7)).render(src);
284        assert!(
285            rendered.contains("2 | bbb"),
286            "line 2 must be the rendered snippet, got:\n{rendered}"
287        );
288        assert!(
289            !rendered.contains("aaa") && !rendered.contains("ccc"),
290            "only the span's own line may be rendered, got:\n{rendered}"
291        );
292    }
293
294    #[test]
295    fn render_mirrors_a_tab_indent_into_the_caret_pad() {
296        // BUG FIX PIN. Pre-lift this method padded with
297        // `" ".repeat(col - 1)`, so `\tfoo` rendered its underline after
298        // ONE space while the source line spent a whole tab-stop — the
299        // carets landed left of `foo` on every real terminal. Going
300        // through the shared `caret_run` mirrors the tab through.
301        let src = "\tfoo";
302        let rendered = EvalError::unbound("foo", Span::new(1, 4)).render(src);
303        assert!(
304            rendered.contains("\t^^^"),
305            "caret pad must mirror the source tab, got:\n{rendered:?}"
306        );
307        assert!(
308            !rendered.contains(" ^^^"),
309            "a space-padded run is the pre-lift drift, got:\n{rendered:?}"
310        );
311    }
312
313    #[test]
314    fn render_sizes_the_caret_run_in_chars_not_bytes() {
315        // BUG FIX PIN. `éé` is 2 chars but 4 bytes. Pre-lift the width
316        // was `span.end - span.start` (bytes) while the pad counted
317        // chars, so this drew FOUR carets under a two-column symbol.
318        let src = "(+ éé 1)";
319        let start = src.find("éé").expect("fixture contains the symbol");
320        let span = Span::new(start, start + "éé".len());
321        let rendered = EvalError::unbound("éé", span).render(src);
322        assert!(
323            rendered.contains("   ^^\n") || rendered.ends_with("   ^^"),
324            "two chars must draw exactly two carets, got:\n{rendered:?}"
325        );
326        assert!(
327            !rendered.contains("^^^"),
328            "a byte-sized run over-underlines multi-byte source, got:\n{rendered:?}"
329        );
330    }
331
332    #[test]
333    fn render_includes_line_col_and_caret() {
334        let err = EvalError::unbound("foo", Span::new(4, 7));
335        let src = "(+ x foo y)";
336        let rendered = err.render(src);
337        assert!(rendered.contains("unbound symbol `foo`"));
338        assert!(rendered.contains("line 1, column 5"));
339        assert!(rendered.contains("(+ x foo y)"));
340        assert!(rendered.contains("^^^"));
341    }
342
343    #[test]
344    fn render_without_span_falls_back_to_display() {
345        let err = EvalError::Halted;
346        assert!(!err.render("ignored").is_empty());
347    }
348
349    #[test]
350    fn render_synthetic_span_falls_back() {
351        let err = EvalError::unbound("x", Span::synthetic());
352        let rendered = err.render("some source");
353        // No source context when span is synthetic.
354        assert!(!rendered.contains("line"));
355    }
356
357    #[test]
358    fn short_message_for_each_variant() {
359        use crate::ffi::Arity;
360
361        assert!(EvalError::DivisionByZero {
362            at: Span::synthetic(),
363        }
364        .short_message()
365        .contains("division"));
366
367        assert!(EvalError::unbound("foo", Span::synthetic())
368            .short_message()
369            .contains("foo"));
370
371        assert!(EvalError::ArityMismatch {
372            fn_name: "+".into(),
373            expected: Arity::Exact(2),
374            got: 3,
375            at: Span::synthetic(),
376        }
377        .short_message()
378        .contains("got 3"));
379    }
380}