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