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!("{}…", crate::text_util::truncate_chars(&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 type_error_with_value_does_not_split_multibyte_char() {
463        // A value whose display is > 40 bytes with a multi-byte char straddling
464        // byte 39 previously panicked ("byte index 39 is not a char boundary").
465        let value = Value::string(&format!("x{}", "λ".repeat(40)));
466        let e = SemaError::type_error_with_value("map", "string", &value);
467        // Must construct without panicking and carry a truncated display.
468        match e {
469            SemaError::Type { got_value, .. } => {
470                let gv = got_value.expect("got_value should be Some");
471                assert!(gv.ends_with('…'));
472            }
473            other => panic!("expected Type variant, got {other:?}"),
474        }
475    }
476
477    #[test]
478    fn eval_error() {
479        let e = SemaError::eval("something broke");
480        // Structural check: correct variant with expected message
481        assert!(
482            matches!(&e, SemaError::Eval(msg) if msg == "something broke"),
483            "expected Eval variant with message 'something broke', got {e:?}"
484        );
485        // Display check (intentionally testing Display format)
486        assert_eq!(e.to_string(), "Eval error: something broke");
487    }
488
489    // 4. SemaError::type_error() constructor — verify variant/fields AND display
490    #[test]
491    fn type_error() {
492        let e = SemaError::type_error("string", "integer");
493        // Structural check: correct variant with expected fields
494        assert!(
495            matches!(
496                &e,
497                SemaError::Type { expected, got, got_value }
498                if expected == "string" && got == "integer" && got_value.is_none()
499            ),
500            "expected Type variant with expected='string', got='integer', got_value=None, got {e:?}"
501        );
502        // Display check (intentionally testing Display format)
503        assert_eq!(e.to_string(), "Type error: expected string, got integer");
504    }
505
506    // 5. SemaError::arity() constructor — verify variant/fields AND display
507    #[test]
508    fn arity_error() {
509        let e = SemaError::arity("my-fn", "2", 5);
510        // Structural check: correct variant with expected fields
511        assert!(
512            matches!(
513                &e,
514                SemaError::Arity { name, expected, got }
515                if name == "my-fn" && expected == "2" && *got == 5
516            ),
517            "expected Arity variant with name='my-fn', expected='2', got=5, got {e:?}"
518        );
519        // Display check (intentionally testing Display format)
520        assert_eq!(e.to_string(), "Arity error: my-fn expects 2 args, got 5");
521    }
522
523    // 6. with_hint attaches hint retrievable via .hint()
524    #[test]
525    fn with_hint() {
526        let e = SemaError::eval("oops").with_hint("try this");
527        assert_eq!(e.hint(), Some("try this"));
528    }
529
530    // 7. with_note attaches note retrievable via .note()
531    #[test]
532    fn with_note() {
533        let e = SemaError::eval("oops").with_note("extra info");
534        assert_eq!(e.note(), Some("extra info"));
535    }
536
537    // 8. with_hint on already-wrapped WithContext preserves note
538    #[test]
539    fn with_hint_preserves_note() {
540        let e = SemaError::eval("oops")
541            .with_note("kept note")
542            .with_hint("new hint");
543        assert_eq!(e.hint(), Some("new hint"));
544        assert_eq!(e.note(), Some("kept note"));
545    }
546
547    // 9. with_note on already-wrapped WithContext preserves hint
548    #[test]
549    fn with_note_preserves_hint() {
550        let e = SemaError::eval("oops")
551            .with_hint("kept hint")
552            .with_note("new note");
553        assert_eq!(e.hint(), Some("kept hint"));
554        assert_eq!(e.note(), Some("new note"));
555    }
556
557    // 10. with_stack_trace wraps in WithTrace, retrievable via .stack_trace()
558    #[test]
559    fn with_stack_trace() {
560        let trace = StackTrace(vec![CallFrame {
561            name: "f".into(),
562            file: None,
563            span: None,
564        }]);
565        let e = SemaError::eval("err").with_stack_trace(trace);
566        let st = e.stack_trace().expect("should have stack trace");
567        assert_eq!(st.0.len(), 1);
568        assert_eq!(st.0[0].name, "f");
569    }
570
571    // 11. with_stack_trace with empty trace is no-op
572    #[test]
573    fn with_stack_trace_empty_is_noop() {
574        let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![]));
575        assert!(e.stack_trace().is_none());
576        assert!(matches!(e, SemaError::Eval(_)));
577    }
578
579    // 12. with_stack_trace on already-wrapped WithTrace is no-op
580    #[test]
581    fn with_stack_trace_already_wrapped_is_noop() {
582        let frame = || CallFrame {
583            name: "first".into(),
584            file: None,
585            span: None,
586        };
587        let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![frame()]));
588        let e2 = e.with_stack_trace(StackTrace(vec![CallFrame {
589            name: "second".into(),
590            file: None,
591            span: None,
592        }]));
593        let st = e2.stack_trace().unwrap();
594        assert_eq!(st.0.len(), 1);
595        assert_eq!(st.0[0].name, "first");
596    }
597
598    // 13. inner() unwraps through WithTrace and WithContext
599    #[test]
600    fn inner_unwraps() {
601        let e = SemaError::eval("root")
602            .with_hint("h")
603            .with_stack_trace(StackTrace(vec![CallFrame {
604                name: "x".into(),
605                file: None,
606                span: None,
607            }]));
608        let inner = e.inner();
609        assert!(matches!(inner, SemaError::Eval(msg) if msg == "root"));
610    }
611
612    // 14. hint() and note() return None on plain errors
613    #[test]
614    fn hint_note_none_on_plain() {
615        let e = SemaError::eval("plain");
616        assert!(e.hint().is_none());
617        assert!(e.note().is_none());
618    }
619
620    // 15. check_arity! exact match passes, mismatch returns error
621    #[test]
622    fn check_arity_exact() {
623        fn run(args: &[Value]) -> Result<(), SemaError> {
624            check_arity!(args, "test-fn", 2);
625            Ok(())
626        }
627        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
628        let err = run(&[Value::nil()]).unwrap_err();
629        assert!(err.to_string().contains("test-fn"));
630        assert!(err.to_string().contains("2"));
631    }
632
633    // 16. check_arity! range match (1..=3) passes and fails
634    #[test]
635    fn check_arity_range() {
636        fn run(args: &[Value]) -> Result<(), SemaError> {
637            check_arity!(args, "range-fn", 1..=3);
638            Ok(())
639        }
640        assert!(run(&[Value::nil()]).is_ok());
641        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
642        assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
643        assert!(run(&[]).is_err());
644        assert!(run(&[Value::nil(), Value::nil(), Value::nil(), Value::nil()]).is_err());
645    }
646
647    #[test]
648    fn test_suggest_similar() {
649        assert_eq!(
650            suggest_similar(
651                "strng/join",
652                &["string/join", "string/split", "map", "println"]
653            ),
654            Some("string/join".to_string())
655        );
656        assert_eq!(
657            suggest_similar("pritnln", &["println", "print", "map"]),
658            Some("println".to_string())
659        );
660        assert_eq!(suggest_similar("xyzzy", &["a", "b", "c"]), None);
661    }
662
663    // 17. check_arity! open range (2..) passes and fails
664    #[test]
665    fn check_arity_open_range() {
666        fn run(args: &[Value]) -> Result<(), SemaError> {
667            check_arity!(args, "open-fn", 2..);
668            Ok(())
669        }
670        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
671        assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
672        assert!(run(&[Value::nil()]).is_err());
673        assert!(run(&[]).is_err());
674    }
675
676    #[test]
677    fn test_veteran_hint_known() {
678        assert_eq!(
679            veteran_hint("defn"),
680            Some("Sema uses 'defun' to define named functions")
681        );
682        assert_eq!(
683            veteran_hint("setq"),
684            Some("Sema uses 'set!' for variable assignment")
685        );
686        assert_eq!(
687            veteran_hint("progn"),
688            Some("Sema uses 'begin' to sequence expressions")
689        );
690        assert_eq!(
691            veteran_hint("mapcar"),
692            Some("Sema uses 'map' for mapping over lists")
693        );
694    }
695
696    #[test]
697    fn test_veteran_hint_unknown() {
698        assert!(veteran_hint("xyzzy").is_none());
699        assert!(veteran_hint("println").is_none());
700    }
701
702    #[test]
703    fn test_veteran_hint_existing_sema_names() {
704        // Names that exist in Sema should return None
705        assert!(veteran_hint("do").is_none());
706        assert!(veteran_hint("while").is_none());
707        assert!(veteran_hint("str").is_none());
708        assert!(veteran_hint("count").is_none());
709    }
710
711    // type_error_with_value constructor — verify variant/fields AND display
712    #[test]
713    fn type_error_with_value_display() {
714        let e = SemaError::type_error_with_value("string", "integer", &Value::int(42));
715        // Structural check: correct variant with got_value populated
716        assert!(
717            matches!(
718                &e,
719                SemaError::Type { expected, got, got_value }
720                if expected == "string" && got == "integer" && got_value.as_deref() == Some("42")
721            ),
722            "expected Type variant with expected='string', got='integer', got_value=Some(\"42\"), got {e:?}"
723        );
724        // Display check (intentionally testing Display format)
725        assert_eq!(
726            e.to_string(),
727            "Type error: expected string, got integer (42)"
728        );
729    }
730
731    // type_error without value — verify got_value is None AND display
732    #[test]
733    fn type_error_without_value_display() {
734        let e = SemaError::type_error("string", "integer");
735        // Structural check: got_value should be None
736        assert!(
737            matches!(
738                &e,
739                SemaError::Type { got_value, .. } if got_value.is_none()
740            ),
741            "expected Type variant with got_value=None, got {e:?}"
742        );
743        // Display check (intentionally testing Display format)
744        assert_eq!(e.to_string(), "Type error: expected string, got integer");
745    }
746}