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};
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. `get` rather than direct indexing so a
165        // span landing off a char boundary degrades to a single caret
166        // instead of panicking inside a diagnostic renderer.
167        let width = src
168            .get(span.start..span.end)
169            .map_or(1, |covered| covered.chars().count());
170        let caret_line = format!("{gutter} | {run}", run = caret_run(line, col, width));
171
172        let summary = self.short_message();
173        format!(
174            "error: {summary}\n  at line {line_no}, column {col}\n{line_num_str} | {line}\n{caret_line}",
175        )
176    }
177
178    /// Short, one-line summary of the error kind — no source context.
179    pub fn short_message(&self) -> String {
180        match self {
181            Self::UnboundSymbol { name, .. } => format!("unbound symbol `{name}`"),
182            Self::ArityMismatch {
183                fn_name,
184                expected,
185                got,
186                ..
187            } => format!("`{fn_name}` expected {expected:?}, got {got}"),
188            Self::TypeMismatch { expected, got, .. } => {
189                format!("type mismatch: expected {expected}, got {got}")
190            }
191            Self::DivisionByZero { .. } => "division by zero".into(),
192            Self::NotCallable { value_kind, .. } => {
193                format!("value of type {value_kind} is not callable")
194            }
195            Self::BadSpecialForm { form, reason, .. } => {
196                format!("bad `{form}`: {reason}")
197            }
198            Self::NativeFn { name, reason, .. } => format!("in native `{name}`: {reason}"),
199            Self::Reader(e) => format!("reader: {e}"),
200            Self::Halted => "halted".into(),
201            Self::NotImplemented(what) => format!("not yet implemented: {what}"),
202            Self::User { value, .. } => format!("uncaught: {value}"),
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210
211    #[test]
212    fn render_slices_the_span_s_own_line_via_the_shared_slicer() {
213        // Replaces the deleted local `find_line`, which duplicated
214        // `tatara_lisp::line_at`. Pinned through `render` rather than by
215        // calling the slicer directly, so the assertion is about the
216        // artifact operators actually read.
217        let src = "aaa\nbbb\nccc";
218        let rendered = EvalError::unbound("bbb", Span::new(4, 7)).render(src);
219        assert!(
220            rendered.contains("2 | bbb"),
221            "line 2 must be the rendered snippet, got:\n{rendered}"
222        );
223        assert!(
224            !rendered.contains("aaa") && !rendered.contains("ccc"),
225            "only the span's own line may be rendered, got:\n{rendered}"
226        );
227    }
228
229    #[test]
230    fn render_mirrors_a_tab_indent_into_the_caret_pad() {
231        // BUG FIX PIN. Pre-lift this method padded with
232        // `" ".repeat(col - 1)`, so `\tfoo` rendered its underline after
233        // ONE space while the source line spent a whole tab-stop — the
234        // carets landed left of `foo` on every real terminal. Going
235        // through the shared `caret_run` mirrors the tab through.
236        let src = "\tfoo";
237        let rendered = EvalError::unbound("foo", Span::new(1, 4)).render(src);
238        assert!(
239            rendered.contains("\t^^^"),
240            "caret pad must mirror the source tab, got:\n{rendered:?}"
241        );
242        assert!(
243            !rendered.contains(" ^^^"),
244            "a space-padded run is the pre-lift drift, got:\n{rendered:?}"
245        );
246    }
247
248    #[test]
249    fn render_sizes_the_caret_run_in_chars_not_bytes() {
250        // BUG FIX PIN. `éé` is 2 chars but 4 bytes. Pre-lift the width
251        // was `span.end - span.start` (bytes) while the pad counted
252        // chars, so this drew FOUR carets under a two-column symbol.
253        let src = "(+ éé 1)";
254        let start = src.find("éé").expect("fixture contains the symbol");
255        let span = Span::new(start, start + "éé".len());
256        let rendered = EvalError::unbound("éé", span).render(src);
257        assert!(
258            rendered.contains("   ^^\n") || rendered.ends_with("   ^^"),
259            "two chars must draw exactly two carets, got:\n{rendered:?}"
260        );
261        assert!(
262            !rendered.contains("^^^"),
263            "a byte-sized run over-underlines multi-byte source, got:\n{rendered:?}"
264        );
265    }
266
267    #[test]
268    fn render_includes_line_col_and_caret() {
269        let err = EvalError::unbound("foo", Span::new(4, 7));
270        let src = "(+ x foo y)";
271        let rendered = err.render(src);
272        assert!(rendered.contains("unbound symbol `foo`"));
273        assert!(rendered.contains("line 1, column 5"));
274        assert!(rendered.contains("(+ x foo y)"));
275        assert!(rendered.contains("^^^"));
276    }
277
278    #[test]
279    fn render_without_span_falls_back_to_display() {
280        let err = EvalError::Halted;
281        assert!(!err.render("ignored").is_empty());
282    }
283
284    #[test]
285    fn render_synthetic_span_falls_back() {
286        let err = EvalError::unbound("x", Span::synthetic());
287        let rendered = err.render("some source");
288        // No source context when span is synthetic.
289        assert!(!rendered.contains("line"));
290    }
291
292    #[test]
293    fn short_message_for_each_variant() {
294        use crate::ffi::Arity;
295
296        assert!(EvalError::DivisionByZero {
297            at: Span::synthetic(),
298        }
299        .short_message()
300        .contains("division"));
301
302        assert!(EvalError::unbound("foo", Span::synthetic())
303            .short_message()
304            .contains("foo"));
305
306        assert!(EvalError::ArityMismatch {
307            fn_name: "+".into(),
308            expected: Arity::Exact(2),
309            got: 3,
310            at: Span::synthetic(),
311        }
312        .short_message()
313        .contains("got 3"));
314    }
315}