Skip to main content

sema_core/
error.rs

1use std::collections::HashMap;
2use std::fmt;
3
4use crate::value::Value;
5
6/// Check arity of a native function's arguments, returning `SemaError::Arity` on mismatch.
7///
8/// # Forms
9///
10/// ```ignore
11/// check_arity!(args, "fn-name", 2);        // exactly 2
12/// check_arity!(args, "fn-name", 1..=3);    // 1 to 3 inclusive
13/// check_arity!(args, "fn-name", 2..);      // 2 or more
14/// ```
15#[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/// A single frame in a call stack trace.
59#[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/// A captured stack trace (list of call frames, innermost first).
67#[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
85/// Maps Rc pointer addresses to source spans for expression tracking.
86pub 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    /// Attach a hint (actionable suggestion) to this error.
159    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    /// Attach a note (extra context) to this error.
175    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    /// Get the hint from this error, if any.
191    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    /// Get the note from this error, if any.
200    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    /// Wrap this error with a stack trace (no-op if already wrapped).
209    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    // 1. Span Display
250    #[test]
251    fn span_display() {
252        let span = Span { line: 1, col: 5 };
253        assert_eq!(span.to_string(), "1:5");
254    }
255
256    // 2. StackTrace Display — file+span, file only, span only, neither
257    #[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    // 3. SemaError::eval() constructor and Display
289    #[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    // 4. SemaError::type_error() constructor and Display
296    #[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    // 5. SemaError::arity() constructor and Display
303    #[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    // 6. with_hint attaches hint retrievable via .hint()
310    #[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    // 7. with_note attaches note retrievable via .note()
317    #[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    // 8. with_hint on already-wrapped WithContext preserves note
324    #[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    // 9. with_note on already-wrapped WithContext preserves hint
334    #[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    // 10. with_stack_trace wraps in WithTrace, retrievable via .stack_trace()
344    #[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    // 11. with_stack_trace with empty trace is no-op
358    #[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    // 12. with_stack_trace on already-wrapped WithTrace is no-op
366    #[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    // 13. inner() unwraps through WithTrace and WithContext
385    #[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    // 14. hint() and note() return None on plain errors
399    #[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    // 15. check_arity! exact match passes, mismatch returns error
407    #[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    // 16. check_arity! range match (1..=3) passes and fails
420    #[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    // 17. check_arity! open range (2..) passes and fails
434    #[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}