1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
//! Runtime evaluator errors.
//!
//! Every variant carries a `Span` pointing back to the offending source
//! subform (or `Span::synthetic()` when the error originated in macro-
//! generated code or native fn). No panics from the evaluator itself —
//! panics from registered native fns are caught at the FFI boundary and
//! surfaced here as `EvalError::NativeFn`.
use std::sync::Arc;
use tatara_lisp::{caret_run, line_at, span_width_chars, Span};
use thiserror::Error;
use crate::env::Seal;
use crate::ffi::Arity;
pub type Result<T> = std::result::Result<T, EvalError>;
#[derive(Debug, Error)]
pub enum EvalError {
#[error("unbound symbol: {name} at {at}")]
UnboundSymbol { name: Arc<str>, at: Span },
#[error("arity mismatch in {fn_name}: expected {expected:?}, got {got} at {at}")]
ArityMismatch {
fn_name: Arc<str>,
expected: Arity,
got: usize,
at: Span,
},
#[error("type mismatch: expected {expected}, got {got} at {at}")]
TypeMismatch {
expected: &'static str,
got: &'static str,
at: Span,
},
#[error("division by zero at {at}")]
DivisionByZero { at: Span },
/// An integer operation whose exact result does not fit `i64`.
///
/// Before this existed the same operation wrapped in a release build
/// (`9223372036854775807 * 2` was `-2`) and panicked in a debug one, so
/// the answer depended on the build profile. Overflow is an error by
/// default; a program that wants two's-complement wrapping asks for it
/// by name (`wrapping-add`, `wrapping-sub`, `wrapping-mul`,
/// `wrapping-neg`).
#[error("integer overflow in `{op}` at {at}")]
IntegerOverflow { op: &'static str, at: Span },
/// A macro rewrite chain exceeded its ceiling.
///
/// Names the macro that was rewriting, because that is the one to look at.
/// Before this existed the same condition was `fatal runtime error: stack
/// overflow, aborting` — uncatchable, and at build time.
#[error("macro `{macro_name}` exceeded the expansion limit of {limit} rewrites at {at} — it likely expands to a call to itself")]
MacroExpansionLimit {
macro_name: Arc<str>,
limit: usize,
at: Span,
},
#[error("not callable: value of type {value_kind} at {at}")]
NotCallable { value_kind: &'static str, at: Span },
/// A `set!` reached a binding in a frame sealed against writes.
///
/// Distinct from `UnboundSymbol`: the name *is* bound, and the write was
/// refused. Carrying the [`Seal`] rather than a prebuilt sentence is
/// deliberate — two callers seal for different reasons, and the wording
/// hard-coded for the first (macro expansion) actively misdirects a reader
/// who hit the second.
#[error("cannot `set!` `{name}`: {} at {at}", seal.refusal())]
SetSealed {
name: Arc<str>,
seal: Seal,
at: Span,
},
#[error("bad special form `{form}`: {reason} at {at}")]
BadSpecialForm {
form: Arc<str>,
reason: String,
at: Span,
},
#[error("in native fn {name}: {reason} at {at}")]
NativeFn {
name: Arc<str>,
reason: String,
at: Span,
},
#[error("reader error: {0}")]
Reader(#[from] tatara_lisp::LispError),
#[error("halted (host-initiated interrupt)")]
Halted,
/// A run exceeded one dimension of its [`crate::vm::Budget`].
///
/// Before this existed, exceeding the depth the tree-walker could hold was
/// `fatal runtime error: stack overflow, aborting` — rc 134 at 4-6k frames,
/// with no `catch` able to see it because the OS process was already gone —
/// and a loop with no exit ran until something outside killed it.
///
/// A `catch` observes it like any runtime error (tag `depth-exceeded` or
/// `fuel-exhausted`). The two differ in what that buys: a depth refusal has
/// unwound the frames it counted, so the handler runs with room to spare;
/// exhausted fuel stays exhausted, so the handler's own first step refuses
/// again and the error leaves the `try`. A budget cannot be spent past by
/// catching it.
#[error("{} budget of {limit} exceeded{} at {at}", dimension.noun(), in_function(function.as_deref()))]
BudgetExceeded {
dimension: BudgetDimension,
limit: usize,
/// The innermost named function running when the bound fired, when
/// one was.
function: Option<Arc<str>>,
at: Span,
},
#[error("not yet implemented: {0} (Phase 2.3+)")]
NotImplemented(&'static str),
/// A Lisp-side error raised via `(throw ...)`. Caught by
/// `(try ... (catch (e) ...))`. The carried `Value` is whatever
/// the user threw — conventionally a `Value::Error` produced by
/// `(error ...)` / `(ex-info ...)`, but any Value is allowed.
#[error("user error: {value}")]
User {
value: crate::value::Value,
at: Span,
},
}
/// Which dimension of a [`crate::vm::Budget`] a run exceeded.
///
/// Fuel bounds TIME and depth bounds SPACE; neither implies the other, so a
/// refusal names which one fired.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum BudgetDimension {
/// Nested (non-tail) closure calls alive at once. Tail calls do not count:
/// the trampoline reuses the frame.
Depth,
/// Evaluation steps since the budget was last set.
Fuel,
}
impl BudgetDimension {
/// The `catch`-visible tag.
#[must_use]
pub const fn tag(self) -> &'static str {
match self {
Self::Depth => "depth-exceeded",
Self::Fuel => "fuel-exhausted",
}
}
/// What the budget bounds, as the error message names it.
#[must_use]
pub const fn noun(self) -> &'static str {
match self {
Self::Depth => "call depth",
Self::Fuel => "fuel",
}
}
}
fn in_function(function: Option<&str>) -> String {
function.map_or_else(String::new, |f| format!(" in `{f}`"))
}
impl EvalError {
/// Name the function a budget refusal fired in, unless an inner frame
/// already did. Every other error passes through untouched.
#[must_use]
pub(crate) fn in_function(self, name: Option<&Arc<str>>) -> Self {
match (self, name) {
(
Self::BudgetExceeded {
dimension,
limit,
function: None,
at,
},
Some(n),
) => Self::BudgetExceeded {
dimension,
limit,
function: Some(Arc::clone(n)),
at,
},
(other, _) => other,
}
}
pub fn unbound(name: impl Into<Arc<str>>, at: Span) -> Self {
Self::UnboundSymbol {
name: name.into(),
at,
}
}
pub fn type_mismatch(expected: &'static str, got: &'static str, at: Span) -> Self {
Self::TypeMismatch { expected, got, at }
}
pub fn native_fn(name: impl Into<Arc<str>>, reason: impl Into<String>, at: Span) -> Self {
Self::NativeFn {
name: name.into(),
reason: reason.into(),
at,
}
}
pub fn bad_form(form: impl Into<Arc<str>>, reason: impl Into<String>, at: Span) -> Self {
Self::BadSpecialForm {
form: form.into(),
reason: reason.into(),
at,
}
}
/// The Lisp-visible tag this error surfaces as when a `(catch ...)`
/// handler observes it.
///
/// One table. It used to be two — the tree-walker and the VM each carried
/// a copy, so `EvalError` had three edit sites per variant and no compiler
/// pressure keeping the two tags equal. A `catch` that matched under one
/// executor and not the other would have been silent.
#[must_use]
pub const fn tag(&self) -> &'static str {
match self {
Self::UnboundSymbol { .. } => "unbound-symbol",
Self::ArityMismatch { .. } => "arity-mismatch",
Self::TypeMismatch { .. } => "type-mismatch",
Self::DivisionByZero { .. } => "division-by-zero",
Self::IntegerOverflow { .. } => "integer-overflow",
Self::MacroExpansionLimit { .. } => "macro-expansion-limit",
Self::NotCallable { .. } => "not-callable",
Self::BadSpecialForm { .. } => "bad-special-form",
Self::NativeFn { .. } => "native-fn",
Self::SetSealed { .. } => "set-sealed",
Self::Reader(_) => "reader",
Self::Halted => "halted",
Self::BudgetExceeded { dimension, .. } => dimension.tag(),
Self::NotImplemented(_) => "not-implemented",
Self::User { .. } => "user",
}
}
/// The span this error is attached to, if any.
pub fn span(&self) -> Option<Span> {
match self {
Self::UnboundSymbol { at, .. }
| Self::ArityMismatch { at, .. }
| Self::TypeMismatch { at, .. }
| Self::MacroExpansionLimit { at, .. }
| Self::DivisionByZero { at }
| Self::IntegerOverflow { at, .. }
| Self::NotCallable { at, .. }
| Self::BadSpecialForm { at, .. }
| Self::NativeFn { at, .. }
| Self::SetSealed { at, .. }
| Self::BudgetExceeded { at, .. }
| Self::User { at, .. } => Some(*at),
Self::Reader(_) | Self::Halted | Self::NotImplemented(_) => None,
}
}
/// Render this error with source context — finds the line containing
/// the error's span in `src`, prints that line, and underlines the
/// span with `^` markers. Produces a multi-line string suitable for
/// CLI / REPL output.
///
/// If the error has no span, or its span is synthetic, renders just
/// the error message without source context.
///
/// The caret line comes from [`tatara_lisp::caret_run`] — the fleet's
/// ONE caret renderer — rather than the hand-rolled pad this method
/// used to carry. That lift fixed two bugs the local copy had drifted
/// into, both of them mis-placing the underline relative to the span
/// it names:
///
/// * it padded with `" ".repeat(col - 1)`, so a tab-indented source
/// line put one space where the source spent a whole tab-stop and
/// the underline slid left of its span;
/// * it sized the underline as `span.end - span.start`, a BYTE count,
/// while padding to a CHAR column — so a multi-byte subform drew
/// more carets than it occupies columns.
///
/// `pending-caret-multiline`: a span covering more than one line still
/// draws its full char-width of carets under the single line rendered
/// above it, overflowing that line's end. Both pre-lift copies did
/// this and the shared renderer preserves it deliberately — clamping
/// the run to the rendered line is a real output change for every
/// whole-form span (which is most of them), so it wants its own
/// measured pass rather than riding along inside this consolidation.
pub fn render(&self, src: &str) -> String {
let Some(span) = self.span() else {
return self.to_string();
};
if span.is_synthetic() || span.end > src.len() {
return self.to_string();
}
let (line_no, col) = Span::line_col(src, span.start);
let line = line_at(src, span.start);
let line_num_str = format!("{line_no}");
let gutter = " ".repeat(line_num_str.len());
// CHARS, not bytes — `caret_run` pads to a char column, so the
// width must be counted in the same unit or the two disagree on
// any non-ASCII source. That conversion is
// [`tatara_lisp::span_width_chars`], the same one
// `format_diagnostic` goes through, rather than a second copy of
// the expression here: two hand-rolls of a unit conversion are
// exactly how the byte-vs-char drift this comment describes got
// in. It also carries the off-char-boundary degrade-to-1 guard,
// so a diagnostic renderer never panics.
let caret_line = format!(
"{gutter} | {run}",
run = caret_run(line, col, span_width_chars(src, span))
);
let summary = self.short_message();
format!(
"error: {summary}\n at line {line_no}, column {col}\n{line_num_str} | {line}\n{caret_line}",
)
}
/// Short, one-line summary of the error kind — no source context.
pub fn short_message(&self) -> String {
match self {
Self::UnboundSymbol { name, .. } => format!("unbound symbol `{name}`"),
Self::SetSealed { name, seal, .. } => {
format!("`set!` of sealed binding `{name}` ({seal:?})")
}
Self::MacroExpansionLimit {
macro_name, limit, ..
} => format!("macro `{macro_name}` exceeded {limit} expansion steps"),
Self::ArityMismatch {
fn_name,
expected,
got,
..
} => format!("`{fn_name}` expected {expected:?}, got {got}"),
Self::TypeMismatch { expected, got, .. } => {
format!("type mismatch: expected {expected}, got {got}")
}
Self::DivisionByZero { .. } => "division by zero".into(),
Self::IntegerOverflow { op, .. } => format!("integer overflow in `{op}`"),
Self::NotCallable { value_kind, .. } => {
format!("value of type {value_kind} is not callable")
}
Self::BadSpecialForm { form, reason, .. } => {
format!("bad `{form}`: {reason}")
}
Self::NativeFn { name, reason, .. } => format!("in native `{name}`: {reason}"),
Self::Reader(e) => format!("reader: {e}"),
Self::Halted => "halted".into(),
Self::BudgetExceeded {
dimension,
limit,
function,
..
} => format!(
"{} budget of {limit} exceeded{}",
dimension.noun(),
in_function(function.as_deref())
),
Self::NotImplemented(what) => format!("not yet implemented: {what}"),
Self::User { value, .. } => format!("uncaught: {value}"),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn render_slices_the_span_s_own_line_via_the_shared_slicer() {
// Replaces the deleted local `find_line`, which duplicated
// `tatara_lisp::line_at`. Pinned through `render` rather than by
// calling the slicer directly, so the assertion is about the
// artifact operators actually read.
let src = "aaa\nbbb\nccc";
let rendered = EvalError::unbound("bbb", Span::new(4, 7)).render(src);
assert!(
rendered.contains("2 | bbb"),
"line 2 must be the rendered snippet, got:\n{rendered}"
);
assert!(
!rendered.contains("aaa") && !rendered.contains("ccc"),
"only the span's own line may be rendered, got:\n{rendered}"
);
}
#[test]
fn render_mirrors_a_tab_indent_into_the_caret_pad() {
// BUG FIX PIN. Pre-lift this method padded with
// `" ".repeat(col - 1)`, so `\tfoo` rendered its underline after
// ONE space while the source line spent a whole tab-stop — the
// carets landed left of `foo` on every real terminal. Going
// through the shared `caret_run` mirrors the tab through.
let src = "\tfoo";
let rendered = EvalError::unbound("foo", Span::new(1, 4)).render(src);
assert!(
rendered.contains("\t^^^"),
"caret pad must mirror the source tab, got:\n{rendered:?}"
);
assert!(
!rendered.contains(" ^^^"),
"a space-padded run is the pre-lift drift, got:\n{rendered:?}"
);
}
#[test]
fn render_sizes_the_caret_run_in_chars_not_bytes() {
// BUG FIX PIN. `éé` is 2 chars but 4 bytes. Pre-lift the width
// was `span.end - span.start` (bytes) while the pad counted
// chars, so this drew FOUR carets under a two-column symbol.
let src = "(+ éé 1)";
let start = src.find("éé").expect("fixture contains the symbol");
let span = Span::new(start, start + "éé".len());
let rendered = EvalError::unbound("éé", span).render(src);
assert!(
rendered.contains(" ^^\n") || rendered.ends_with(" ^^"),
"two chars must draw exactly two carets, got:\n{rendered:?}"
);
assert!(
!rendered.contains("^^^"),
"a byte-sized run over-underlines multi-byte source, got:\n{rendered:?}"
);
}
#[test]
fn render_includes_line_col_and_caret() {
let err = EvalError::unbound("foo", Span::new(4, 7));
let src = "(+ x foo y)";
let rendered = err.render(src);
assert!(rendered.contains("unbound symbol `foo`"));
assert!(rendered.contains("line 1, column 5"));
assert!(rendered.contains("(+ x foo y)"));
assert!(rendered.contains("^^^"));
}
#[test]
fn render_without_span_falls_back_to_display() {
let err = EvalError::Halted;
assert!(!err.render("ignored").is_empty());
}
#[test]
fn render_synthetic_span_falls_back() {
let err = EvalError::unbound("x", Span::synthetic());
let rendered = err.render("some source");
// No source context when span is synthetic.
assert!(!rendered.contains("line"));
}
#[test]
fn short_message_for_each_variant() {
use crate::ffi::Arity;
assert!(EvalError::DivisionByZero {
at: Span::synthetic(),
}
.short_message()
.contains("division"));
assert!(EvalError::unbound("foo", Span::synthetic())
.short_message()
.contains("foo"));
assert!(EvalError::ArityMismatch {
fn_name: "+".into(),
expected: Arity::Exact(2),
got: 3,
at: Span::synthetic(),
}
.short_message()
.contains("got 3"));
}
}