Skip to main content

sema_core/
error.rs

1use std::collections::{BTreeMap, HashMap};
2use std::fmt;
3
4use crate::runtime::{CancelReason, OperationId, RootId, ScopeId};
5use crate::value::Value;
6
7/// Check arity of a native function's arguments, returning `SemaError::Arity` on mismatch.
8///
9/// # Forms
10///
11/// ```ignore
12/// check_arity!(args, "fn-name", 2);        // exactly 2
13/// check_arity!(args, "fn-name", 1..=3);    // 1 to 3 inclusive
14/// check_arity!(args, "fn-name", 2..);      // 2 or more
15/// ```
16#[macro_export]
17macro_rules! check_arity {
18    ($args:expr, $name:expr, $exact:literal) => {
19        if $args.len() != $exact {
20            return Err($crate::SemaError::arity(
21                $name,
22                stringify!($exact),
23                $args.len(),
24            ));
25        }
26    };
27    ($args:expr, $name:expr, $lo:literal ..= $hi:literal) => {
28        if $args.len() < $lo || $args.len() > $hi {
29            return Err($crate::SemaError::arity(
30                $name,
31                concat!(stringify!($lo), "-", stringify!($hi)),
32                $args.len(),
33            ));
34        }
35    };
36    ($args:expr, $name:expr, $lo:literal ..) => {
37        if $args.len() < $lo {
38            return Err($crate::SemaError::arity(
39                $name,
40                concat!(stringify!($lo), "+"),
41                $args.len(),
42            ));
43        }
44    };
45}
46
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub struct Span {
49    pub line: usize,
50    pub col: usize,
51    pub end_line: usize,
52    pub end_col: usize,
53}
54
55impl Span {
56    /// Create a point span (start == end).
57    pub fn point(line: usize, col: usize) -> Self {
58        Span {
59            line,
60            col,
61            end_line: line,
62            end_col: col,
63        }
64    }
65
66    /// Create a span with explicit start and end.
67    pub fn new(line: usize, col: usize, end_line: usize, end_col: usize) -> Self {
68        Span {
69            line,
70            col,
71            end_line,
72            end_col,
73        }
74    }
75
76    /// Create a span from the start of `self` to the end of `other`.
77    pub fn to(self, other: &Span) -> Span {
78        Span {
79            line: self.line,
80            col: self.col,
81            end_line: other.end_line,
82            end_col: other.end_col,
83        }
84    }
85
86    /// Create a span from the start of `self` to an explicit end position.
87    pub fn with_end(self, end_line: usize, end_col: usize) -> Span {
88        Span {
89            line: self.line,
90            col: self.col,
91            end_line,
92            end_col,
93        }
94    }
95
96    /// Check if `self` fully contains `other` (inclusive bounds).
97    pub fn contains(&self, other: &Span) -> bool {
98        let inner_start = (other.line, other.col);
99        let inner_end = (other.end_line, other.end_col);
100        let outer_start = (self.line, self.col);
101        let outer_end = (self.end_line, self.end_col);
102        inner_start >= outer_start && inner_end <= outer_end
103    }
104
105    /// Check if position `(line, col)` falls within this span (inclusive).
106    pub fn contains_pos(&self, line: usize, col: usize) -> bool {
107        let pos = (line, col);
108        let start = (self.line, self.col);
109        let end = (self.end_line, self.end_col);
110        pos >= start && pos <= end
111    }
112}
113
114impl fmt::Display for Span {
115    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
116        write!(f, "{}:{}", self.line, self.col)
117    }
118}
119
120/// A single frame in a call stack trace.
121#[derive(Debug, Clone)]
122pub struct CallFrame {
123    pub name: String,
124    pub file: Option<std::path::PathBuf>,
125    pub span: Option<Span>,
126}
127
128/// A captured stack trace (list of call frames, innermost first).
129#[derive(Debug, Clone)]
130pub struct StackTrace(pub Vec<CallFrame>);
131
132impl fmt::Display for StackTrace {
133    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
134        for frame in &self.0 {
135            write!(f, "  at {}", frame.name)?;
136            match (&frame.file, &frame.span) {
137                (Some(file), Some(span)) => writeln!(f, " ({}:{span})", file.display())?,
138                (Some(file), None) => writeln!(f, " ({})", file.display())?,
139                (None, Some(span)) => writeln!(f, " (<input>:{span})")?,
140                (None, None) => writeln!(f)?,
141            }
142        }
143        Ok(())
144    }
145}
146
147/// Maps Rc pointer addresses to source spans for expression tracking.
148pub type SpanMap = HashMap<usize, Span>;
149
150/// Structured details for a policy denial.
151#[derive(Debug, Clone, PartialEq, Eq)]
152pub struct PolicyDenial {
153    pub policy: Option<String>,
154    pub boundary: String,
155    pub subject: String,
156    pub rule: String,
157    pub reason: String,
158    pub action: String,
159    pub source: String,
160}
161
162impl fmt::Display for PolicyDenial {
163    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
164        if let Some(policy) = &self.policy {
165            write!(
166                f,
167                "Policy '{policy}' denied {} '{}': {}",
168                self.boundary, self.subject, self.reason
169            )
170        } else {
171            write!(
172                f,
173                "Policy denied {} '{}': {}",
174                self.boundary, self.subject, self.reason
175            )
176        }
177    }
178}
179
180#[derive(Debug, Clone, PartialEq, Eq)]
181pub struct TypeContext {
182    pub function: String,
183    pub argument: Option<usize>,
184}
185
186#[derive(Debug, Clone, thiserror::Error)]
187pub enum SemaError {
188    #[error("Reader error at {span}: {message}")]
189    Reader { message: String, span: Span },
190
191    #[error("Eval error: {0}")]
192    Eval(String),
193
194    #[error("Type error: {}expected {expected}, got {got}{}", type_context(context.as_deref()), got_value.as_ref().map(|v| format!(" ({v})")).unwrap_or_default())]
195    Type {
196        context: Option<Box<TypeContext>>,
197        expected: String,
198        got: String,
199        got_value: Option<String>,
200    },
201
202    #[error(
203        "Arity error: {name} expects {}, got {got}",
204        format_expected_arity(expected)
205    )]
206    Arity {
207        name: String,
208        expected: String,
209        got: usize,
210    },
211
212    #[error("Unbound variable: {0}")]
213    Unbound(String),
214
215    #[error("LLM error: {0}")]
216    Llm(String),
217
218    #[error("IO error: {0}")]
219    Io(String),
220
221    #[error("Permission denied: {function} requires '{capability}' capability")]
222    PermissionDenied {
223        function: String,
224        capability: String,
225    },
226
227    #[error("Permission denied: {function} — path '{path}' is outside allowed directories")]
228    PathDenied { function: String, path: String },
229
230    #[error("{0}")]
231    PolicyDenied(Box<PolicyDenial>),
232
233    /// Internal workflow control transfer emitted after a durable approval request has
234    /// been created. This is deliberately not catchable by Sema `try`/`catch`; only the
235    /// enclosing `workflow/run` consumes it and returns a `:needs-approval` envelope.
236    #[error("workflow approval required: {approval_id}")]
237    WorkflowApprovalRequired { approval_id: String },
238
239    /// Internal workflow control transfer emitted when a durable rejection is observed.
240    /// Like [`Self::WorkflowApprovalRequired`], user code cannot catch it and continue
241    /// past the protected action.
242    #[error("workflow approval rejected: {approval_id}")]
243    WorkflowApprovalRejected {
244        approval_id: String,
245        reason: Option<String>,
246    },
247
248    /// Fail-closed approval infrastructure or placement error. It is host-owned and
249    /// uncatchable for the same reason as pending/rejected controls: user code must not
250    /// continue to the protected action after authority validation fails.
251    #[error("workflow approval failed: {message}")]
252    WorkflowApprovalFailed { message: String },
253
254    #[error("Internal error: {0}")]
255    Internal(String),
256
257    #[error("User exception: {0}")]
258    UserException(Value),
259
260    /// A re-raised condition map — the `{:type ... :message ...}` value a
261    /// `catch`/`guard` handler was bound to, thrown again. Kept as that map
262    /// verbatim so catching and re-throwing is idempotent: N nested
263    /// `(catch e ... (throw e))` guards surface the same condition as one.
264    /// Displays as the condition's `:message` so the top-level report reads
265    /// like the original error, not a stringified map.
266    #[error("{}", condition_message(.0))]
267    Condition(Value),
268
269    #[error("{inner}")]
270    WithTrace {
271        inner: Box<SemaError>,
272        trace: StackTrace,
273    },
274
275    #[error("{inner}")]
276    WithContext {
277        inner: Box<SemaError>,
278        hint: Option<String>,
279        note: Option<String>,
280    },
281}
282
283fn type_context(context: Option<&TypeContext>) -> String {
284    match context {
285        Some(TypeContext {
286            function,
287            argument: Some(argument),
288        }) => format!("{function} argument {argument} "),
289        Some(TypeContext {
290            function,
291            argument: None,
292        }) => format!("{function} "),
293        None => String::new(),
294    }
295}
296
297fn format_expected_arity(expected: &str) -> String {
298    if let Some(minimum) = expected.strip_suffix('+') {
299        return format!("{minimum} or more arguments");
300    }
301    if let Some((minimum, maximum)) = expected.split_once('-') {
302        return format!("{minimum} to {maximum} arguments");
303    }
304    if expected.contains(" or ") {
305        return format!("{expected} arguments");
306    }
307    match expected {
308        "0" => "no arguments".to_string(),
309        "1" => "1 argument".to_string(),
310        _ => format!("{expected} arguments"),
311    }
312}
313
314fn type_message(
315    context: Option<&TypeContext>,
316    expected: &str,
317    got: &str,
318    got_value: Option<&str>,
319) -> String {
320    let value = got_value.map_or_else(String::new, |value| format!(" ({value})"));
321    format!(
322        "{}expected {expected}, got {got}{value}",
323        type_context(context)
324    )
325}
326
327/// Compute the Levenshtein edit distance between two strings.
328fn edit_distance(a: &str, b: &str) -> usize {
329    let a_len = a.len();
330    let b_len = b.len();
331    if a_len == 0 {
332        return b_len;
333    }
334    if b_len == 0 {
335        return a_len;
336    }
337
338    let mut prev: Vec<usize> = (0..=b_len).collect();
339    let mut curr = vec![0; b_len + 1];
340
341    for (i, ca) in a.chars().enumerate() {
342        curr[0] = i + 1;
343        for (j, cb) in b.chars().enumerate() {
344            let cost = if ca == cb { 0 } else { 1 };
345            curr[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(curr[j] + 1);
346        }
347        std::mem::swap(&mut prev, &mut curr);
348    }
349    prev[b_len]
350}
351
352/// Find the most similar name from a list of candidates.
353/// Returns `None` if no candidate is close enough.
354pub fn suggest_similar(name: &str, candidates: &[&str]) -> Option<String> {
355    // Max distance threshold: roughly 1/3 of the name length, min 1, max 3
356    let threshold = (name.len() / 3).clamp(1, 3);
357
358    candidates
359        .iter()
360        .filter_map(|c| {
361            let d = edit_distance(name, c);
362            if d > 0 && d <= threshold {
363                Some((*c, d))
364            } else {
365                None
366            }
367        })
368        .min_by_key(|(_, d)| *d)
369        .map(|(name, _)| name.to_string())
370}
371
372/// Provide targeted hints for common names from other Lisp dialects.
373/// Checked before fuzzy matching to give more helpful, specific guidance.
374///
375/// Only for names Sema does *not* have. Names accepted as aliases (`defn`,
376/// `progn`, `def`, `fn`) belong in the special-form table, not here — a hint
377/// redirecting away from a working form is worse than no hint.
378pub fn veteran_hint(name: &str) -> Option<&'static str> {
379    match name {
380        // Common Lisp / Emacs Lisp
381        "setq" | "setf" => Some("Sema uses 'set!' for variable assignment"),
382        "funcall" => Some("In Sema, functions are called directly: (f arg ...)"),
383        "mapcar" => Some("Sema uses 'map' for mapping over lists"),
384        "loop" => Some("Sema uses 'do' or 'while' for iteration, or tail recursion"),
385        "princ" | "prin1" => Some("Sema uses 'print' or 'println' for output"),
386        "format-string" => Some("Sema uses 'format' with ~a (display) and ~s (write) directives"),
387        "defvar" | "defparameter" => Some("Sema uses 'define' for variable definitions"),
388        "labels" | "flet" => Some("Sema uses 'letrec' for local recursive bindings"),
389        "block" | "return-from" => {
390            Some("Sema uses 'begin' for sequencing; use 'throw'/'try' for non-local exits")
391        }
392        "multiple-value-bind" => Some("Sema uses destructuring 'let' for multiple return values"),
393        "typep" | "type-of" => Some("Sema uses 'type' to get the type of a value"),
394
395        // Clojure
396        "atom" => Some("Sema is single-threaded; use 'define' for mutable state with 'set!'"),
397        "swap!" => Some("Sema is single-threaded; use 'set!' for mutation"),
398        "deref" => Some("Sema uses 'force' to evaluate delayed/promised values"),
399        "into" => Some("Use type-specific conversions like 'list->vector' or 'vector->list'"),
400        "conj" => Some("Sema uses 'cons' to prepend and 'append' to add to the end"),
401        "some" => Some("Sema uses 'any' to test if any element matches a predicate"),
402        "every?" => Some("Sema uses 'every' (without '?') to test if all elements match"),
403        "any?" => Some("Sema uses 'any' (without '?') to test if any element matches"),
404        "not=" => Some("Use (not (equal? a b)) for inequality in Sema"),
405
406        // Scheme / Racket
407        "syntax-case" => {
408            Some("Sema supports 'define-syntax' with 'syntax-rules', or use 'defmacro'")
409        }
410        "call-with-current-continuation" | "call/cc" => Some(
411            "Sema doesn't support first-class continuations; use 'try'/'throw' for control flow",
412        ),
413        "string-join" => Some("Sema uses 'string/join' (slash-namespaced)"),
414        "string-split" => Some("Sema uses 'string/split' (slash-namespaced)"),
415        "string-trim" => Some("Sema uses 'string/trim' (slash-namespaced)"),
416        "string-contains" => Some("Sema uses 'string/contains?' (slash-namespaced, with '?')"),
417        "string-upcase" | "string-downcase" => Some("Sema uses 'string/upper' and 'string/lower'"),
418        "make-string" => Some("Sema uses 'string/repeat' to create repeated strings"),
419        "hash-ref" => Some("Sema uses 'get' to look up values in maps"),
420        "hash-set!" => Some("Sema maps are immutable; use 'assoc' to create an updated copy"),
421        "hash-map?" => Some("Sema uses 'map?' to check if a value is a map"),
422        "with-exception-handler" => {
423            Some("Sema uses 'try'/'catch', 'throw'/'raise', and 'guard' for exception handling")
424        }
425
426        _ => None,
427    }
428}
429
430/// The `:type` keywords `error_to_value` puts on condition maps. A thrown map
431/// is only treated as a re-raised condition when its `:type` is one of these
432/// (and `:message` is a string) — user data maps that merely resemble a
433/// condition keep the wrap-as-user-exception behavior.
434const CONDITION_TYPES: &[&str] = &[
435    "eval",
436    "type-error",
437    "arity",
438    "unbound",
439    "user",
440    "io",
441    "llm",
442    "reader",
443    "permission-denied",
444    "policy-denied",
445    "internal",
446    "cancelled",
447    "timeout",
448];
449
450/// The `:message` of a condition map, for `Display` of `SemaError::Condition`.
451/// Falls back to the whole map's printed form if the shape is unexpected.
452fn condition_message(condition: &Value) -> String {
453    condition
454        .as_map_ref()
455        .and_then(|m| m.get(&Value::keyword("message")))
456        .and_then(|msg| msg.as_str().map(str::to_string))
457        .unwrap_or_else(|| condition.to_string())
458}
459
460/// True when `value` has the exact shape of a caught condition map:
461/// a map with a known keyword `:type` and a string `:message`.
462fn is_condition_map(value: &Value) -> bool {
463    let Some(map) = value.as_map_ref() else {
464        return false;
465    };
466    let known_type = map
467        .get(&Value::keyword("type"))
468        .and_then(|t| t.as_keyword())
469        .is_some_and(|kw| CONDITION_TYPES.contains(&kw.as_str()));
470    known_type
471        && map
472            .get(&Value::keyword("message"))
473            .is_some_and(|m| m.as_str().is_some())
474}
475
476fn cancel_reason_keyword(reason: CancelReason) -> &'static str {
477    match reason {
478        CancelReason::Root => "root",
479        CancelReason::Owner => "owner",
480        CancelReason::Explicit => "explicit",
481        CancelReason::Timeout => "timeout",
482        CancelReason::HostStop => "host-stop",
483        CancelReason::ResourceDisconnect => "resource-disconnect",
484        CancelReason::InterpreterShutdown => "interpreter-shutdown",
485    }
486}
487
488fn insert_decimal(condition: &mut BTreeMap<Value, Value>, key: &str, value: Option<u64>) {
489    if let Some(value) = value {
490        condition.insert(Value::keyword(key), Value::int(value as i64));
491    }
492}
493
494fn insert_optional_string(condition: &mut BTreeMap<Value, Value>, key: &str, value: Option<&str>) {
495    if let Some(value) = value {
496        condition.insert(Value::keyword(key), Value::string(value));
497    }
498}
499
500impl SemaError {
501    pub fn eval(msg: impl Into<String>) -> Self {
502        SemaError::Eval(msg.into())
503    }
504
505    pub fn policy_denied(denial: PolicyDenial) -> Self {
506        let rule = denial.rule.clone();
507        SemaError::PolicyDenied(Box::new(denial)).with_note(format!("policy rule: {rule}"))
508    }
509
510    /// Whether this error is a host-owned control transfer that language-level exception
511    /// handlers must not intercept.
512    pub fn is_uncatchable(&self) -> bool {
513        matches!(
514            self.inner(),
515            SemaError::WorkflowApprovalRequired { .. }
516                | SemaError::WorkflowApprovalRejected { .. }
517                | SemaError::WorkflowApprovalFailed { .. }
518        )
519    }
520
521    pub fn internal(message: impl Into<String>) -> Self {
522        SemaError::Internal(message.into())
523            .with_hint("report this as a Sema bug and include the stack trace")
524    }
525
526    #[allow(clippy::too_many_arguments)]
527    pub fn cancelled_condition(
528        message: &str,
529        reason: CancelReason,
530        root_id: Option<RootId>,
531        scope_id: Option<ScopeId>,
532        operation_id: Option<OperationId>,
533        operation: Option<&str>,
534        duration_ms: Option<u64>,
535        resource_kind: Option<&str>,
536    ) -> Self {
537        let mut condition = BTreeMap::from([
538            (Value::keyword("type"), Value::keyword("cancelled")),
539            (Value::keyword("message"), Value::string(message)),
540            (
541                Value::keyword("reason"),
542                Value::keyword(cancel_reason_keyword(reason)),
543            ),
544        ]);
545        insert_decimal(&mut condition, "root-id", root_id.map(RootId::get));
546        insert_decimal(&mut condition, "scope-id", scope_id.map(ScopeId::get));
547        insert_decimal(
548            &mut condition,
549            "operation-id",
550            operation_id.map(OperationId::get),
551        );
552        insert_optional_string(&mut condition, "operation", operation);
553        insert_decimal(&mut condition, "duration-ms", duration_ms);
554        insert_optional_string(&mut condition, "resource-kind", resource_kind);
555        SemaError::Condition(Value::map(condition))
556    }
557
558    pub fn timeout_condition(
559        message: &str,
560        operation: &str,
561        duration_ms: u64,
562        operation_id: Option<OperationId>,
563    ) -> Self {
564        let mut condition = BTreeMap::from([
565            (Value::keyword("type"), Value::keyword("timeout")),
566            (Value::keyword("message"), Value::string(message)),
567            (Value::keyword("operation"), Value::string(operation)),
568            (
569                Value::keyword("duration-ms"),
570                Value::int(duration_ms as i64),
571            ),
572        ]);
573        insert_decimal(
574            &mut condition,
575            "operation-id",
576            operation_id.map(OperationId::get),
577        );
578        SemaError::Condition(Value::map(condition))
579    }
580
581    /// The error a `throw`/`raise` of `value` raises: a caught condition map
582    /// re-raises as itself (`Condition`) so nested catch/re-throw guards don't
583    /// wrap it again per layer; anything else is a fresh `UserException`.
584    pub fn from_thrown(value: Value) -> Self {
585        if is_condition_map(&value) {
586            SemaError::Condition(value)
587        } else {
588            SemaError::UserException(value)
589        }
590    }
591
592    pub fn type_error(expected: impl Into<String>, got: impl Into<String>) -> Self {
593        SemaError::Type {
594            context: None,
595            expected: expected.into(),
596            got: got.into(),
597            got_value: None,
598        }
599    }
600
601    pub fn type_error_with_value(
602        expected: impl Into<String>,
603        got: impl Into<String>,
604        value: &Value,
605    ) -> Self {
606        SemaError::Type {
607            context: None,
608            expected: expected.into(),
609            got: got.into(),
610            got_value: Some(Self::value_preview(value)),
611        }
612    }
613
614    pub fn argument_type(
615        function: impl Into<String>,
616        argument: usize,
617        expected: impl Into<String>,
618        value: &Value,
619    ) -> Self {
620        SemaError::Type {
621            context: Some(Box::new(TypeContext {
622                function: function.into(),
623                argument: Some(argument),
624            })),
625            expected: expected.into(),
626            got: value.type_name().to_string(),
627            got_value: None,
628        }
629    }
630
631    pub fn argument_type_with_value(
632        function: impl Into<String>,
633        argument: usize,
634        expected: impl Into<String>,
635        value: &Value,
636    ) -> Self {
637        let mut error = Self::argument_type(function, argument, expected, value);
638        if let SemaError::Type { got_value, .. } = &mut error {
639            *got_value = Some(Self::value_preview(value));
640        }
641        error
642    }
643
644    fn value_preview(value: &Value) -> String {
645        let display = format!("{value}");
646        if display.len() > 40 {
647            format!("{}…", crate::text_util::truncate_chars(&display, 39))
648        } else {
649            display
650        }
651    }
652
653    pub fn arity(name: impl Into<String>, expected: impl Into<String>, got: usize) -> Self {
654        SemaError::Arity {
655            name: name.into(),
656            expected: expected.into(),
657            got,
658        }
659    }
660
661    /// Attach a hint (actionable suggestion) to this error.
662    pub fn with_hint(self, hint: impl Into<String>) -> Self {
663        match self {
664            SemaError::WithContext { inner, note, .. } => SemaError::WithContext {
665                inner,
666                hint: Some(hint.into()),
667                note,
668            },
669            other => SemaError::WithContext {
670                inner: Box::new(other),
671                hint: Some(hint.into()),
672                note: None,
673            },
674        }
675    }
676
677    /// Attach a note (extra context) to this error.
678    pub fn with_note(self, note: impl Into<String>) -> Self {
679        match self {
680            SemaError::WithContext { inner, hint, .. } => SemaError::WithContext {
681                inner,
682                hint,
683                note: Some(note.into()),
684            },
685            other => SemaError::WithContext {
686                inner: Box::new(other),
687                hint: None,
688                note: Some(note.into()),
689            },
690        }
691    }
692
693    /// Get the hint from this error, if any.
694    pub fn hint(&self) -> Option<&str> {
695        match self {
696            SemaError::WithContext { hint, .. } => hint.as_deref(),
697            SemaError::WithTrace { inner, .. } => inner.hint(),
698            _ => None,
699        }
700    }
701
702    /// Get the note from this error, if any.
703    pub fn note(&self) -> Option<&str> {
704        match self {
705            SemaError::WithContext { note, .. } => note.as_deref(),
706            SemaError::WithTrace { inner, .. } => inner.note(),
707            _ => None,
708        }
709    }
710
711    /// Wrap this error with a stack trace (no-op if already wrapped).
712    pub fn with_stack_trace(self, trace: StackTrace) -> Self {
713        if trace.0.is_empty() {
714            return self;
715        }
716        match self {
717            SemaError::WithTrace { .. } => self,
718            SemaError::WithContext { inner, hint, note } => SemaError::WithContext {
719                inner: Box::new(inner.with_stack_trace(trace)),
720                hint,
721                note,
722            },
723            other => SemaError::WithTrace {
724                inner: Box::new(other),
725                trace,
726            },
727        }
728    }
729
730    /// Fill in `file` on any trace frame that lacks one (no-op without a trace).
731    ///
732    /// Lowering errors synthesize frames with `file: None` because the lowering
733    /// pass doesn't know the source path; the compile entry points (which do)
734    /// stamp it here so traces render the real filename instead of `<input>`.
735    /// Frames that already carry a file are left untouched.
736    pub fn fill_trace_file(self, file: &std::path::Path) -> Self {
737        match self {
738            SemaError::WithTrace { inner, mut trace } => {
739                for frame in &mut trace.0 {
740                    if frame.file.is_none() {
741                        frame.file = Some(file.to_path_buf());
742                    }
743                }
744                SemaError::WithTrace { inner, trace }
745            }
746            SemaError::WithContext { inner, hint, note } => SemaError::WithContext {
747                inner: Box::new(inner.fill_trace_file(file)),
748                hint,
749                note,
750            },
751            other => other,
752        }
753    }
754
755    pub fn stack_trace(&self) -> Option<&StackTrace> {
756        match self {
757            SemaError::WithTrace { trace, .. } => Some(trace),
758            SemaError::WithContext { inner, .. } => inner.stack_trace(),
759            _ => None,
760        }
761    }
762
763    pub fn inner(&self) -> &SemaError {
764        match self {
765            SemaError::WithTrace { inner, .. } => inner.inner(),
766            SemaError::WithContext { inner, .. } => inner.inner(),
767            other => other,
768        }
769    }
770
771    /// Return the primary user-facing message without wrapper prefixes.
772    pub fn user_message(&self) -> String {
773        match self.inner() {
774            SemaError::Reader { message, .. } | SemaError::Eval(message) => message.clone(),
775            SemaError::Type {
776                context,
777                expected,
778                got,
779                got_value,
780            } => type_message(context.as_deref(), expected, got, got_value.as_deref()),
781            SemaError::Arity {
782                name,
783                expected,
784                got,
785            } => format!(
786                "{name} expects {}, got {got}",
787                format_expected_arity(expected)
788            ),
789            SemaError::Unbound(name) => format!("Unbound variable: {name}"),
790            SemaError::Llm(message) => format!("LLM error: {message}"),
791            SemaError::Io(message) => format!("I/O error: {message}"),
792            SemaError::PermissionDenied {
793                function,
794                capability,
795            } => format!("Permission denied: {function} requires '{capability}' capability"),
796            SemaError::PathDenied { function, path } => format!(
797                "Permission denied: {function} — path '{path}' is outside allowed directories"
798            ),
799            SemaError::PolicyDenied(denial) => denial.to_string(),
800            SemaError::WorkflowApprovalRequired { approval_id } => {
801                format!("workflow approval required: {approval_id}")
802            }
803            SemaError::WorkflowApprovalRejected {
804                approval_id,
805                reason,
806            } => reason.as_ref().map_or_else(
807                || format!("workflow approval rejected: {approval_id}"),
808                |reason| format!("workflow approval rejected: {approval_id}: {reason}"),
809            ),
810            SemaError::WorkflowApprovalFailed { message } => {
811                format!("workflow approval failed: {message}")
812            }
813            SemaError::Internal(message) => format!("Internal error: {message}"),
814            SemaError::UserException(value) => format!("User exception: {value}"),
815            SemaError::Condition(condition) => condition_message(condition),
816            SemaError::WithTrace { .. } | SemaError::WithContext { .. } => {
817                unreachable!("inner() already unwraps wrappers")
818            }
819        }
820    }
821
822    /// Format a diagnostic message without source location or stack frames.
823    pub fn format_diagnostic(&self) -> String {
824        let mut message = self.user_message();
825        if let Some(hint) = self.hint() {
826            message.push_str("\n  hint: ");
827            message.push_str(hint);
828        }
829        if let Some(note) = self.note() {
830            message.push_str("\n  note: ");
831            message.push_str(note);
832        }
833        message
834    }
835
836    /// Format an error for a plain-text channel.
837    pub fn format_plain(&self) -> String {
838        let mut message = self.user_message();
839        if let SemaError::Reader { span, .. } = self.inner() {
840            message.push_str(&format!("\n  at <input>:{span}"));
841        }
842        if let Some(trace) = self.stack_trace() {
843            message.push('\n');
844            message.push_str(trace.to_string().trim_end());
845        }
846        if let Some(hint) = self.hint() {
847            message.push_str("\n  hint: ");
848            message.push_str(hint);
849        }
850        if let Some(note) = self.note() {
851            message.push_str("\n  note: ");
852            message.push_str(note);
853        }
854        message
855    }
856}
857
858#[cfg(test)]
859mod tests {
860    use super::*;
861    use crate::Value;
862
863    // 1. Span Display
864    #[test]
865    fn span_display() {
866        let span = Span::point(1, 5);
867        assert_eq!(span.to_string(), "1:5");
868    }
869
870    // 2. StackTrace Display — file+span, file only, span only, neither
871    //    Intentionally testing the Display format; string assertions are appropriate here.
872    #[test]
873    fn stack_trace_display() {
874        let trace = StackTrace(vec![
875            CallFrame {
876                name: "foo".into(),
877                file: Some("/a/b.sema".into()),
878                span: Some(Span::point(3, 7)),
879            },
880            CallFrame {
881                name: "bar".into(),
882                file: Some("/c/d.sema".into()),
883                span: None,
884            },
885            CallFrame {
886                name: "baz".into(),
887                file: None,
888                span: Some(Span::point(10, 1)),
889            },
890            CallFrame {
891                name: "qux".into(),
892                file: None,
893                span: None,
894            },
895        ]);
896        let s = trace.to_string();
897        assert!(s.contains("at foo (/a/b.sema:3:7)"));
898        assert!(s.contains("at bar (/c/d.sema)"));
899        assert!(s.contains("at baz (<input>:10:1)"));
900        assert!(s.contains("at qux\n"));
901    }
902
903    // 3. SemaError::eval() constructor — verify variant/fields AND display
904    #[test]
905    fn type_error_with_value_does_not_split_multibyte_char() {
906        // A value whose display is > 40 bytes with a multi-byte char straddling
907        // byte 39: truncating at a raw byte index would split the char ("byte
908        // index 39 is not a char boundary"), so truncation must land on a boundary.
909        let value = Value::string(&format!("x{}", "λ".repeat(40)));
910        let e = SemaError::type_error_with_value("map", "string", &value);
911        // Must construct without panicking and carry a truncated display.
912        match e {
913            SemaError::Type { got_value, .. } => {
914                let gv = got_value.expect("got_value should be Some");
915                assert!(gv.ends_with('…'));
916            }
917            other => panic!("expected Type variant, got {other:?}"),
918        }
919    }
920
921    #[test]
922    fn eval_error() {
923        let e = SemaError::eval("something broke");
924        // Structural check: correct variant with expected message
925        assert!(
926            matches!(&e, SemaError::Eval(msg) if msg == "something broke"),
927            "expected Eval variant with message 'something broke', got {e:?}"
928        );
929        // Display check (intentionally testing Display format)
930        assert_eq!(e.to_string(), "Eval error: something broke");
931    }
932
933    // 4. SemaError::type_error() constructor — verify variant/fields AND display
934    #[test]
935    fn type_error() {
936        let e = SemaError::type_error("string", "integer");
937        // Structural check: correct variant with expected fields
938        assert!(
939            matches!(
940                &e,
941                SemaError::Type { expected, got, got_value, .. }
942                if expected == "string" && got == "integer" && got_value.is_none()
943            ),
944            "expected Type variant with expected='string', got='integer', got_value=None, got {e:?}"
945        );
946        // Display check (intentionally testing Display format)
947        assert_eq!(e.to_string(), "Type error: expected string, got integer");
948    }
949
950    // 5. SemaError::arity() constructor — verify variant/fields AND display
951    #[test]
952    fn arity_error() {
953        let e = SemaError::arity("my-fn", "2", 5);
954        // Structural check: correct variant with expected fields
955        assert!(
956            matches!(
957                &e,
958                SemaError::Arity { name, expected, got }
959                if name == "my-fn" && expected == "2" && *got == 5
960            ),
961            "expected Arity variant with name='my-fn', expected='2', got=5, got {e:?}"
962        );
963        // Display check (intentionally testing Display format)
964        assert_eq!(
965            e.to_string(),
966            "Arity error: my-fn expects 2 arguments, got 5"
967        );
968    }
969
970    // 6. with_hint attaches hint retrievable via .hint()
971    #[test]
972    fn with_hint() {
973        let e = SemaError::eval("oops").with_hint("try this");
974        assert_eq!(e.hint(), Some("try this"));
975    }
976
977    // 7. with_note attaches note retrievable via .note()
978    #[test]
979    fn with_note() {
980        let e = SemaError::eval("oops").with_note("extra info");
981        assert_eq!(e.note(), Some("extra info"));
982    }
983
984    // 8. with_hint on already-wrapped WithContext preserves note
985    #[test]
986    fn with_hint_preserves_note() {
987        let e = SemaError::eval("oops")
988            .with_note("kept note")
989            .with_hint("new hint");
990        assert_eq!(e.hint(), Some("new hint"));
991        assert_eq!(e.note(), Some("kept note"));
992    }
993
994    // 9. with_note on already-wrapped WithContext preserves hint
995    #[test]
996    fn with_note_preserves_hint() {
997        let e = SemaError::eval("oops")
998            .with_hint("kept hint")
999            .with_note("new note");
1000        assert_eq!(e.hint(), Some("kept hint"));
1001        assert_eq!(e.note(), Some("new note"));
1002    }
1003
1004    // 10. with_stack_trace wraps in WithTrace, retrievable via .stack_trace()
1005    #[test]
1006    fn with_stack_trace() {
1007        let trace = StackTrace(vec![CallFrame {
1008            name: "f".into(),
1009            file: None,
1010            span: None,
1011        }]);
1012        let e = SemaError::eval("err").with_stack_trace(trace);
1013        let st = e.stack_trace().expect("should have stack trace");
1014        assert_eq!(st.0.len(), 1);
1015        assert_eq!(st.0[0].name, "f");
1016    }
1017
1018    // 11. with_stack_trace with empty trace is no-op
1019    #[test]
1020    fn with_stack_trace_empty_is_noop() {
1021        let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![]));
1022        assert!(e.stack_trace().is_none());
1023        assert!(matches!(e, SemaError::Eval(_)));
1024    }
1025
1026    // 12. with_stack_trace on already-wrapped WithTrace is no-op
1027    #[test]
1028    fn with_stack_trace_already_wrapped_is_noop() {
1029        let frame = || CallFrame {
1030            name: "first".into(),
1031            file: None,
1032            span: None,
1033        };
1034        let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![frame()]));
1035        let e2 = e.with_stack_trace(StackTrace(vec![CallFrame {
1036            name: "second".into(),
1037            file: None,
1038            span: None,
1039        }]));
1040        let st = e2.stack_trace().unwrap();
1041        assert_eq!(st.0.len(), 1);
1042        assert_eq!(st.0[0].name, "first");
1043    }
1044
1045    // fill_trace_file fills only `file: None` frames, recurses through
1046    // WithContext, and is a no-op without a trace.
1047    #[test]
1048    fn fill_trace_file_fills_missing_files() {
1049        let e = SemaError::eval("err")
1050            .with_stack_trace(StackTrace(vec![
1051                CallFrame {
1052                    name: "bare".into(),
1053                    file: None,
1054                    span: None,
1055                },
1056                CallFrame {
1057                    name: "stamped".into(),
1058                    file: Some("already.sema".into()),
1059                    span: None,
1060                },
1061            ]))
1062            .with_hint("h");
1063        let e = e.fill_trace_file(std::path::Path::new("main.sema"));
1064        assert_eq!(e.hint(), Some("h"));
1065        let st = e.stack_trace().unwrap();
1066        assert_eq!(
1067            st.0[0].file.as_deref(),
1068            Some(std::path::Path::new("main.sema"))
1069        );
1070        assert_eq!(
1071            st.0[1].file.as_deref(),
1072            Some(std::path::Path::new("already.sema"))
1073        );
1074    }
1075
1076    #[test]
1077    fn fill_trace_file_noop_without_trace() {
1078        let e = SemaError::eval("err").fill_trace_file(std::path::Path::new("main.sema"));
1079        assert!(e.stack_trace().is_none());
1080        assert!(matches!(e, SemaError::Eval(_)));
1081    }
1082
1083    // 13. inner() unwraps through WithTrace and WithContext
1084    #[test]
1085    fn inner_unwraps() {
1086        let e = SemaError::eval("root")
1087            .with_hint("h")
1088            .with_stack_trace(StackTrace(vec![CallFrame {
1089                name: "x".into(),
1090                file: None,
1091                span: None,
1092            }]));
1093        let inner = e.inner();
1094        assert!(matches!(inner, SemaError::Eval(msg) if msg == "root"));
1095    }
1096
1097    // 14. hint() and note() return None on plain errors
1098    #[test]
1099    fn hint_note_none_on_plain() {
1100        let e = SemaError::eval("plain");
1101        assert!(e.hint().is_none());
1102        assert!(e.note().is_none());
1103    }
1104
1105    // 15. check_arity! exact match passes, mismatch returns error
1106    #[test]
1107    fn check_arity_exact() {
1108        fn run(args: &[Value]) -> Result<(), SemaError> {
1109            check_arity!(args, "test-fn", 2);
1110            Ok(())
1111        }
1112        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
1113        let err = run(&[Value::nil()]).unwrap_err();
1114        assert!(err.to_string().contains("test-fn"));
1115        assert!(err.to_string().contains("2"));
1116    }
1117
1118    // 16. check_arity! range match (1..=3) passes and fails
1119    #[test]
1120    fn check_arity_range() {
1121        fn run(args: &[Value]) -> Result<(), SemaError> {
1122            check_arity!(args, "range-fn", 1..=3);
1123            Ok(())
1124        }
1125        assert!(run(&[Value::nil()]).is_ok());
1126        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
1127        assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
1128        assert!(run(&[]).is_err());
1129        assert!(run(&[Value::nil(), Value::nil(), Value::nil(), Value::nil()]).is_err());
1130    }
1131
1132    #[test]
1133    fn test_suggest_similar() {
1134        assert_eq!(
1135            suggest_similar(
1136                "strng/join",
1137                &["string/join", "string/split", "map", "println"]
1138            ),
1139            Some("string/join".to_string())
1140        );
1141        assert_eq!(
1142            suggest_similar("pritnln", &["println", "print", "map"]),
1143            Some("println".to_string())
1144        );
1145        assert_eq!(suggest_similar("xyzzy", &["a", "b", "c"]), None);
1146    }
1147
1148    // 17. check_arity! open range (2..) passes and fails
1149    #[test]
1150    fn check_arity_open_range() {
1151        fn run(args: &[Value]) -> Result<(), SemaError> {
1152            check_arity!(args, "open-fn", 2..);
1153            Ok(())
1154        }
1155        assert!(run(&[Value::nil(), Value::nil()]).is_ok());
1156        assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
1157        assert!(run(&[Value::nil()]).is_err());
1158        assert!(run(&[]).is_err());
1159    }
1160
1161    #[test]
1162    fn test_veteran_hint_known() {
1163        assert_eq!(
1164            veteran_hint("setq"),
1165            Some("Sema uses 'set!' for variable assignment")
1166        );
1167        assert_eq!(
1168            veteran_hint("mapcar"),
1169            Some("Sema uses 'map' for mapping over lists")
1170        );
1171        assert_eq!(
1172            veteran_hint("funcall"),
1173            Some("In Sema, functions are called directly: (f arg ...)")
1174        );
1175    }
1176
1177    #[test]
1178    fn test_veteran_hint_unknown() {
1179        assert!(veteran_hint("xyzzy").is_none());
1180        assert!(veteran_hint("println").is_none());
1181    }
1182
1183    #[test]
1184    fn test_veteran_hint_existing_sema_names() {
1185        // Names that exist in Sema should return None
1186        assert!(veteran_hint("do").is_none());
1187        assert!(veteran_hint("while").is_none());
1188        assert!(veteran_hint("str").is_none());
1189        assert!(veteran_hint("count").is_none());
1190        // Accepted aliases — hinting away from a working form would mislead
1191        assert!(veteran_hint("defn").is_none());
1192        assert!(veteran_hint("progn").is_none());
1193        assert!(veteran_hint("def").is_none());
1194    }
1195
1196    // type_error_with_value constructor — verify variant/fields AND display
1197    #[test]
1198    fn type_error_with_value_display() {
1199        let e = SemaError::type_error_with_value("string", "integer", &Value::int(42));
1200        // Structural check: correct variant with got_value populated
1201        assert!(
1202            matches!(
1203                &e,
1204                SemaError::Type { expected, got, got_value, .. }
1205                if expected == "string" && got == "integer" && got_value.as_deref() == Some("42")
1206            ),
1207            "expected Type variant with expected='string', got='integer', got_value=Some(\"42\"), got {e:?}"
1208        );
1209        // Display check (intentionally testing Display format)
1210        assert_eq!(
1211            e.to_string(),
1212            "Type error: expected string, got integer (42)"
1213        );
1214    }
1215
1216    // type_error without value — verify got_value is None AND display
1217    #[test]
1218    fn type_error_without_value_display() {
1219        let e = SemaError::type_error("string", "integer");
1220        // Structural check: got_value should be None
1221        assert!(
1222            matches!(
1223                &e,
1224                SemaError::Type { got_value, .. } if got_value.is_none()
1225            ),
1226            "expected Type variant with got_value=None, got {e:?}"
1227        );
1228        // Display check (intentionally testing Display format)
1229        assert_eq!(e.to_string(), "Type error: expected string, got integer");
1230    }
1231
1232    #[test]
1233    fn argument_type_includes_call_context() {
1234        let e = SemaError::argument_type_with_value("string/split", 1, "string", &Value::int(42));
1235        assert_eq!(
1236            e.user_message(),
1237            "string/split argument 1 expected string, got int (42)"
1238        );
1239    }
1240
1241    #[test]
1242    fn arity_expectations_use_readable_grammar() {
1243        let cases = [
1244            ("0", "f expects no arguments, got 9"),
1245            ("1", "f expects 1 argument, got 9"),
1246            ("2", "f expects 2 arguments, got 9"),
1247            ("1+", "f expects 1 or more arguments, got 9"),
1248            ("2-4", "f expects 2 to 4 arguments, got 9"),
1249            ("2 or 3", "f expects 2 or 3 arguments, got 9"),
1250        ];
1251        for (expected, message) in cases {
1252            assert_eq!(SemaError::arity("f", expected, 9).user_message(), message);
1253        }
1254    }
1255
1256    #[test]
1257    fn policy_denial_preserves_details_and_renders_context() {
1258        let e = SemaError::policy_denied(PolicyDenial {
1259            policy: Some("safe-agent".to_string()),
1260            boundary: "tool".to_string(),
1261            subject: "shell/run".to_string(),
1262            rule: "tools.shell.deny".to_string(),
1263            reason: "command execution is not allowed".to_string(),
1264            action: "fail".to_string(),
1265            source: "request".to_string(),
1266        });
1267        assert_eq!(
1268            e.user_message(),
1269            "Policy 'safe-agent' denied tool 'shell/run': command execution is not allowed"
1270        );
1271        assert_eq!(e.note(), Some("policy rule: tools.shell.deny"));
1272    }
1273
1274    #[test]
1275    fn plain_format_orders_trace_hint_and_note() {
1276        let e = SemaError::eval("failed")
1277            .with_stack_trace(StackTrace(vec![CallFrame {
1278                name: "main".to_string(),
1279                file: None,
1280                span: Some(Span::point(2, 3)),
1281            }]))
1282            .with_hint("try again")
1283            .with_note("extra context");
1284        assert_eq!(
1285            e.format_plain(),
1286            "failed\n  at main (<input>:2:3)\n  hint: try again\n  note: extra context"
1287        );
1288    }
1289}