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