1use std::collections::HashMap;
2use std::fmt;
3
4use crate::value::Value;
5
6#[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 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 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 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 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#[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#[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
129pub 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
189fn 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
214pub fn suggest_similar(name: &str, candidates: &[&str]) -> Option<String> {
217 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
234pub fn veteran_hint(name: &str) -> Option<&'static str> {
237 match name {
238 "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 "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 "define-syntax" | "syntax-rules" | "syntax-case" => {
268 Some("Sema uses 'defmacro' for macro definitions")
269 }
270 "call-with-current-continuation" | "call/cc" => Some(
271 "Sema doesn't support first-class continuations; use 'try'/'throw' for control flow",
272 ),
273 "string-join" => Some("Sema uses 'string/join' (slash-namespaced)"),
274 "string-split" => Some("Sema uses 'string/split' (slash-namespaced)"),
275 "string-trim" => Some("Sema uses 'string/trim' (slash-namespaced)"),
276 "string-contains" => Some("Sema uses 'string/contains?' (slash-namespaced, with '?')"),
277 "string-upcase" | "string-downcase" => Some("Sema uses 'string/upper' and 'string/lower'"),
278 "make-string" => Some("Sema uses 'string/repeat' to create repeated strings"),
279 "hash-ref" => Some("Sema uses 'get' to look up values in maps"),
280 "hash-set!" => Some("Sema maps are immutable; use 'assoc' to create an updated copy"),
281 "hash-map?" => Some("Sema uses 'map?' to check if a value is a map"),
282 "with-exception-handler" | "raise" => {
283 Some("Sema uses 'try'/'catch' and 'throw' for exception handling")
284 }
285
286 _ => None,
287 }
288}
289
290impl SemaError {
291 pub fn eval(msg: impl Into<String>) -> Self {
292 SemaError::Eval(msg.into())
293 }
294
295 pub fn type_error(expected: impl Into<String>, got: impl Into<String>) -> Self {
296 SemaError::Type {
297 expected: expected.into(),
298 got: got.into(),
299 got_value: None,
300 }
301 }
302
303 pub fn type_error_with_value(
304 expected: impl Into<String>,
305 got: impl Into<String>,
306 value: &Value,
307 ) -> Self {
308 let display = format!("{value}");
309 let truncated = if display.len() > 40 {
310 format!("{}…", &display[..39])
311 } else {
312 display
313 };
314 SemaError::Type {
315 expected: expected.into(),
316 got: got.into(),
317 got_value: Some(truncated),
318 }
319 }
320
321 pub fn arity(name: impl Into<String>, expected: impl Into<String>, got: usize) -> Self {
322 SemaError::Arity {
323 name: name.into(),
324 expected: expected.into(),
325 got,
326 }
327 }
328
329 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 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 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 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 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 #[test]
422 fn span_display() {
423 let span = Span::point(1, 5);
424 assert_eq!(span.to_string(), "1:5");
425 }
426
427 #[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 #[test]
462 fn eval_error() {
463 let e = SemaError::eval("something broke");
464 assert!(
466 matches!(&e, SemaError::Eval(msg) if msg == "something broke"),
467 "expected Eval variant with message 'something broke', got {e:?}"
468 );
469 assert_eq!(e.to_string(), "Eval error: something broke");
471 }
472
473 #[test]
475 fn type_error() {
476 let e = SemaError::type_error("string", "integer");
477 assert!(
479 matches!(
480 &e,
481 SemaError::Type { expected, got, got_value }
482 if expected == "string" && got == "integer" && got_value.is_none()
483 ),
484 "expected Type variant with expected='string', got='integer', got_value=None, got {e:?}"
485 );
486 assert_eq!(e.to_string(), "Type error: expected string, got integer");
488 }
489
490 #[test]
492 fn arity_error() {
493 let e = SemaError::arity("my-fn", "2", 5);
494 assert!(
496 matches!(
497 &e,
498 SemaError::Arity { name, expected, got }
499 if name == "my-fn" && expected == "2" && *got == 5
500 ),
501 "expected Arity variant with name='my-fn', expected='2', got=5, got {e:?}"
502 );
503 assert_eq!(e.to_string(), "Arity error: my-fn expects 2 args, got 5");
505 }
506
507 #[test]
509 fn with_hint() {
510 let e = SemaError::eval("oops").with_hint("try this");
511 assert_eq!(e.hint(), Some("try this"));
512 }
513
514 #[test]
516 fn with_note() {
517 let e = SemaError::eval("oops").with_note("extra info");
518 assert_eq!(e.note(), Some("extra info"));
519 }
520
521 #[test]
523 fn with_hint_preserves_note() {
524 let e = SemaError::eval("oops")
525 .with_note("kept note")
526 .with_hint("new hint");
527 assert_eq!(e.hint(), Some("new hint"));
528 assert_eq!(e.note(), Some("kept note"));
529 }
530
531 #[test]
533 fn with_note_preserves_hint() {
534 let e = SemaError::eval("oops")
535 .with_hint("kept hint")
536 .with_note("new note");
537 assert_eq!(e.hint(), Some("kept hint"));
538 assert_eq!(e.note(), Some("new note"));
539 }
540
541 #[test]
543 fn with_stack_trace() {
544 let trace = StackTrace(vec![CallFrame {
545 name: "f".into(),
546 file: None,
547 span: None,
548 }]);
549 let e = SemaError::eval("err").with_stack_trace(trace);
550 let st = e.stack_trace().expect("should have stack trace");
551 assert_eq!(st.0.len(), 1);
552 assert_eq!(st.0[0].name, "f");
553 }
554
555 #[test]
557 fn with_stack_trace_empty_is_noop() {
558 let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![]));
559 assert!(e.stack_trace().is_none());
560 assert!(matches!(e, SemaError::Eval(_)));
561 }
562
563 #[test]
565 fn with_stack_trace_already_wrapped_is_noop() {
566 let frame = || CallFrame {
567 name: "first".into(),
568 file: None,
569 span: None,
570 };
571 let e = SemaError::eval("err").with_stack_trace(StackTrace(vec![frame()]));
572 let e2 = e.with_stack_trace(StackTrace(vec![CallFrame {
573 name: "second".into(),
574 file: None,
575 span: None,
576 }]));
577 let st = e2.stack_trace().unwrap();
578 assert_eq!(st.0.len(), 1);
579 assert_eq!(st.0[0].name, "first");
580 }
581
582 #[test]
584 fn inner_unwraps() {
585 let e = SemaError::eval("root")
586 .with_hint("h")
587 .with_stack_trace(StackTrace(vec![CallFrame {
588 name: "x".into(),
589 file: None,
590 span: None,
591 }]));
592 let inner = e.inner();
593 assert!(matches!(inner, SemaError::Eval(msg) if msg == "root"));
594 }
595
596 #[test]
598 fn hint_note_none_on_plain() {
599 let e = SemaError::eval("plain");
600 assert!(e.hint().is_none());
601 assert!(e.note().is_none());
602 }
603
604 #[test]
606 fn check_arity_exact() {
607 fn run(args: &[Value]) -> Result<(), SemaError> {
608 check_arity!(args, "test-fn", 2);
609 Ok(())
610 }
611 assert!(run(&[Value::nil(), Value::nil()]).is_ok());
612 let err = run(&[Value::nil()]).unwrap_err();
613 assert!(err.to_string().contains("test-fn"));
614 assert!(err.to_string().contains("2"));
615 }
616
617 #[test]
619 fn check_arity_range() {
620 fn run(args: &[Value]) -> Result<(), SemaError> {
621 check_arity!(args, "range-fn", 1..=3);
622 Ok(())
623 }
624 assert!(run(&[Value::nil()]).is_ok());
625 assert!(run(&[Value::nil(), Value::nil()]).is_ok());
626 assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
627 assert!(run(&[]).is_err());
628 assert!(run(&[Value::nil(), Value::nil(), Value::nil(), Value::nil()]).is_err());
629 }
630
631 #[test]
632 fn test_suggest_similar() {
633 assert_eq!(
634 suggest_similar(
635 "strng/join",
636 &["string/join", "string/split", "map", "println"]
637 ),
638 Some("string/join".to_string())
639 );
640 assert_eq!(
641 suggest_similar("pritnln", &["println", "print", "map"]),
642 Some("println".to_string())
643 );
644 assert_eq!(suggest_similar("xyzzy", &["a", "b", "c"]), None);
645 }
646
647 #[test]
649 fn check_arity_open_range() {
650 fn run(args: &[Value]) -> Result<(), SemaError> {
651 check_arity!(args, "open-fn", 2..);
652 Ok(())
653 }
654 assert!(run(&[Value::nil(), Value::nil()]).is_ok());
655 assert!(run(&[Value::nil(), Value::nil(), Value::nil()]).is_ok());
656 assert!(run(&[Value::nil()]).is_err());
657 assert!(run(&[]).is_err());
658 }
659
660 #[test]
661 fn test_veteran_hint_known() {
662 assert_eq!(
663 veteran_hint("defn"),
664 Some("Sema uses 'defun' to define named functions")
665 );
666 assert_eq!(
667 veteran_hint("setq"),
668 Some("Sema uses 'set!' for variable assignment")
669 );
670 assert_eq!(
671 veteran_hint("progn"),
672 Some("Sema uses 'begin' to sequence expressions")
673 );
674 assert_eq!(
675 veteran_hint("mapcar"),
676 Some("Sema uses 'map' for mapping over lists")
677 );
678 }
679
680 #[test]
681 fn test_veteran_hint_unknown() {
682 assert!(veteran_hint("xyzzy").is_none());
683 assert!(veteran_hint("println").is_none());
684 }
685
686 #[test]
687 fn test_veteran_hint_existing_sema_names() {
688 assert!(veteran_hint("do").is_none());
690 assert!(veteran_hint("while").is_none());
691 assert!(veteran_hint("str").is_none());
692 assert!(veteran_hint("count").is_none());
693 }
694
695 #[test]
697 fn type_error_with_value_display() {
698 let e = SemaError::type_error_with_value("string", "integer", &Value::int(42));
699 assert!(
701 matches!(
702 &e,
703 SemaError::Type { expected, got, got_value }
704 if expected == "string" && got == "integer" && got_value.as_deref() == Some("42")
705 ),
706 "expected Type variant with expected='string', got='integer', got_value=Some(\"42\"), got {e:?}"
707 );
708 assert_eq!(
710 e.to_string(),
711 "Type error: expected string, got integer (42)"
712 );
713 }
714
715 #[test]
717 fn type_error_without_value_display() {
718 let e = SemaError::type_error("string", "integer");
719 assert!(
721 matches!(
722 &e,
723 SemaError::Type { got_value, .. } if got_value.is_none()
724 ),
725 "expected Type variant with got_value=None, got {e:?}"
726 );
727 assert_eq!(e.to_string(), "Type error: expected string, got integer");
729 }
730}