1use std::sync::Arc;
10
11use tatara_lisp::{caret_run, line_at, span_width_chars, Span};
12use thiserror::Error;
13
14use crate::env::Seal;
15use crate::ffi::Arity;
16
17pub type Result<T> = std::result::Result<T, EvalError>;
18
19#[derive(Debug, Error)]
20pub enum EvalError {
21 #[error("unbound symbol: {name} at {at}")]
22 UnboundSymbol { name: Arc<str>, at: Span },
23
24 #[error("arity mismatch in {fn_name}: expected {expected:?}, got {got} at {at}")]
25 ArityMismatch {
26 fn_name: Arc<str>,
27 expected: Arity,
28 got: usize,
29 at: Span,
30 },
31
32 #[error("type mismatch: expected {expected}, got {got} at {at}")]
33 TypeMismatch {
34 expected: &'static str,
35 got: &'static str,
36 at: Span,
37 },
38
39 #[error("division by zero at {at}")]
40 DivisionByZero { at: Span },
41
42 #[error("integer overflow in `{op}` at {at}")]
51 IntegerOverflow { op: &'static str, at: Span },
52
53 #[error("macro `{macro_name}` exceeded the expansion limit of {limit} rewrites at {at} — it likely expands to a call to itself")]
59 MacroExpansionLimit {
60 macro_name: Arc<str>,
61 limit: usize,
62 at: Span,
63 },
64
65 #[error("not callable: value of type {value_kind} at {at}")]
66 NotCallable { value_kind: &'static str, at: Span },
67
68 #[error("cannot `set!` `{name}`: {} at {at}", seal.refusal())]
76 SetSealed {
77 name: Arc<str>,
78 seal: Seal,
79 at: Span,
80 },
81
82 #[error("bad special form `{form}`: {reason} at {at}")]
83 BadSpecialForm {
84 form: Arc<str>,
85 reason: String,
86 at: Span,
87 },
88
89 #[error("in native fn {name}: {reason} at {at}")]
90 NativeFn {
91 name: Arc<str>,
92 reason: String,
93 at: Span,
94 },
95
96 #[error("reader error: {0}")]
97 Reader(#[from] tatara_lisp::LispError),
98
99 #[error("halted (host-initiated interrupt)")]
100 Halted,
101
102 #[error("{} budget of {limit} exceeded{} at {at}", dimension.noun(), in_function(function.as_deref()))]
116 BudgetExceeded {
117 dimension: BudgetDimension,
118 limit: usize,
119 function: Option<Arc<str>>,
122 at: Span,
123 },
124
125 #[error("not yet implemented: {0} (Phase 2.3+)")]
126 NotImplemented(&'static str),
127
128 #[error("user error: {value}")]
133 User {
134 value: crate::value::Value,
135 at: Span,
136 },
137}
138
139#[derive(Debug, Clone, Copy, PartialEq, Eq)]
144pub enum BudgetDimension {
145 Depth,
148 Fuel,
150}
151
152impl BudgetDimension {
153 #[must_use]
155 pub const fn tag(self) -> &'static str {
156 match self {
157 Self::Depth => "depth-exceeded",
158 Self::Fuel => "fuel-exhausted",
159 }
160 }
161
162 #[must_use]
164 pub const fn noun(self) -> &'static str {
165 match self {
166 Self::Depth => "call depth",
167 Self::Fuel => "fuel",
168 }
169 }
170}
171
172fn in_function(function: Option<&str>) -> String {
173 function.map_or_else(String::new, |f| format!(" in `{f}`"))
174}
175
176impl EvalError {
177 #[must_use]
180 pub(crate) fn in_function(self, name: Option<&Arc<str>>) -> Self {
181 match (self, name) {
182 (
183 Self::BudgetExceeded {
184 dimension,
185 limit,
186 function: None,
187 at,
188 },
189 Some(n),
190 ) => Self::BudgetExceeded {
191 dimension,
192 limit,
193 function: Some(Arc::clone(n)),
194 at,
195 },
196 (other, _) => other,
197 }
198 }
199
200 pub fn unbound(name: impl Into<Arc<str>>, at: Span) -> Self {
201 Self::UnboundSymbol {
202 name: name.into(),
203 at,
204 }
205 }
206
207 pub fn type_mismatch(expected: &'static str, got: &'static str, at: Span) -> Self {
208 Self::TypeMismatch { expected, got, at }
209 }
210
211 pub fn native_fn(name: impl Into<Arc<str>>, reason: impl Into<String>, at: Span) -> Self {
212 Self::NativeFn {
213 name: name.into(),
214 reason: reason.into(),
215 at,
216 }
217 }
218
219 pub fn bad_form(form: impl Into<Arc<str>>, reason: impl Into<String>, at: Span) -> Self {
220 Self::BadSpecialForm {
221 form: form.into(),
222 reason: reason.into(),
223 at,
224 }
225 }
226
227 #[must_use]
235 pub const fn tag(&self) -> &'static str {
236 match self {
237 Self::UnboundSymbol { .. } => "unbound-symbol",
238 Self::ArityMismatch { .. } => "arity-mismatch",
239 Self::TypeMismatch { .. } => "type-mismatch",
240 Self::DivisionByZero { .. } => "division-by-zero",
241 Self::IntegerOverflow { .. } => "integer-overflow",
242 Self::MacroExpansionLimit { .. } => "macro-expansion-limit",
243 Self::NotCallable { .. } => "not-callable",
244 Self::BadSpecialForm { .. } => "bad-special-form",
245 Self::NativeFn { .. } => "native-fn",
246 Self::SetSealed { .. } => "set-sealed",
247 Self::Reader(_) => "reader",
248 Self::Halted => "halted",
249 Self::BudgetExceeded { dimension, .. } => dimension.tag(),
250 Self::NotImplemented(_) => "not-implemented",
251 Self::User { .. } => "user",
252 }
253 }
254
255 pub fn span(&self) -> Option<Span> {
257 match self {
258 Self::UnboundSymbol { at, .. }
259 | Self::ArityMismatch { at, .. }
260 | Self::TypeMismatch { at, .. }
261 | Self::MacroExpansionLimit { at, .. }
262 | Self::DivisionByZero { at }
263 | Self::IntegerOverflow { at, .. }
264 | Self::NotCallable { at, .. }
265 | Self::BadSpecialForm { at, .. }
266 | Self::NativeFn { at, .. }
267 | Self::SetSealed { at, .. }
268 | Self::BudgetExceeded { at, .. }
269 | Self::User { at, .. } => Some(*at),
270 Self::Reader(_) | Self::Halted | Self::NotImplemented(_) => None,
271 }
272 }
273
274 pub fn render(&self, src: &str) -> String {
303 let Some(span) = self.span() else {
304 return self.to_string();
305 };
306 if span.is_synthetic() || span.end > src.len() {
307 return self.to_string();
308 }
309
310 let (line_no, col) = Span::line_col(src, span.start);
311 let line = line_at(src, span.start);
312 let line_num_str = format!("{line_no}");
313 let gutter = " ".repeat(line_num_str.len());
314
315 let caret_line = format!(
325 "{gutter} | {run}",
326 run = caret_run(line, col, span_width_chars(src, span))
327 );
328
329 let summary = self.short_message();
330 format!(
331 "error: {summary}\n at line {line_no}, column {col}\n{line_num_str} | {line}\n{caret_line}",
332 )
333 }
334
335 pub fn short_message(&self) -> String {
337 match self {
338 Self::UnboundSymbol { name, .. } => format!("unbound symbol `{name}`"),
339 Self::SetSealed { name, seal, .. } => {
340 format!("`set!` of sealed binding `{name}` ({seal:?})")
341 }
342 Self::MacroExpansionLimit {
343 macro_name, limit, ..
344 } => format!("macro `{macro_name}` exceeded {limit} expansion steps"),
345 Self::ArityMismatch {
346 fn_name,
347 expected,
348 got,
349 ..
350 } => format!("`{fn_name}` expected {expected:?}, got {got}"),
351 Self::TypeMismatch { expected, got, .. } => {
352 format!("type mismatch: expected {expected}, got {got}")
353 }
354 Self::DivisionByZero { .. } => "division by zero".into(),
355 Self::IntegerOverflow { op, .. } => format!("integer overflow in `{op}`"),
356 Self::NotCallable { value_kind, .. } => {
357 format!("value of type {value_kind} is not callable")
358 }
359 Self::BadSpecialForm { form, reason, .. } => {
360 format!("bad `{form}`: {reason}")
361 }
362 Self::NativeFn { name, reason, .. } => format!("in native `{name}`: {reason}"),
363 Self::Reader(e) => format!("reader: {e}"),
364 Self::Halted => "halted".into(),
365 Self::BudgetExceeded {
366 dimension,
367 limit,
368 function,
369 ..
370 } => format!(
371 "{} budget of {limit} exceeded{}",
372 dimension.noun(),
373 in_function(function.as_deref())
374 ),
375 Self::NotImplemented(what) => format!("not yet implemented: {what}"),
376 Self::User { value, .. } => format!("uncaught: {value}"),
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 use super::*;
384
385 #[test]
386 fn render_slices_the_span_s_own_line_via_the_shared_slicer() {
387 let src = "aaa\nbbb\nccc";
392 let rendered = EvalError::unbound("bbb", Span::new(4, 7)).render(src);
393 assert!(
394 rendered.contains("2 | bbb"),
395 "line 2 must be the rendered snippet, got:\n{rendered}"
396 );
397 assert!(
398 !rendered.contains("aaa") && !rendered.contains("ccc"),
399 "only the span's own line may be rendered, got:\n{rendered}"
400 );
401 }
402
403 #[test]
404 fn render_mirrors_a_tab_indent_into_the_caret_pad() {
405 let src = "\tfoo";
411 let rendered = EvalError::unbound("foo", Span::new(1, 4)).render(src);
412 assert!(
413 rendered.contains("\t^^^"),
414 "caret pad must mirror the source tab, got:\n{rendered:?}"
415 );
416 assert!(
417 !rendered.contains(" ^^^"),
418 "a space-padded run is the pre-lift drift, got:\n{rendered:?}"
419 );
420 }
421
422 #[test]
423 fn render_sizes_the_caret_run_in_chars_not_bytes() {
424 let src = "(+ éé 1)";
428 let start = src.find("éé").expect("fixture contains the symbol");
429 let span = Span::new(start, start + "éé".len());
430 let rendered = EvalError::unbound("éé", span).render(src);
431 assert!(
432 rendered.contains(" ^^\n") || rendered.ends_with(" ^^"),
433 "two chars must draw exactly two carets, got:\n{rendered:?}"
434 );
435 assert!(
436 !rendered.contains("^^^"),
437 "a byte-sized run over-underlines multi-byte source, got:\n{rendered:?}"
438 );
439 }
440
441 #[test]
442 fn render_includes_line_col_and_caret() {
443 let err = EvalError::unbound("foo", Span::new(4, 7));
444 let src = "(+ x foo y)";
445 let rendered = err.render(src);
446 assert!(rendered.contains("unbound symbol `foo`"));
447 assert!(rendered.contains("line 1, column 5"));
448 assert!(rendered.contains("(+ x foo y)"));
449 assert!(rendered.contains("^^^"));
450 }
451
452 #[test]
453 fn render_without_span_falls_back_to_display() {
454 let err = EvalError::Halted;
455 assert!(!err.render("ignored").is_empty());
456 }
457
458 #[test]
459 fn render_synthetic_span_falls_back() {
460 let err = EvalError::unbound("x", Span::synthetic());
461 let rendered = err.render("some source");
462 assert!(!rendered.contains("line"));
464 }
465
466 #[test]
467 fn short_message_for_each_variant() {
468 use crate::ffi::Arity;
469
470 assert!(EvalError::DivisionByZero {
471 at: Span::synthetic(),
472 }
473 .short_message()
474 .contains("division"));
475
476 assert!(EvalError::unbound("foo", Span::synthetic())
477 .short_message()
478 .contains("foo"));
479
480 assert!(EvalError::ArityMismatch {
481 fn_name: "+".into(),
482 expected: Arity::Exact(2),
483 got: 3,
484 at: Span::synthetic(),
485 }
486 .short_message()
487 .contains("got 3"));
488 }
489}