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}{}", 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    #[test]
429    fn stack_trace_display() {
430        let trace = StackTrace(vec![
431            CallFrame {
432                name: "foo".into(),
433                file: Some("/a/b.sema".into()),
434                span: Some(Span::point(3, 7)),
435            },
436            CallFrame {
437                name: "bar".into(),
438                file: Some("/c/d.sema".into()),
439                span: None,
440            },
441            CallFrame {
442                name: "baz".into(),
443                file: None,
444                span: Some(Span::point(10, 1)),
445            },
446            CallFrame {
447                name: "qux".into(),
448                file: None,
449                span: None,
450            },
451        ]);
452        let s = trace.to_string();
453        assert!(s.contains("at foo (/a/b.sema:3:7)"));
454        assert!(s.contains("at bar (/c/d.sema)"));
455        assert!(s.contains("at baz (<input>:10:1)"));
456        assert!(s.contains("at qux\n"));
457    }
458
459    // 3. SemaError::eval() constructor and Display
460    #[test]
461    fn eval_error() {
462        let e = SemaError::eval("something broke");
463        assert_eq!(e.to_string(), "Eval error: something broke");
464    }
465
466    // 4. SemaError::type_error() constructor and Display
467    #[test]
468    fn type_error() {
469        let e = SemaError::type_error("string", "integer");
470        assert_eq!(e.to_string(), "Type error: expected string, got integer");
471    }
472
473    // 5. SemaError::arity() constructor and Display
474    #[test]
475    fn arity_error() {
476        let e = SemaError::arity("my-fn", "2", 5);
477        assert_eq!(e.to_string(), "Arity error: my-fn expects 2 args, got 5");
478    }
479
480    // 6. with_hint attaches hint retrievable via .hint()
481    #[test]
482    fn with_hint() {
483        let e = SemaError::eval("oops").with_hint("try this");
484        assert_eq!(e.hint(), Some("try this"));
485    }
486
487    // 7. with_note attaches note retrievable via .note()
488    #[test]
489    fn with_note() {
490        let e = SemaError::eval("oops").with_note("extra info");
491        assert_eq!(e.note(), Some("extra info"));
492    }
493
494    // 8. with_hint on already-wrapped WithContext preserves note
495    #[test]
496    fn with_hint_preserves_note() {
497        let e = SemaError::eval("oops")
498            .with_note("kept note")
499            .with_hint("new hint");
500        assert_eq!(e.hint(), Some("new hint"));
501        assert_eq!(e.note(), Some("kept note"));
502    }
503
504    // 9. with_note on already-wrapped WithContext preserves hint
505    #[test]
506    fn with_note_preserves_hint() {
507        let e = SemaError::eval("oops")
508            .with_hint("kept hint")
509            .with_note("new note");
510        assert_eq!(e.hint(), Some("kept hint"));
511        assert_eq!(e.note(), Some("new note"));
512    }
513
514    // 10. with_stack_trace wraps in WithTrace, retrievable via .stack_trace()
515    #[test]
516    fn with_stack_trace() {
517        let trace = StackTrace(vec![CallFrame {
518            name: "f".into(),
519            file: None,
520            span: None,
521        }]);
522        let e = SemaError::eval("err").with_stack_trace(trace);
523        let st = e.stack_trace().expect("should have stack trace");
524        assert_eq!(st.0.len(), 1);
525        assert_eq!(st.0[0].name, "f");
526    }
527
528    // 11. with_stack_trace with empty trace is no-op
529    #[test]
530    fn with_stack_trace_empty_is_noop() {
531        let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![]));
532        assert!(e.stack_trace().is_none());
533        assert!(matches!(e, SemaError::Eval(_)));
534    }
535
536    // 12. with_stack_trace on already-wrapped WithTrace is no-op
537    #[test]
538    fn with_stack_trace_already_wrapped_is_noop() {
539        let frame = || CallFrame {
540            name: "first".into(),
541            file: None,
542            span: None,
543        };
544        let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![frame()]));
545        let e2 = e.with_stack_trace(StackTrace(vec![CallFrame {
546            name: "second".into(),
547            file: None,
548            span: None,
549        }]));
550        let st = e2.stack_trace().unwrap();
551        assert_eq!(st.0.len(), 1);
552        assert_eq!(st.0[0].name, "first");
553    }
554
555    // 13. inner() unwraps through WithTrace and WithContext
556    #[test]
557    fn inner_unwraps() {
558        let e = SemaError::eval("root")
559            .with_hint("h")
560            .with_stack_trace(StackTrace(vec![CallFrame {
561                name: "x".into(),
562                file: None,
563                span: None,
564            }]));
565        let inner = e.inner();
566        assert!(matches!(inner, SemaError::Eval(msg) if msg == "root"));
567    }
568
569    // 14. hint() and note() return None on plain errors
570    #[test]
571    fn hint_note_none_on_plain() {
572        let e = SemaError::eval("plain");
573        assert!(e.hint().is_none());
574        assert!(e.note().is_none());
575    }
576
577    // 15. check_arity! exact match passes, mismatch returns error
578    #[test]
579    fn check_arity_exact() {
580        fn run(args: &[Value]) -> Result<(), SemaError> {
581            check_arity!(args, "test-fn", 2);
582            Ok(())
583        }
584        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
585        let err = run(&[Value::nil()]).unwrap_err();
586        assert!(err.to_string().contains("test-fn"));
587        assert!(err.to_string().contains("2"));
588    }
589
590    // 16. check_arity! range match (1..=3) passes and fails
591    #[test]
592    fn check_arity_range() {
593        fn run(args: &[Value]) -> Result<(), SemaError> {
594            check_arity!(args, "range-fn", 1..=3);
595            Ok(())
596        }
597        assert!(run(&[Value::nil()]).is_ok());
598        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
599        assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
600        assert!(run(&[]).is_err());
601        assert!(run(&[Value::nil(), Value::nil(), Value::nil(), Value::nil()]).is_err());
602    }
603
604    #[test]
605    fn test_suggest_similar() {
606        assert_eq!(
607            suggest_similar(
608                "strng/join",
609                &["string/join", "string/split", "map", "println"]
610            ),
611            Some("string/join".to_string())
612        );
613        assert_eq!(
614            suggest_similar("pritnln", &["println", "print", "map"]),
615            Some("println".to_string())
616        );
617        assert_eq!(suggest_similar("xyzzy", &["a", "b", "c"]), None);
618    }
619
620    // 17. check_arity! open range (2..) passes and fails
621    #[test]
622    fn check_arity_open_range() {
623        fn run(args: &[Value]) -> Result<(), SemaError> {
624            check_arity!(args, "open-fn", 2..);
625            Ok(())
626        }
627        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
628        assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
629        assert!(run(&[Value::nil()]).is_err());
630        assert!(run(&[]).is_err());
631    }
632
633    #[test]
634    fn test_veteran_hint_known() {
635        assert_eq!(
636            veteran_hint("defn"),
637            Some("Sema uses 'defun' to define named functions")
638        );
639        assert_eq!(
640            veteran_hint("setq"),
641            Some("Sema uses 'set!' for variable assignment")
642        );
643        assert_eq!(
644            veteran_hint("progn"),
645            Some("Sema uses 'begin' to sequence expressions")
646        );
647        assert_eq!(
648            veteran_hint("mapcar"),
649            Some("Sema uses 'map' for mapping over lists")
650        );
651    }
652
653    #[test]
654    fn test_veteran_hint_unknown() {
655        assert!(veteran_hint("xyzzy").is_none());
656        assert!(veteran_hint("println").is_none());
657    }
658
659    #[test]
660    fn test_veteran_hint_existing_sema_names() {
661        // Names that exist in Sema should return None
662        assert!(veteran_hint("do").is_none());
663        assert!(veteran_hint("while").is_none());
664        assert!(veteran_hint("str").is_none());
665        assert!(veteran_hint("count").is_none());
666    }
667
668    #[test]
669    fn type_error_with_value_display() {
670        let e = SemaError::type_error_with_value("string", "integer", &Value::int(42));
671        assert_eq!(
672            e.to_string(),
673            "Type error: expected string, got integer (42)"
674        );
675    }
676
677    #[test]
678    fn type_error_without_value_display() {
679        let e = SemaError::type_error("string", "integer");
680        assert_eq!(e.to_string(), "Type error: expected string, got integer");
681    }
682}