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, PartialEq, Eq)]
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}{}", got_value.as_ref().map(|v| format!(" ({v})")).unwrap_or_default())]
141    Type {
142        expected: String,
143        got: String,
144        got_value: Option<String>,
145    },
146
147    #[error("Arity error: {name} expects {expected} args, got {got}")]
148    Arity {
149        name: String,
150        expected: String,
151        got: usize,
152    },
153
154    #[error("Unbound variable: {0}")]
155    Unbound(String),
156
157    #[error("LLM error: {0}")]
158    Llm(String),
159
160    #[error("IO error: {0}")]
161    Io(String),
162
163    #[error("Permission denied: {function} requires '{capability}' capability")]
164    PermissionDenied {
165        function: String,
166        capability: String,
167    },
168
169    #[error("Permission denied: {function} — path '{path}' is outside allowed directories")]
170    PathDenied { function: String, path: String },
171
172    #[error("User exception: {0}")]
173    UserException(Value),
174
175    #[error("{inner}")]
176    WithTrace {
177        inner: Box<SemaError>,
178        trace: StackTrace,
179    },
180
181    #[error("{inner}")]
182    WithContext {
183        inner: Box<SemaError>,
184        hint: Option<String>,
185        note: Option<String>,
186    },
187}
188
189/// Compute the Levenshtein edit distance between two strings.
190fn edit_distance(a: &str, b: &str) -> usize {
191    let a_len = a.len();
192    let b_len = b.len();
193    if a_len == 0 {
194        return b_len;
195    }
196    if b_len == 0 {
197        return a_len;
198    }
199
200    let mut prev: Vec<usize> = (0..=b_len).collect();
201    let mut curr = vec![0; b_len + 1];
202
203    for (i, ca) in a.chars().enumerate() {
204        curr[0] = i + 1;
205        for (j, cb) in b.chars().enumerate() {
206            let cost = if ca == cb { 0 } else { 1 };
207            curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
208        }
209        std::mem::swap(&mut prev, &mut curr);
210    }
211    prev[b_len]
212}
213
214/// Find the most similar name from a list of candidates.
215/// Returns `None` if no candidate is close enough.
216pub fn suggest_similar(name: &str, candidates: &[&str]) -> Option<String> {
217    // Max distance threshold: roughly 1/3 of the name length, min 1, max 3
218    let threshold = (name.len() / 3).clamp(1, 3);
219
220    candidates
221        .iter()
222        .filter_map(|c| {
223            let d = edit_distance(name, c);
224            if d > 0 && d <= threshold {
225                Some((*c, d))
226            } else {
227                None
228            }
229        })
230        .min_by_key(|(_, d)| *d)
231        .map(|(name, _)| name.to_string())
232}
233
234/// Provide targeted hints for common names from other Lisp dialects.
235/// Checked before fuzzy matching to give more helpful, specific guidance.
236pub fn veteran_hint(name: &str) -> Option<&'static str> {
237    match name {
238        // Common Lisp / Emacs Lisp
239        "setq" | "setf" => Some("Sema uses 'set!' for variable assignment"),
240        "progn" => Some("Sema uses 'begin' to sequence expressions"),
241        "funcall" => Some("In Sema, functions are called directly: (f arg ...)"),
242        "mapcar" => Some("Sema uses 'map' for mapping over lists"),
243        "loop" => Some("Sema uses 'do' or 'while' for iteration, or tail recursion"),
244        "princ" | "prin1" => Some("Sema uses 'print' or 'println' for output"),
245        "format-string" => Some("Sema uses 'format' with ~a (display) and ~s (write) directives"),
246        "defvar" | "defparameter" => Some("Sema uses 'define' for variable definitions"),
247        "labels" | "flet" => Some("Sema uses 'letrec' for local recursive bindings"),
248        "block" | "return-from" => {
249            Some("Sema uses 'begin' for sequencing; use 'throw'/'try' for non-local exits")
250        }
251        "multiple-value-bind" => Some("Sema uses destructuring 'let' for multiple return values"),
252        "typep" | "type-of" => Some("Sema uses 'type' to get the type of a value"),
253
254        // Clojure
255        "defn" => Some("Sema uses 'defun' to define named functions"),
256        "atom" => Some("Sema is single-threaded; use 'define' for mutable state with 'set!'"),
257        "swap!" => Some("Sema is single-threaded; use 'set!' for mutation"),
258        "deref" => Some("Sema uses 'force' to evaluate delayed/promised values"),
259        "into" => Some("Use type-specific conversions like 'list->vector' or 'vector->list'"),
260        "conj" => Some("Sema uses 'cons' to prepend and 'append' to add to the end"),
261        "some" => Some("Sema uses 'any' to test if any element matches a predicate"),
262        "every?" => Some("Sema uses 'every' (without '?') to test if all elements match"),
263        "any?" => Some("Sema uses 'any' (without '?') to test if any element matches"),
264        "not=" => Some("Use (not (equal? a b)) for inequality in Sema"),
265
266        // Scheme / Racket
267        "define-syntax" | "syntax-rules" | "syntax-case" => {
268            Some("Sema uses 'defmacro' for macro definitions")
269        }
270        "call-with-current-continuation" | "call/cc" => Some(
271            "Sema doesn't support first-class continuations; use 'try'/'throw' for control flow",
272        ),
273        "string-join" => Some("Sema uses 'string/join' (slash-namespaced)"),
274        "string-split" => Some("Sema uses 'string/split' (slash-namespaced)"),
275        "string-trim" => Some("Sema uses 'string/trim' (slash-namespaced)"),
276        "string-contains" => Some("Sema uses 'string/contains?' (slash-namespaced, with '?')"),
277        "string-upcase" | "string-downcase" => Some("Sema uses 'string/upper' and 'string/lower'"),
278        "make-string" => Some("Sema uses 'string/repeat' to create repeated strings"),
279        "hash-ref" => Some("Sema uses 'get' to look up values in maps"),
280        "hash-set!" => Some("Sema maps are immutable; use 'assoc' to create an updated copy"),
281        "hash-map?" => Some("Sema uses 'map?' to check if a value is a map"),
282        "with-exception-handler" | "raise" => {
283            Some("Sema uses 'try'/'catch' and 'throw' for exception handling")
284        }
285
286        _ => None,
287    }
288}
289
290impl SemaError {
291    pub fn eval(msg: impl Into<String>) -> Self {
292        SemaError::Eval(msg.into())
293    }
294
295    pub fn type_error(expected: impl Into<String>, got: impl Into<String>) -> Self {
296        SemaError::Type {
297            expected: expected.into(),
298            got: got.into(),
299            got_value: None,
300        }
301    }
302
303    pub fn type_error_with_value(
304        expected: impl Into<String>,
305        got: impl Into<String>,
306        value: &Value,
307    ) -> Self {
308        let display = format!("{value}");
309        let truncated = if display.len() > 40 {
310            format!("{}…", &display[..39])
311        } else {
312            display
313        };
314        SemaError::Type {
315            expected: expected.into(),
316            got: got.into(),
317            got_value: Some(truncated),
318        }
319    }
320
321    pub fn arity(name: impl Into<String>, expected: impl Into<String>, got: usize) -> Self {
322        SemaError::Arity {
323            name: name.into(),
324            expected: expected.into(),
325            got,
326        }
327    }
328
329    /// Attach a hint (actionable suggestion) to this error.
330    pub fn with_hint(self, hint: impl Into<String>) -> Self {
331        match self {
332            SemaError::WithContext { inner, note, .. } => SemaError::WithContext {
333                inner,
334                hint: Some(hint.into()),
335                note,
336            },
337            other => SemaError::WithContext {
338                inner: Box::new(other),
339                hint: Some(hint.into()),
340                note: None,
341            },
342        }
343    }
344
345    /// Attach a note (extra context) to this error.
346    pub fn with_note(self, note: impl Into<String>) -> Self {
347        match self {
348            SemaError::WithContext { inner, hint, .. } => SemaError::WithContext {
349                inner,
350                hint,
351                note: Some(note.into()),
352            },
353            other => SemaError::WithContext {
354                inner: Box::new(other),
355                hint: None,
356                note: Some(note.into()),
357            },
358        }
359    }
360
361    /// Get the hint from this error, if any.
362    pub fn hint(&self) -> Option<&str> {
363        match self {
364            SemaError::WithContext { hint, .. } => hint.as_deref(),
365            SemaError::WithTrace { inner, .. } => inner.hint(),
366            _ => None,
367        }
368    }
369
370    /// Get the note from this error, if any.
371    pub fn note(&self) -> Option<&str> {
372        match self {
373            SemaError::WithContext { note, .. } => note.as_deref(),
374            SemaError::WithTrace { inner, .. } => inner.note(),
375            _ => None,
376        }
377    }
378
379    /// Wrap this error with a stack trace (no-op if already wrapped).
380    pub fn with_stack_trace(self, trace: StackTrace) -> Self {
381        if trace.0.is_empty() {
382            return self;
383        }
384        match self {
385            SemaError::WithTrace { .. } => self,
386            SemaError::WithContext { inner, hint, note } => SemaError::WithContext {
387                inner: Box::new(inner.with_stack_trace(trace)),
388                hint,
389                note,
390            },
391            other => SemaError::WithTrace {
392                inner: Box::new(other),
393                trace,
394            },
395        }
396    }
397
398    pub fn stack_trace(&self) -> Option<&StackTrace> {
399        match self {
400            SemaError::WithTrace { trace, .. } => Some(trace),
401            SemaError::WithContext { inner, .. } => inner.stack_trace(),
402            _ => None,
403        }
404    }
405
406    pub fn inner(&self) -> &SemaError {
407        match self {
408            SemaError::WithTrace { inner, .. } => inner.inner(),
409            SemaError::WithContext { inner, .. } => inner.inner(),
410            other => other,
411        }
412    }
413}
414
415#[cfg(test)]
416mod tests {
417    use super::*;
418    use crate::Value;
419
420    // 1. Span Display
421    #[test]
422    fn span_display() {
423        let span = Span::point(1, 5);
424        assert_eq!(span.to_string(), "1:5");
425    }
426
427    // 2. StackTrace Display — file+span, file only, span only, neither
428    //    Intentionally testing the Display format; string assertions are appropriate here.
429    #[test]
430    fn stack_trace_display() {
431        let trace = StackTrace(vec![
432            CallFrame {
433                name: "foo".into(),
434                file: Some("/a/b.sema".into()),
435                span: Some(Span::point(3, 7)),
436            },
437            CallFrame {
438                name: "bar".into(),
439                file: Some("/c/d.sema".into()),
440                span: None,
441            },
442            CallFrame {
443                name: "baz".into(),
444                file: None,
445                span: Some(Span::point(10, 1)),
446            },
447            CallFrame {
448                name: "qux".into(),
449                file: None,
450                span: None,
451            },
452        ]);
453        let s = trace.to_string();
454        assert!(s.contains("at foo (/a/b.sema:3:7)"));
455        assert!(s.contains("at bar (/c/d.sema)"));
456        assert!(s.contains("at baz (<input>:10:1)"));
457        assert!(s.contains("at qux\n"));
458    }
459
460    // 3. SemaError::eval() constructor — verify variant/fields AND display
461    #[test]
462    fn eval_error() {
463        let e = SemaError::eval("something broke");
464        // Structural check: correct variant with expected message
465        assert!(
466            matches!(&e, SemaError::Eval(msg) if msg == "something broke"),
467            "expected Eval variant with message 'something broke', got {e:?}"
468        );
469        // Display check (intentionally testing Display format)
470        assert_eq!(e.to_string(), "Eval error: something broke");
471    }
472
473    // 4. SemaError::type_error() constructor — verify variant/fields AND display
474    #[test]
475    fn type_error() {
476        let e = SemaError::type_error("string", "integer");
477        // Structural check: correct variant with expected fields
478        assert!(
479            matches!(
480                &e,
481                SemaError::Type { expected, got, got_value }
482                if expected == "string" && got == "integer" && got_value.is_none()
483            ),
484            "expected Type variant with expected='string', got='integer', got_value=None, got {e:?}"
485        );
486        // Display check (intentionally testing Display format)
487        assert_eq!(e.to_string(), "Type error: expected string, got integer");
488    }
489
490    // 5. SemaError::arity() constructor — verify variant/fields AND display
491    #[test]
492    fn arity_error() {
493        let e = SemaError::arity("my-fn", "2", 5);
494        // Structural check: correct variant with expected fields
495        assert!(
496            matches!(
497                &e,
498                SemaError::Arity { name, expected, got }
499                if name == "my-fn" && expected == "2" && *got == 5
500            ),
501            "expected Arity variant with name='my-fn', expected='2', got=5, got {e:?}"
502        );
503        // Display check (intentionally testing Display format)
504        assert_eq!(e.to_string(), "Arity error: my-fn expects 2 args, got 5");
505    }
506
507    // 6. with_hint attaches hint retrievable via .hint()
508    #[test]
509    fn with_hint() {
510        let e = SemaError::eval("oops").with_hint("try this");
511        assert_eq!(e.hint(), Some("try this"));
512    }
513
514    // 7. with_note attaches note retrievable via .note()
515    #[test]
516    fn with_note() {
517        let e = SemaError::eval("oops").with_note("extra info");
518        assert_eq!(e.note(), Some("extra info"));
519    }
520
521    // 8. with_hint on already-wrapped WithContext preserves note
522    #[test]
523    fn with_hint_preserves_note() {
524        let e = SemaError::eval("oops")
525            .with_note("kept note")
526            .with_hint("new hint");
527        assert_eq!(e.hint(), Some("new hint"));
528        assert_eq!(e.note(), Some("kept note"));
529    }
530
531    // 9. with_note on already-wrapped WithContext preserves hint
532    #[test]
533    fn with_note_preserves_hint() {
534        let e = SemaError::eval("oops")
535            .with_hint("kept hint")
536            .with_note("new note");
537        assert_eq!(e.hint(), Some("kept hint"));
538        assert_eq!(e.note(), Some("new note"));
539    }
540
541    // 10. with_stack_trace wraps in WithTrace, retrievable via .stack_trace()
542    #[test]
543    fn with_stack_trace() {
544        let trace = StackTrace(vec![CallFrame {
545            name: "f".into(),
546            file: None,
547            span: None,
548        }]);
549        let e = SemaError::eval("err").with_stack_trace(trace);
550        let st = e.stack_trace().expect("should have stack trace");
551        assert_eq!(st.0.len(), 1);
552        assert_eq!(st.0[0].name, "f");
553    }
554
555    // 11. with_stack_trace with empty trace is no-op
556    #[test]
557    fn with_stack_trace_empty_is_noop() {
558        let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![]));
559        assert!(e.stack_trace().is_none());
560        assert!(matches!(e, SemaError::Eval(_)));
561    }
562
563    // 12. with_stack_trace on already-wrapped WithTrace is no-op
564    #[test]
565    fn with_stack_trace_already_wrapped_is_noop() {
566        let frame = || CallFrame {
567            name: "first".into(),
568            file: None,
569            span: None,
570        };
571        let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![frame()]));
572        let e2 = e.with_stack_trace(StackTrace(vec![CallFrame {
573            name: "second".into(),
574            file: None,
575            span: None,
576        }]));
577        let st = e2.stack_trace().unwrap();
578        assert_eq!(st.0.len(), 1);
579        assert_eq!(st.0[0].name, "first");
580    }
581
582    // 13. inner() unwraps through WithTrace and WithContext
583    #[test]
584    fn inner_unwraps() {
585        let e = SemaError::eval("root")
586            .with_hint("h")
587            .with_stack_trace(StackTrace(vec![CallFrame {
588                name: "x".into(),
589                file: None,
590                span: None,
591            }]));
592        let inner = e.inner();
593        assert!(matches!(inner, SemaError::Eval(msg) if msg == "root"));
594    }
595
596    // 14. hint() and note() return None on plain errors
597    #[test]
598    fn hint_note_none_on_plain() {
599        let e = SemaError::eval("plain");
600        assert!(e.hint().is_none());
601        assert!(e.note().is_none());
602    }
603
604    // 15. check_arity! exact match passes, mismatch returns error
605    #[test]
606    fn check_arity_exact() {
607        fn run(args: &[Value]) -> Result<(), SemaError> {
608            check_arity!(args, "test-fn", 2);
609            Ok(())
610        }
611        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
612        let err = run(&[Value::nil()]).unwrap_err();
613        assert!(err.to_string().contains("test-fn"));
614        assert!(err.to_string().contains("2"));
615    }
616
617    // 16. check_arity! range match (1..=3) passes and fails
618    #[test]
619    fn check_arity_range() {
620        fn run(args: &[Value]) -> Result<(), SemaError> {
621            check_arity!(args, "range-fn", 1..=3);
622            Ok(())
623        }
624        assert!(run(&[Value::nil()]).is_ok());
625        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
626        assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
627        assert!(run(&[]).is_err());
628        assert!(run(&[Value::nil(), Value::nil(), Value::nil(), Value::nil()]).is_err());
629    }
630
631    #[test]
632    fn test_suggest_similar() {
633        assert_eq!(
634            suggest_similar(
635                "strng/join",
636                &["string/join", "string/split", "map", "println"]
637            ),
638            Some("string/join".to_string())
639        );
640        assert_eq!(
641            suggest_similar("pritnln", &["println", "print", "map"]),
642            Some("println".to_string())
643        );
644        assert_eq!(suggest_similar("xyzzy", &["a", "b", "c"]), None);
645    }
646
647    // 17. check_arity! open range (2..) passes and fails
648    #[test]
649    fn check_arity_open_range() {
650        fn run(args: &[Value]) -> Result<(), SemaError> {
651            check_arity!(args, "open-fn", 2..);
652            Ok(())
653        }
654        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
655        assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
656        assert!(run(&[Value::nil()]).is_err());
657        assert!(run(&[]).is_err());
658    }
659
660    #[test]
661    fn test_veteran_hint_known() {
662        assert_eq!(
663            veteran_hint("defn"),
664            Some("Sema uses 'defun' to define named functions")
665        );
666        assert_eq!(
667            veteran_hint("setq"),
668            Some("Sema uses 'set!' for variable assignment")
669        );
670        assert_eq!(
671            veteran_hint("progn"),
672            Some("Sema uses 'begin' to sequence expressions")
673        );
674        assert_eq!(
675            veteran_hint("mapcar"),
676            Some("Sema uses 'map' for mapping over lists")
677        );
678    }
679
680    #[test]
681    fn test_veteran_hint_unknown() {
682        assert!(veteran_hint("xyzzy").is_none());
683        assert!(veteran_hint("println").is_none());
684    }
685
686    #[test]
687    fn test_veteran_hint_existing_sema_names() {
688        // Names that exist in Sema should return None
689        assert!(veteran_hint("do").is_none());
690        assert!(veteran_hint("while").is_none());
691        assert!(veteran_hint("str").is_none());
692        assert!(veteran_hint("count").is_none());
693    }
694
695    // type_error_with_value constructor — verify variant/fields AND display
696    #[test]
697    fn type_error_with_value_display() {
698        let e = SemaError::type_error_with_value("string", "integer", &Value::int(42));
699        // Structural check: correct variant with got_value populated
700        assert!(
701            matches!(
702                &e,
703                SemaError::Type { expected, got, got_value }
704                if expected == "string" && got == "integer" && got_value.as_deref() == Some("42")
705            ),
706            "expected Type variant with expected='string', got='integer', got_value=Some(\"42\"), got {e:?}"
707        );
708        // Display check (intentionally testing Display format)
709        assert_eq!(
710            e.to_string(),
711            "Type error: expected string, got integer (42)"
712        );
713    }
714
715    // type_error without value — verify got_value is None AND display
716    #[test]
717    fn type_error_without_value_display() {
718        let e = SemaError::type_error("string", "integer");
719        // Structural check: got_value should be None
720        assert!(
721            matches!(
722                &e,
723                SemaError::Type { got_value, .. } if got_value.is_none()
724            ),
725            "expected Type variant with got_value=None, got {e:?}"
726        );
727        // Display check (intentionally testing Display format)
728        assert_eq!(e.to_string(), "Type error: expected string, got integer");
729    }
730}