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
//! 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::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 },
#[error("not callable: value of type {value_kind} at {at}")]
NotCallable { value_kind: &'static str, 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,
#[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,
},
}
impl EvalError {
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 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::DivisionByZero { at }
| Self::NotCallable { at, .. }
| Self::BadSpecialForm { at, .. }
| Self::NativeFn { 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::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::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::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"));
}
}