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::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    pub fn render(&self, src: &str) -> String {
129        let Some(span) = self.span() else {
130            return self.to_string();
131        };
132        if span.is_synthetic() || span.end > src.len() {
133            return self.to_string();
134        }
135
136        let (line_no, col) = Span::line_col(src, span.start);
137        let line = find_line(src, span.start);
138        let line_num_str = format!("{line_no}");
139        let gutter = " ".repeat(line_num_str.len());
140
141        let col_offset = col.saturating_sub(1);
142        let len = (span.end - span.start).max(1);
143        let caret_line = format!(
144            "{gutter} | {blanks}{carets}",
145            blanks = " ".repeat(col_offset),
146            carets = "^".repeat(len)
147        );
148
149        let summary = self.short_message();
150        format!(
151            "error: {summary}\n  at line {line_no}, column {col}\n{line_num_str} | {line}\n{caret_line}",
152        )
153    }
154
155    /// Short, one-line summary of the error kind — no source context.
156    pub fn short_message(&self) -> String {
157        match self {
158            Self::UnboundSymbol { name, .. } => format!("unbound symbol `{name}`"),
159            Self::ArityMismatch {
160                fn_name,
161                expected,
162                got,
163                ..
164            } => format!("`{fn_name}` expected {expected:?}, got {got}"),
165            Self::TypeMismatch { expected, got, .. } => {
166                format!("type mismatch: expected {expected}, got {got}")
167            }
168            Self::DivisionByZero { .. } => "division by zero".into(),
169            Self::NotCallable { value_kind, .. } => {
170                format!("value of type {value_kind} is not callable")
171            }
172            Self::BadSpecialForm { form, reason, .. } => {
173                format!("bad `{form}`: {reason}")
174            }
175            Self::NativeFn { name, reason, .. } => format!("in native `{name}`: {reason}"),
176            Self::Reader(e) => format!("reader: {e}"),
177            Self::Halted => "halted".into(),
178            Self::NotImplemented(what) => format!("not yet implemented: {what}"),
179            Self::User { value, .. } => format!("uncaught: {value}"),
180        }
181    }
182}
183
184/// Extract the single line of `src` containing the byte offset `pos`.
185fn find_line(src: &str, pos: usize) -> &str {
186    let start = src[..pos].rfind('\n').map(|i| i + 1).unwrap_or(0);
187    let end = src[pos..].find('\n').map(|i| pos + i).unwrap_or(src.len());
188    &src[start..end]
189}
190
191#[cfg(test)]
192mod tests {
193    use super::*;
194
195    #[test]
196    fn find_line_single_line() {
197        let src = "foo bar baz";
198        assert_eq!(find_line(src, 5), "foo bar baz");
199    }
200
201    #[test]
202    fn find_line_multi_line() {
203        let src = "aaa\nbbb\nccc";
204        assert_eq!(find_line(src, 0), "aaa");
205        assert_eq!(find_line(src, 4), "bbb");
206        assert_eq!(find_line(src, 8), "ccc");
207    }
208
209    #[test]
210    fn render_includes_line_col_and_caret() {
211        let err = EvalError::unbound("foo", Span::new(4, 7));
212        let src = "(+ x foo y)";
213        let rendered = err.render(src);
214        assert!(rendered.contains("unbound symbol `foo`"));
215        assert!(rendered.contains("line 1, column 5"));
216        assert!(rendered.contains("(+ x foo y)"));
217        assert!(rendered.contains("^^^"));
218    }
219
220    #[test]
221    fn render_without_span_falls_back_to_display() {
222        let err = EvalError::Halted;
223        assert!(!err.render("ignored").is_empty());
224    }
225
226    #[test]
227    fn render_synthetic_span_falls_back() {
228        let err = EvalError::unbound("x", Span::synthetic());
229        let rendered = err.render("some source");
230        // No source context when span is synthetic.
231        assert!(!rendered.contains("line"));
232    }
233
234    #[test]
235    fn short_message_for_each_variant() {
236        use crate::ffi::Arity;
237
238        assert!(EvalError::DivisionByZero {
239            at: Span::synthetic(),
240        }
241        .short_message()
242        .contains("division"));
243
244        assert!(EvalError::unbound("foo", Span::synthetic())
245            .short_message()
246            .contains("foo"));
247
248        assert!(EvalError::ArityMismatch {
249            fn_name: "+".into(),
250            expected: Arity::Exact(2),
251            got: 3,
252            at: Span::synthetic(),
253        }
254        .short_message()
255        .contains("got 3"));
256    }
257}