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