1use std::collections::{BTreeMap, HashMap};
2use std::fmt;
3
4use crate::runtime::{CancelReason, OperationId, RootId, ScopeId};
5use crate::value::Value;
6
7#[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 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 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 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 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 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 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#[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#[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
147pub type SpanMap = HashMap<usize, Span>;
149
150#[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 #[error("workflow approval required: {approval_id}")]
237 WorkflowApprovalRequired { approval_id: String },
238
239 #[error("workflow approval rejected: {approval_id}")]
243 WorkflowApprovalRejected {
244 approval_id: String,
245 reason: Option<String>,
246 },
247
248 #[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 #[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
327fn 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
352pub fn suggest_similar(name: &str, candidates: &[&str]) -> Option<String> {
355 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
372pub fn veteran_hint(name: &str) -> Option<&'static str> {
379 match name {
380 "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 "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 "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
430const 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
450fn 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
460fn 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 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 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 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 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 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 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 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 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 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 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 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 #[test]
865 fn span_display() {
866 let span = Span::point(1, 5);
867 assert_eq!(span.to_string(), "1:5");
868 }
869
870 #[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 #[test]
905 fn type_error_with_value_does_not_split_multibyte_char() {
906 let value = Value::string(&format!("x{}", "λ".repeat(40)));
910 let e = SemaError::type_error_with_value("map", "string", &value);
911 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 assert!(
926 matches!(&e, SemaError::Eval(msg) if msg == "something broke"),
927 "expected Eval variant with message 'something broke', got {e:?}"
928 );
929 assert_eq!(e.to_string(), "Eval error: something broke");
931 }
932
933 #[test]
935 fn type_error() {
936 let e = SemaError::type_error("string", "integer");
937 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 assert_eq!(e.to_string(), "Type error: expected string, got integer");
948 }
949
950 #[test]
952 fn arity_error() {
953 let e = SemaError::arity("my-fn", "2", 5);
954 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 assert_eq!(
965 e.to_string(),
966 "Arity error: my-fn expects 2 arguments, got 5"
967 );
968 }
969
970 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 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 assert!(veteran_hint("defn").is_none());
1192 assert!(veteran_hint("progn").is_none());
1193 assert!(veteran_hint("def").is_none());
1194 }
1195
1196 #[test]
1198 fn type_error_with_value_display() {
1199 let e = SemaError::type_error_with_value("string", "integer", &Value::int(42));
1200 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 assert_eq!(
1211 e.to_string(),
1212 "Type error: expected string, got integer (42)"
1213 );
1214 }
1215
1216 #[test]
1218 fn type_error_without_value_display() {
1219 let e = SemaError::type_error("string", "integer");
1220 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 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}