1use 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("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 #[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 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 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 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 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 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 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 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 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}