1use std::collections::HashMap;
2use std::fmt;
3
4use crate::value::Value;
5
6#[macro_export]
16macro_rules! check_arity {
17 ($args:expr, $name:expr, $exact:literal) => {
18 if $args.len() != $exact {
19 return Err($crate::SemaError::arity(
20 $name,
21 stringify!($exact),
22 $args.len(),
23 ));
24 }
25 };
26 ($args:expr, $name:expr, $lo:literal ..= $hi:literal) => {
27 if $args.len() < $lo || $args.len() > $hi {
28 return Err($crate::SemaError::arity(
29 $name,
30 concat!(stringify!($lo), "-", stringify!($hi)),
31 $args.len(),
32 ));
33 }
34 };
35 ($args:expr, $name:expr, $lo:literal ..) => {
36 if $args.len() < $lo {
37 return Err($crate::SemaError::arity(
38 $name,
39 concat!(stringify!($lo), "+"),
40 $args.len(),
41 ));
42 }
43 };
44}
45
46#[derive(Debug, Clone, Copy)]
47pub struct Span {
48 pub line: usize,
49 pub col: usize,
50}
51
52impl fmt::Display for Span {
53 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
54 write!(f, "{}:{}", self.line, self.col)
55 }
56}
57
58#[derive(Debug, Clone)]
60pub struct CallFrame {
61 pub name: String,
62 pub file: Option<std::path::PathBuf>,
63 pub span: Option<Span>,
64}
65
66#[derive(Debug, Clone)]
68pub struct StackTrace(pub Vec<CallFrame>);
69
70impl fmt::Display for StackTrace {
71 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
72 for frame in &self.0 {
73 write!(f, " at {}", frame.name)?;
74 match (&frame.file, &frame.span) {
75 (Some(file), Some(span)) => writeln!(f, " ({}:{span})", file.display())?,
76 (Some(file), None) => writeln!(f, " ({})", file.display())?,
77 (None, Some(span)) => writeln!(f, " (<input>:{span})")?,
78 (None, None) => writeln!(f)?,
79 }
80 }
81 Ok(())
82 }
83}
84
85pub type SpanMap = HashMap<usize, Span>;
87
88#[derive(Debug, Clone, thiserror::Error)]
89pub enum SemaError {
90 #[error("Reader error at {span}: {message}")]
91 Reader { message: String, span: Span },
92
93 #[error("Eval error: {0}")]
94 Eval(String),
95
96 #[error("Type error: expected {expected}, got {got}")]
97 Type { expected: String, got: String },
98
99 #[error("Arity error: {name} expects {expected} args, got {got}")]
100 Arity {
101 name: String,
102 expected: String,
103 got: usize,
104 },
105
106 #[error("Unbound variable: {0}")]
107 Unbound(String),
108
109 #[error("LLM error: {0}")]
110 Llm(String),
111
112 #[error("IO error: {0}")]
113 Io(String),
114
115 #[error("Permission denied: {function} requires '{capability}' capability")]
116 PermissionDenied {
117 function: String,
118 capability: String,
119 },
120
121 #[error("User exception: {0}")]
122 UserException(Value),
123
124 #[error("{inner}")]
125 WithTrace {
126 inner: Box<SemaError>,
127 trace: StackTrace,
128 },
129
130 #[error("{inner}")]
131 WithContext {
132 inner: Box<SemaError>,
133 hint: Option<String>,
134 note: Option<String>,
135 },
136}
137
138impl SemaError {
139 pub fn eval(msg: impl Into<String>) -> Self {
140 SemaError::Eval(msg.into())
141 }
142
143 pub fn type_error(expected: impl Into<String>, got: impl Into<String>) -> Self {
144 SemaError::Type {
145 expected: expected.into(),
146 got: got.into(),
147 }
148 }
149
150 pub fn arity(name: impl Into<String>, expected: impl Into<String>, got: usize) -> Self {
151 SemaError::Arity {
152 name: name.into(),
153 expected: expected.into(),
154 got,
155 }
156 }
157
158 pub fn with_hint(self, hint: impl Into<String>) -> Self {
160 match self {
161 SemaError::WithContext { inner, note, .. } => SemaError::WithContext {
162 inner,
163 hint: Some(hint.into()),
164 note,
165 },
166 other => SemaError::WithContext {
167 inner: Box::new(other),
168 hint: Some(hint.into()),
169 note: None,
170 },
171 }
172 }
173
174 pub fn with_note(self, note: impl Into<String>) -> Self {
176 match self {
177 SemaError::WithContext { inner, hint, .. } => SemaError::WithContext {
178 inner,
179 hint,
180 note: Some(note.into()),
181 },
182 other => SemaError::WithContext {
183 inner: Box::new(other),
184 hint: None,
185 note: Some(note.into()),
186 },
187 }
188 }
189
190 pub fn hint(&self) -> Option<&str> {
192 match self {
193 SemaError::WithContext { hint, .. } => hint.as_deref(),
194 SemaError::WithTrace { inner, .. } => inner.hint(),
195 _ => None,
196 }
197 }
198
199 pub fn note(&self) -> Option<&str> {
201 match self {
202 SemaError::WithContext { note, .. } => note.as_deref(),
203 SemaError::WithTrace { inner, .. } => inner.note(),
204 _ => None,
205 }
206 }
207
208 pub fn with_stack_trace(self, trace: StackTrace) -> Self {
210 if trace.0.is_empty() {
211 return self;
212 }
213 match self {
214 SemaError::WithTrace { .. } => self,
215 SemaError::WithContext { inner, hint, note } => SemaError::WithContext {
216 inner: Box::new(inner.with_stack_trace(trace)),
217 hint,
218 note,
219 },
220 other => SemaError::WithTrace {
221 inner: Box::new(other),
222 trace,
223 },
224 }
225 }
226
227 pub fn stack_trace(&self) -> Option<&StackTrace> {
228 match self {
229 SemaError::WithTrace { trace, .. } => Some(trace),
230 SemaError::WithContext { inner, .. } => inner.stack_trace(),
231 _ => None,
232 }
233 }
234
235 pub fn inner(&self) -> &SemaError {
236 match self {
237 SemaError::WithTrace { inner, .. } => inner.inner(),
238 SemaError::WithContext { inner, .. } => inner.inner(),
239 other => other,
240 }
241 }
242}
243
244#[cfg(test)]
245mod tests {
246 use super::*;
247 use crate::Value;
248
249 #[test]
251 fn span_display() {
252 let span = Span { line: 1, col: 5 };
253 assert_eq!(span.to_string(), "1:5");
254 }
255
256 #[test]
258 fn stack_trace_display() {
259 let trace = StackTrace(vec![
260 CallFrame {
261 name: "foo".into(),
262 file: Some("/a/b.sema".into()),
263 span: Some(Span { line: 3, col: 7 }),
264 },
265 CallFrame {
266 name: "bar".into(),
267 file: Some("/c/d.sema".into()),
268 span: None,
269 },
270 CallFrame {
271 name: "baz".into(),
272 file: None,
273 span: Some(Span { line: 10, col: 1 }),
274 },
275 CallFrame {
276 name: "qux".into(),
277 file: None,
278 span: None,
279 },
280 ]);
281 let s = trace.to_string();
282 assert!(s.contains("at foo (/a/b.sema:3:7)"));
283 assert!(s.contains("at bar (/c/d.sema)"));
284 assert!(s.contains("at baz (<input>:10:1)"));
285 assert!(s.contains("at qux\n"));
286 }
287
288 #[test]
290 fn eval_error() {
291 let e = SemaError::eval("something broke");
292 assert_eq!(e.to_string(), "Eval error: something broke");
293 }
294
295 #[test]
297 fn type_error() {
298 let e = SemaError::type_error("string", "integer");
299 assert_eq!(e.to_string(), "Type error: expected string, got integer");
300 }
301
302 #[test]
304 fn arity_error() {
305 let e = SemaError::arity("my-fn", "2", 5);
306 assert_eq!(e.to_string(), "Arity error: my-fn expects 2 args, got 5");
307 }
308
309 #[test]
311 fn with_hint() {
312 let e = SemaError::eval("oops").with_hint("try this");
313 assert_eq!(e.hint(), Some("try this"));
314 }
315
316 #[test]
318 fn with_note() {
319 let e = SemaError::eval("oops").with_note("extra info");
320 assert_eq!(e.note(), Some("extra info"));
321 }
322
323 #[test]
325 fn with_hint_preserves_note() {
326 let e = SemaError::eval("oops")
327 .with_note("kept note")
328 .with_hint("new hint");
329 assert_eq!(e.hint(), Some("new hint"));
330 assert_eq!(e.note(), Some("kept note"));
331 }
332
333 #[test]
335 fn with_note_preserves_hint() {
336 let e = SemaError::eval("oops")
337 .with_hint("kept hint")
338 .with_note("new note");
339 assert_eq!(e.hint(), Some("kept hint"));
340 assert_eq!(e.note(), Some("new note"));
341 }
342
343 #[test]
345 fn with_stack_trace() {
346 let trace = StackTrace(vec![CallFrame {
347 name: "f".into(),
348 file: None,
349 span: None,
350 }]);
351 let e = SemaError::eval("err").with_stack_trace(trace);
352 let st = e.stack_trace().expect("should have stack trace");
353 assert_eq!(st.0.len(), 1);
354 assert_eq!(st.0[0].name, "f");
355 }
356
357 #[test]
359 fn with_stack_trace_empty_is_noop() {
360 let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![]));
361 assert!(e.stack_trace().is_none());
362 assert!(matches!(e, SemaError::Eval(_)));
363 }
364
365 #[test]
367 fn with_stack_trace_already_wrapped_is_noop() {
368 let frame = || CallFrame {
369 name: "first".into(),
370 file: None,
371 span: None,
372 };
373 let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![frame()]));
374 let e2 = e.with_stack_trace(StackTrace(vec![CallFrame {
375 name: "second".into(),
376 file: None,
377 span: None,
378 }]));
379 let st = e2.stack_trace().unwrap();
380 assert_eq!(st.0.len(), 1);
381 assert_eq!(st.0[0].name, "first");
382 }
383
384 #[test]
386 fn inner_unwraps() {
387 let e = SemaError::eval("root")
388 .with_hint("h")
389 .with_stack_trace(StackTrace(vec![CallFrame {
390 name: "x".into(),
391 file: None,
392 span: None,
393 }]));
394 let inner = e.inner();
395 assert!(matches!(inner, SemaError::Eval(msg) if msg == "root"));
396 }
397
398 #[test]
400 fn hint_note_none_on_plain() {
401 let e = SemaError::eval("plain");
402 assert!(e.hint().is_none());
403 assert!(e.note().is_none());
404 }
405
406 #[test]
408 fn check_arity_exact() {
409 fn run(args: &[Value]) -> Result<(), SemaError> {
410 check_arity!(args, "test-fn", 2);
411 Ok(())
412 }
413 assert!(run(&[Value::nil(), Value::nil()]).is_ok());
414 let err = run(&[Value::nil()]).unwrap_err();
415 assert!(err.to_string().contains("test-fn"));
416 assert!(err.to_string().contains("2"));
417 }
418
419 #[test]
421 fn check_arity_range() {
422 fn run(args: &[Value]) -> Result<(), SemaError> {
423 check_arity!(args, "range-fn", 1..=3);
424 Ok(())
425 }
426 assert!(run(&[Value::nil()]).is_ok());
427 assert!(run(&[Value::nil(), Value::nil()]).is_ok());
428 assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
429 assert!(run(&[]).is_err());
430 assert!(run(&[Value::nil(), Value::nil(), Value::nil(), Value::nil()]).is_err());
431 }
432
433 #[test]
435 fn check_arity_open_range() {
436 fn run(args: &[Value]) -> Result<(), SemaError> {
437 check_arity!(args, "open-fn", 2..);
438 Ok(())
439 }
440 assert!(run(&[Value::nil(), Value::nil()]).is_ok());
441 assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
442 assert!(run(&[Value::nil()]).is_err());
443 assert!(run(&[]).is_err());
444 }
445}