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    pub end_line: usize,
51    pub end_col: usize,
52}
53
54impl Span {
55    /// Create a point span (start == end).
56    pub fn point(line: usize, col: usize) -> Self {
57        Span {
58            line,
59            col,
60            end_line: line,
61            end_col: col,
62        }
63    }
64
65    /// Create a span with explicit start and end.
66    pub fn new(line: usize, col: usize, end_line: usize, end_col: usize) -> Self {
67        Span {
68            line,
69            col,
70            end_line,
71            end_col,
72        }
73    }
74
75    /// Create a span from the start of `self` to the end of `other`.
76    pub fn to(self, other: &Span) -> Span {
77        Span {
78            line: self.line,
79            col: self.col,
80            end_line: other.end_line,
81            end_col: other.end_col,
82        }
83    }
84
85    /// Create a span from the start of `self` to an explicit end position.
86    pub fn with_end(self, end_line: usize, end_col: usize) -> Span {
87        Span {
88            line: self.line,
89            col: self.col,
90            end_line,
91            end_col,
92        }
93    }
94}
95
96impl fmt::Display for Span {
97    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
98        write!(f, "{}:{}", self.line, self.col)
99    }
100}
101
102/// A single frame in a call stack trace.
103#[derive(Debug, Clone)]
104pub struct CallFrame {
105    pub name: String,
106    pub file: Option<std::path::PathBuf>,
107    pub span: Option<Span>,
108}
109
110/// A captured stack trace (list of call frames, innermost first).
111#[derive(Debug, Clone)]
112pub struct StackTrace(pub Vec<CallFrame>);
113
114impl fmt::Display for StackTrace {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        for frame in &self.0 {
117            write!(f, "  at {}", frame.name)?;
118            match (&frame.file, &frame.span) {
119                (Some(file), Some(span)) => writeln!(f, " ({}:{span})", file.display())?,
120                (Some(file), None) => writeln!(f, " ({})", file.display())?,
121                (None, Some(span)) => writeln!(f, " (<input>:{span})")?,
122                (None, None) => writeln!(f)?,
123            }
124        }
125        Ok(())
126    }
127}
128
129/// Maps Rc pointer addresses to source spans for expression tracking.
130pub type SpanMap = HashMap<usize, Span>;
131
132#[derive(Debug, Clone, thiserror::Error)]
133pub enum SemaError {
134    #[error("Reader error at {span}: {message}")]
135    Reader { message: String, span: Span },
136
137    #[error("Eval error: {0}")]
138    Eval(String),
139
140    #[error("Type error: expected {expected}, got {got}")]
141    Type { expected: String, got: String },
142
143    #[error("Arity error: {name} expects {expected} args, got {got}")]
144    Arity {
145        name: String,
146        expected: String,
147        got: usize,
148    },
149
150    #[error("Unbound variable: {0}")]
151    Unbound(String),
152
153    #[error("LLM error: {0}")]
154    Llm(String),
155
156    #[error("IO error: {0}")]
157    Io(String),
158
159    #[error("Permission denied: {function} requires '{capability}' capability")]
160    PermissionDenied {
161        function: String,
162        capability: String,
163    },
164
165    #[error("Permission denied: {function} — path '{path}' is outside allowed directories")]
166    PathDenied { function: String, path: String },
167
168    #[error("User exception: {0}")]
169    UserException(Value),
170
171    #[error("{inner}")]
172    WithTrace {
173        inner: Box<SemaError>,
174        trace: StackTrace,
175    },
176
177    #[error("{inner}")]
178    WithContext {
179        inner: Box<SemaError>,
180        hint: Option<String>,
181        note: Option<String>,
182    },
183}
184
185impl SemaError {
186    pub fn eval(msg: impl Into<String>) -> Self {
187        SemaError::Eval(msg.into())
188    }
189
190    pub fn type_error(expected: impl Into<String>, got: impl Into<String>) -> Self {
191        SemaError::Type {
192            expected: expected.into(),
193            got: got.into(),
194        }
195    }
196
197    pub fn arity(name: impl Into<String>, expected: impl Into<String>, got: usize) -> Self {
198        SemaError::Arity {
199            name: name.into(),
200            expected: expected.into(),
201            got,
202        }
203    }
204
205    /// Attach a hint (actionable suggestion) to this error.
206    pub fn with_hint(self, hint: impl Into<String>) -> Self {
207        match self {
208            SemaError::WithContext { inner, note, .. } => SemaError::WithContext {
209                inner,
210                hint: Some(hint.into()),
211                note,
212            },
213            other => SemaError::WithContext {
214                inner: Box::new(other),
215                hint: Some(hint.into()),
216                note: None,
217            },
218        }
219    }
220
221    /// Attach a note (extra context) to this error.
222    pub fn with_note(self, note: impl Into<String>) -> Self {
223        match self {
224            SemaError::WithContext { inner, hint, .. } => SemaError::WithContext {
225                inner,
226                hint,
227                note: Some(note.into()),
228            },
229            other => SemaError::WithContext {
230                inner: Box::new(other),
231                hint: None,
232                note: Some(note.into()),
233            },
234        }
235    }
236
237    /// Get the hint from this error, if any.
238    pub fn hint(&self) -> Option<&str> {
239        match self {
240            SemaError::WithContext { hint, .. } => hint.as_deref(),
241            SemaError::WithTrace { inner, .. } => inner.hint(),
242            _ => None,
243        }
244    }
245
246    /// Get the note from this error, if any.
247    pub fn note(&self) -> Option<&str> {
248        match self {
249            SemaError::WithContext { note, .. } => note.as_deref(),
250            SemaError::WithTrace { inner, .. } => inner.note(),
251            _ => None,
252        }
253    }
254
255    /// Wrap this error with a stack trace (no-op if already wrapped).
256    pub fn with_stack_trace(self, trace: StackTrace) -> Self {
257        if trace.0.is_empty() {
258            return self;
259        }
260        match self {
261            SemaError::WithTrace { .. } => self,
262            SemaError::WithContext { inner, hint, note } => SemaError::WithContext {
263                inner: Box::new(inner.with_stack_trace(trace)),
264                hint,
265                note,
266            },
267            other => SemaError::WithTrace {
268                inner: Box::new(other),
269                trace,
270            },
271        }
272    }
273
274    pub fn stack_trace(&self) -> Option<&StackTrace> {
275        match self {
276            SemaError::WithTrace { trace, .. } => Some(trace),
277            SemaError::WithContext { inner, .. } => inner.stack_trace(),
278            _ => None,
279        }
280    }
281
282    pub fn inner(&self) -> &SemaError {
283        match self {
284            SemaError::WithTrace { inner, .. } => inner.inner(),
285            SemaError::WithContext { inner, .. } => inner.inner(),
286            other => other,
287        }
288    }
289}
290
291#[cfg(test)]
292mod tests {
293    use super::*;
294    use crate::Value;
295
296    // 1. Span Display
297    #[test]
298    fn span_display() {
299        let span = Span::point(1, 5);
300        assert_eq!(span.to_string(), "1:5");
301    }
302
303    // 2. StackTrace Display — file+span, file only, span only, neither
304    #[test]
305    fn stack_trace_display() {
306        let trace = StackTrace(vec![
307            CallFrame {
308                name: "foo".into(),
309                file: Some("/a/b.sema".into()),
310                span: Some(Span::point(3, 7)),
311            },
312            CallFrame {
313                name: "bar".into(),
314                file: Some("/c/d.sema".into()),
315                span: None,
316            },
317            CallFrame {
318                name: "baz".into(),
319                file: None,
320                span: Some(Span::point(10, 1)),
321            },
322            CallFrame {
323                name: "qux".into(),
324                file: None,
325                span: None,
326            },
327        ]);
328        let s = trace.to_string();
329        assert!(s.contains("at foo (/a/b.sema:3:7)"));
330        assert!(s.contains("at bar (/c/d.sema)"));
331        assert!(s.contains("at baz (<input>:10:1)"));
332        assert!(s.contains("at qux\n"));
333    }
334
335    // 3. SemaError::eval() constructor and Display
336    #[test]
337    fn eval_error() {
338        let e = SemaError::eval("something broke");
339        assert_eq!(e.to_string(), "Eval error: something broke");
340    }
341
342    // 4. SemaError::type_error() constructor and Display
343    #[test]
344    fn type_error() {
345        let e = SemaError::type_error("string", "integer");
346        assert_eq!(e.to_string(), "Type error: expected string, got integer");
347    }
348
349    // 5. SemaError::arity() constructor and Display
350    #[test]
351    fn arity_error() {
352        let e = SemaError::arity("my-fn", "2", 5);
353        assert_eq!(e.to_string(), "Arity error: my-fn expects 2 args, got 5");
354    }
355
356    // 6. with_hint attaches hint retrievable via .hint()
357    #[test]
358    fn with_hint() {
359        let e = SemaError::eval("oops").with_hint("try this");
360        assert_eq!(e.hint(), Some("try this"));
361    }
362
363    // 7. with_note attaches note retrievable via .note()
364    #[test]
365    fn with_note() {
366        let e = SemaError::eval("oops").with_note("extra info");
367        assert_eq!(e.note(), Some("extra info"));
368    }
369
370    // 8. with_hint on already-wrapped WithContext preserves note
371    #[test]
372    fn with_hint_preserves_note() {
373        let e = SemaError::eval("oops")
374            .with_note("kept note")
375            .with_hint("new hint");
376        assert_eq!(e.hint(), Some("new hint"));
377        assert_eq!(e.note(), Some("kept note"));
378    }
379
380    // 9. with_note on already-wrapped WithContext preserves hint
381    #[test]
382    fn with_note_preserves_hint() {
383        let e = SemaError::eval("oops")
384            .with_hint("kept hint")
385            .with_note("new note");
386        assert_eq!(e.hint(), Some("kept hint"));
387        assert_eq!(e.note(), Some("new note"));
388    }
389
390    // 10. with_stack_trace wraps in WithTrace, retrievable via .stack_trace()
391    #[test]
392    fn with_stack_trace() {
393        let trace = StackTrace(vec![CallFrame {
394            name: "f".into(),
395            file: None,
396            span: None,
397        }]);
398        let e = SemaError::eval("err").with_stack_trace(trace);
399        let st = e.stack_trace().expect("should have stack trace");
400        assert_eq!(st.0.len(), 1);
401        assert_eq!(st.0[0].name, "f");
402    }
403
404    // 11. with_stack_trace with empty trace is no-op
405    #[test]
406    fn with_stack_trace_empty_is_noop() {
407        let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![]));
408        assert!(e.stack_trace().is_none());
409        assert!(matches!(e, SemaError::Eval(_)));
410    }
411
412    // 12. with_stack_trace on already-wrapped WithTrace is no-op
413    #[test]
414    fn with_stack_trace_already_wrapped_is_noop() {
415        let frame = || CallFrame {
416            name: "first".into(),
417            file: None,
418            span: None,
419        };
420        let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![frame()]));
421        let e2 = e.with_stack_trace(StackTrace(vec![CallFrame {
422            name: "second".into(),
423            file: None,
424            span: None,
425        }]));
426        let st = e2.stack_trace().unwrap();
427        assert_eq!(st.0.len(), 1);
428        assert_eq!(st.0[0].name, "first");
429    }
430
431    // 13. inner() unwraps through WithTrace and WithContext
432    #[test]
433    fn inner_unwraps() {
434        let e = SemaError::eval("root")
435            .with_hint("h")
436            .with_stack_trace(StackTrace(vec![CallFrame {
437                name: "x".into(),
438                file: None,
439                span: None,
440            }]));
441        let inner = e.inner();
442        assert!(matches!(inner, SemaError::Eval(msg) if msg == "root"));
443    }
444
445    // 14. hint() and note() return None on plain errors
446    #[test]
447    fn hint_note_none_on_plain() {
448        let e = SemaError::eval("plain");
449        assert!(e.hint().is_none());
450        assert!(e.note().is_none());
451    }
452
453    // 15. check_arity! exact match passes, mismatch returns error
454    #[test]
455    fn check_arity_exact() {
456        fn run(args: &[Value]) -> Result<(), SemaError> {
457            check_arity!(args, "test-fn", 2);
458            Ok(())
459        }
460        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
461        let err = run(&[Value::nil()]).unwrap_err();
462        assert!(err.to_string().contains("test-fn"));
463        assert!(err.to_string().contains("2"));
464    }
465
466    // 16. check_arity! range match (1..=3) passes and fails
467    #[test]
468    fn check_arity_range() {
469        fn run(args: &[Value]) -> Result<(), SemaError> {
470            check_arity!(args, "range-fn", 1..=3);
471            Ok(())
472        }
473        assert!(run(&[Value::nil()]).is_ok());
474        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
475        assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
476        assert!(run(&[]).is_err());
477        assert!(run(&[Value::nil(), Value::nil(), Value::nil(), Value::nil()]).is_err());
478    }
479
480    // 17. check_arity! open range (2..) passes and fails
481    #[test]
482    fn check_arity_open_range() {
483        fn run(args: &[Value]) -> Result<(), SemaError> {
484            check_arity!(args, "open-fn", 2..);
485            Ok(())
486        }
487        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
488        assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
489        assert!(run(&[Value::nil()]).is_err());
490        assert!(run(&[]).is_err());
491    }
492}