diff --git a/DESIGN.md b/DESIGN.md index 877cbdb..fb2040f 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -404,7 +404,26 @@ UTF-8 by default. Same owned/borrowed distinction as Rust, but inferred: ; Destructuring: [let [x y] [get-point]] [let {name age} user] -``` + +; Vector/tuple patterns in match: +[match v + #[a b] => [+ a b] ; matches a vector/tuple of EXACTLY length 2 + #[a b c] => [+ a [+ b c]] + _ => "other"] +``` + +**Sequence-pattern ruling (2026-07).** A `#[p1 … pn]` (or `(p1, …, pn)`) +pattern in `match` matches when the scrutinee is a vector or tuple of +*exactly* length n (Rust slice-pattern prior); element subpatterns match +recursively, and a non-match falls through to the next arm. A destructuring +`let` (`[let [x y] v]`, `[let #[x y] v]`) requires a vector or tuple with *at +least* as many elements as binders — extra elements are allowed (Clojure +prior) — and anything shorter, or a non-sequence value, is a **loud runtime +error** on every backend ("destructuring expected at least N elements, got +M" / "destructuring requires a vector or tuple"). Clojure would bind `nil` +for missing elements; Loon has no nil, and silently binding `()` is the +worst failure mode for an agent-first language, so the divergence is +deliberate and loud. ### Closures diff --git a/crates/loon-cli/tests/prior-alignment/clojure-vec-destructure.oo b/crates/loon-cli/tests/prior-alignment/clojure-vec-destructure.oo new file mode 100644 index 0000000..8fac174 --- /dev/null +++ b/crates/loon-cli/tests/prior-alignment/clojure-vec-destructure.oo @@ -0,0 +1,13 @@ +; prior: Clojure — `(let [[x y] [7 8]] (+ x y))` binds positionally; extra +; elements are ignored: `(let [[a b] [1 2 3]] ...)` is fine. +; expectation: positional let destructuring binds elements; extra elements +; are allowed. (Divergence: Clojure binds nil for MISSING elements; Loon has +; no nil and errors loudly instead — DESIGN.md §6 sequence-pattern ruling.) +; principle: silent () binds are the worst failure mode (agent-first.md). +; expect-stdout: 15 +; expect-stdout: 3 +[fn main [] + [let [x y] #[7 8]] + [println [+ x y]] + [let [a b] #[1 2 3 4]] + [println [+ a b]]] diff --git a/crates/loon-cli/tests/prior-alignment/rust-match-vec-pattern.oo b/crates/loon-cli/tests/prior-alignment/rust-match-vec-pattern.oo new file mode 100644 index 0000000..8e503ba --- /dev/null +++ b/crates/loon-cli/tests/prior-alignment/rust-match-vec-pattern.oo @@ -0,0 +1,15 @@ +; prior: Rust — slice patterns: `match v[..] { [a, b] => a + b, _ => 0 }` +; matches on exact length and binds the elements. +; expectation: `#[a b]` in match matches a vector of exactly length 2 and +; binds a and b; a wrong-length vector falls through to the next arm. +; principle: patterns that look like they bind must bind (issue #17 — they +; used to silently produce () on the default backend). +; expect-stdout: 3 +; expect-stdout: fallthrough +[fn main [] + [println [match #[1 2] + #[a b] [str [+ a b]] + _ "no match"]] + [println [match #[1 2 3] + #[a b] [str [+ a b]] + _ "fallthrough"]]] diff --git a/crates/loon-lang/src/check/mod.rs b/crates/loon-lang/src/check/mod.rs index 7f12ecd..f64f555 100644 --- a/crates/loon-lang/src/check/mod.rs +++ b/crates/loon-lang/src/check/mod.rs @@ -3779,12 +3779,56 @@ impl Checker { let form_span = Span::new(binding.span.start, args[val_idx].span.end); self.add_definition(name, binding.span, form_span); } + // Positional destructuring: [let [x y] v] / [let #[x y] v] / + // [let (x, y) v] — bind each element with its inferred type. + ExprKind::List(_) | ExprKind::Vec(_) | ExprKind::Tuple(_) => { + self.bind_destructure_vars(binding, &val_ty); + } + // Map destructuring: [let {a b} m] — bind the names (value + // types are not derived from the map type yet). + ExprKind::Map(pairs) => { + for (k, _) in pairs { + if let ExprKind::Symbol(name) = &k.kind { + let t = self.subst.fresh(); + self.env.set(name.clone(), Scheme::mono(t)); + } + } + } _ => {} } val_ty } + /// Bind the variables of a positional `let` destructure (`[x y]`, + /// `#[x y]`, `(x, y)`, possibly nested) against the bound value's type: + /// vector elements get the element type, tuple elements their positional + /// type, anything else a fresh var. + fn bind_destructure_vars(&mut self, binding: &Expr, val_ty: &Type) { + match &binding.kind { + ExprKind::Symbol(name) if name != "_" => { + let t = self.subst.resolve(val_ty); + self.env.set(name.clone(), Scheme::mono(t.clone())); + self.type_of.insert(binding.id, t); + self.add_definition(name, binding.span, binding.span); + } + ExprKind::List(items) | ExprKind::Vec(items) | ExprKind::Tuple(items) => { + let resolved = self.subst.resolve(val_ty); + for (i, item) in items.iter().enumerate() { + let elem_ty = match &resolved { + Type::Con(name, params) if name == "Vec" && params.len() == 1 => { + params[0].clone() + } + Type::Tuple(ts) if i < ts.len() => ts[i].clone(), + _ => self.subst.fresh(), + }; + self.bind_destructure_vars(item, &elem_ty); + } + } + _ => {} + } + } + fn infer_if(&mut self, args: &[Expr], span: Span) -> Type { if args.len() < 2 { return Type::Unit; @@ -4565,23 +4609,45 @@ impl Checker { } } - /// Bind variables from a match pattern into the current scope. - fn bind_pattern_vars(&mut self, pattern: &Expr, _scrutinee_ty: &Type) { + /// Bind variables from a match pattern into the current scope, with the + /// type the pattern is matched against. + fn bind_pattern_vars(&mut self, pattern: &Expr, scrutinee_ty: &Type) { match &pattern.kind { - ExprKind::Symbol(s) if s != "_" && !s.starts_with(char::is_uppercase) => { - let t = self.subst.fresh(); - self.env.set(s.clone(), Scheme::mono(t)); + ExprKind::Symbol(s) + if s != "_" && !s.starts_with(char::is_uppercase) && !s.starts_with(':') => + { + let t = self.subst.resolve(scrutinee_ty); + self.env.set(s.clone(), Scheme::mono(t.clone())); + self.type_of.insert(pattern.id, t); } ExprKind::List(items) if !items.is_empty() => { - // Constructor pattern: [Ok x] — bind the field vars + // Constructor pattern: [Ok x] — bind the field vars. Field + // types are not derived from the constructor signature yet, + // so each gets a fresh var. if let ExprKind::Symbol(ctor) = &items[0].kind { if ctor.starts_with(char::is_uppercase) { for field in &items[1..] { - self.bind_pattern_vars(field, _scrutinee_ty); + let t = self.subst.fresh(); + self.bind_pattern_vars(field, &t); } } } } + // Vector/tuple pattern: #[a b] or (a, b) — bind each element + // subpattern with its inferred element type. + ExprKind::Vec(items) | ExprKind::Tuple(items) => { + let resolved = self.subst.resolve(scrutinee_ty); + for (i, item) in items.iter().enumerate() { + let elem_ty = match &resolved { + Type::Con(name, params) if name == "Vec" && params.len() == 1 => { + params[0].clone() + } + Type::Tuple(ts) if i < ts.len() => ts[i].clone(), + _ => self.subst.fresh(), + }; + self.bind_pattern_vars(item, &elem_ty); + } + } _ => {} } } diff --git a/crates/loon-lang/src/eir/lower.rs b/crates/loon-lang/src/eir/lower.rs index 99d65ee..00de6a0 100644 --- a/crates/loon-lang/src/eir/lower.rs +++ b/crates/loon-lang/src/eir/lower.rs @@ -1103,14 +1103,44 @@ impl<'a> Lower<'a> { let val = self.lower_expr(val_expr); - // Bind the name - if let ExprKind::Symbol(name) = &binding.kind { - self.bind(name, val); - } - // TODO: destructuring patterns + self.lower_let_binding(binding, val); val } + /// Bind a `let` binding form to an already-lowered value: a plain name, + /// or a positional vector/tuple destructure (`[let [x y] v]`, + /// `[let #[x y] v]`). Destructuring emits a runtime guard that errors + /// loudly when the value is not a vector/tuple with at least as many + /// elements as binders (extra elements are allowed, Clojure-style) — + /// never a silent `()` bind. Map destructuring is still TODO here. + fn lower_let_binding(&mut self, binding: &Expr, val: Reg) { + match &binding.kind { + ExprKind::Symbol(name) => { + if name != "_" { + self.bind(name, val); + } + } + ExprKind::List(items) | ExprKind::Vec(items) | ExprKind::Tuple(items) => { + let span = binding.span; + let expected = self.reg(); + self.emit(Op::Lit(expected, Lit::Int(items.len() as i64), span)); + let guard = self.reg(); + self.emit(Op::Builtin( + guard, + Built::DestructureCheck, + vec![val, expected], + span, + )); + for (i, item) in items.iter().enumerate() { + let r = self.reg(); + self.emit(Op::Field(r, val, Selector::Index(i as u16), item.span)); + self.lower_let_binding(item, r); + } + } + _ => {} // TODO: map destructuring patterns + } + } + fn lower_if(&mut self, args: &[Expr], span: Span) -> Reg { if args.len() < 2 { let r = self.reg(); @@ -1427,6 +1457,32 @@ impl<'a> Lower<'a> { None } + // Vector/tuple pattern: #[a b] or (a, b) — matches a vector or + // tuple of exactly that length (Rust slice-pattern prior), with + // element subpatterns matched recursively. + ExprKind::Vec(items) | ExprKind::Tuple(items) => { + let len_r = self.reg(); + self.emit(Op::Builtin(len_r, Built::SeqLen, vec![scrutinee], span)); + let expected = self.reg(); + self.emit(Op::Lit(expected, Lit::Int(items.len() as i64), span)); + let mut cond = self.reg(); + self.emit(Op::Bin(cond, BinOp::Eq, len_r, expected, span)); + // Element subtests are side-effect free and safe to evaluate + // eagerly even when the length test fails (out-of-range Field + // yields unit, which simply fails the comparison), so a flat + // non-short-circuit And chain is correct. + for (i, item) in items.iter().enumerate() { + let elem = self.reg(); + self.emit(Op::Field(elem, scrutinee, Selector::Index(i as u16), span)); + if let Some(sub) = self.compile_pattern_test(item, elem, span) { + let both = self.reg(); + self.emit(Op::Bin(both, BinOp::And, cond, sub, span)); + cond = both; + } + } + Some(cond) + } + _ => None, } } @@ -1458,6 +1514,19 @@ impl<'a> Lower<'a> { let _ = ctor; // used for tag check in full implementation } } + ExprKind::Vec(items) | ExprKind::Tuple(items) => { + // Vector/tuple pattern: bind each element subpattern. + for (i, item) in items.iter().enumerate() { + let r = self.reg(); + self.emit(Op::Field( + r, + scrutinee, + Selector::Index(i as u16), + item.span, + )); + self.bind_pattern(item, r); + } + } _ => { // Literal pattern — value equality check // For now, bind nothing (handled by match semantics) diff --git a/crates/loon-lang/src/eir/mod.rs b/crates/loon-lang/src/eir/mod.rs index 15b35d4..1e25775 100644 --- a/crates/loon-lang/src/eir/mod.rs +++ b/crates/loon-lang/src/eir/mod.rs @@ -373,4 +373,13 @@ pub enum Built { SomeP, /// `none?` — true for None/unit (complement of `some?`). NoneP, + /// Internal (not name-resolvable): length of a vector or tuple, or -1 + /// for any other value. Emitted by pattern compilation so `#[a b]` can + /// test "is a sequence of length 2" in one comparison. + SeqLen, + /// Internal (not name-resolvable): destructuring guard. Args are + /// (value, expected-binder-count). Errors loudly unless the value is a + /// vector/tuple with at least that many elements — a silent `()` bind + /// here is the worst failure mode for an agent-first language. + DestructureCheck, } diff --git a/crates/loon-lang/src/eir/vm.rs b/crates/loon-lang/src/eir/vm.rs index dab719d..4b16f91 100644 --- a/crates/loon-lang/src/eir/vm.rs +++ b/crates/loon-lang/src/eir/vm.rs @@ -998,6 +998,9 @@ impl Vm { (Some(Obj::Tuple(fields)), Selector::Index(i)) => { fields.get(*i as usize).copied().unwrap_or(Val::UNIT) } + (Some(Obj::Vec(items)), Selector::Index(i)) => { + items.get(*i as usize).copied().unwrap_or(Val::UNIT) + } (Some(Obj::Map(map)), Selector::Name(sid)) => { // Try symbol key first, then string key let sym_key = Val::sym(sid.0); @@ -1416,6 +1419,41 @@ impl Vm { }; Ok(Val::int(len)) } + Built::SeqLen => { + let v = args.first().copied().unwrap_or(Val::UNIT); + let len = match self.get_obj(v) { + Some(Obj::Vec(items)) => items.len() as i64, + Some(Obj::Tuple(items)) => items.len() as i64, + _ => -1, + }; + Ok(Val::int(len)) + } + Built::DestructureCheck => { + let v = args.first().copied().unwrap_or(Val::UNIT); + let expected = args + .get(1) + .copied() + .filter(|e| e.is_int()) + .map(|e| e.as_int()) + .unwrap_or(0); + let len = match self.get_obj(v) { + Some(Obj::Vec(items)) => items.len() as i64, + Some(Obj::Tuple(items)) => items.len() as i64, + _ => { + return Err(VmError::new(VmErrorKind::DestructureMismatch( + "destructuring requires a vector or tuple".to_string(), + )) + .with_span(self.current_span)); + } + }; + if len < expected { + return Err(VmError::new(VmErrorKind::DestructureMismatch(format!( + "destructuring expected at least {expected} elements, got {len}" + ))) + .with_span(self.current_span)); + } + Ok(Val::UNIT) + } Built::Get => { let coll = args.first().copied().unwrap_or(Val::UNIT); let key = args.get(1).copied().unwrap_or(Val::UNIT); @@ -3049,6 +3087,10 @@ pub enum VmErrorKind { /// here would let programs believe they got a valid quotient. The `&str` /// names the operation ("division" or "modulo") for the diagnostic. DivideByZero(&'static str), + /// A destructuring `let` (or handler binding) was given a value that is + /// not a vector/tuple or has too few elements. Silently binding `()` + /// here would let programs proceed with wrong data. + DestructureMismatch(String), } /// Structured detail for a replay divergence, so `loon verify` can classify @@ -3078,6 +3120,7 @@ impl VmErrorKind { VmErrorKind::ReplayDivergence(_) => "replay-divergence", VmErrorKind::UnhandledEffect(_) => "unhandled-effect", VmErrorKind::DivideByZero(_) => "divide-by-zero", + VmErrorKind::DestructureMismatch(_) => "destructure-mismatch", } } } @@ -3124,6 +3167,11 @@ impl std::fmt::Display for VmError { // "modulo by zero" so both backends fail the same way. write!(f, "{kind} by zero") } + VmErrorKind::DestructureMismatch(msg) => { + // Wording matches the interpreter's destructuring errors so + // both backends fail the same way. + write!(f, "{msg}") + } } } } diff --git a/crates/loon-lang/src/interp/mod.rs b/crates/loon-lang/src/interp/mod.rs index 4b42676..5c2254d 100644 --- a/crates/loon-lang/src/interp/mod.rs +++ b/crates/loon-lang/src/interp/mod.rs @@ -1426,6 +1426,33 @@ pub(crate) fn pattern_matches( } Ok(false) } + // Vector/tuple pattern: #[a b] or (a, b) — matches a vector or tuple + // of exactly that length (Rust slice-pattern prior), with element + // subpatterns matched recursively. + ExprKind::Vec(pats) | ExprKind::Tuple(pats) => { + let matches_elems = |elems: &[Value], + bindings: &mut HashMap, + env: &mut Env| + -> Result { + if elems.len() != pats.len() { + return Ok(false); + } + for (pat, val) in pats.iter().zip(elems.iter()) { + if !pattern_matches(pat, val, bindings, env)? { + return Ok(false); + } + } + Ok(true) + }; + match value { + Value::Vec(v) => { + let elems: Vec = v.iter().cloned().collect(); + matches_elems(&elems, bindings, env) + } + Value::Tuple(t) => matches_elems(t, bindings, env), + _ => Ok(false), + } + } _ => Ok(false), } } @@ -2332,7 +2359,7 @@ pub(crate) fn extract_params(expr: &Expr) -> Result, InterpErr pub(crate) fn extract_param(expr: &Expr) -> Result { match &expr.kind { ExprKind::Symbol(s) => Ok(value::Param::Simple(s.clone())), - ExprKind::List(items) => { + ExprKind::List(items) | ExprKind::Vec(items) | ExprKind::Tuple(items) => { let mut inner = Vec::new(); for item in items { inner.push(extract_param(item)?); @@ -2371,16 +2398,25 @@ pub(crate) fn bind_param( Ok(()) } value::Param::VecDestructure(inner) => { + // Too few elements is a loud error, never a silent `()` bind + // (extra elements are allowed, Clojure-style). + let too_short = |len: usize| { + err(&format!( + "destructuring expected at least {} elements, got {}", + inner.len(), + len + )) + }; match val { Value::Vec(v) => { for (i, p) in inner.iter().enumerate() { - let v = v.get(i).cloned().unwrap_or(Value::Unit); + let v = v.get(i).cloned().ok_or_else(|| too_short(v.len()))?; bind_param(p, &v, env)?; } } Value::Tuple(v) => { for (i, p) in inner.iter().enumerate() { - let v = v.get(i).cloned().unwrap_or(Value::Unit); + let v = v.get(i).cloned().ok_or_else(|| too_short(v.len()))?; bind_param(p, &v, env)?; } } diff --git a/crates/loon-lang/tests/backend_parity.rs b/crates/loon-lang/tests/backend_parity.rs index bf87139..ec0f0fb 100644 --- a/crates/loon-lang/tests/backend_parity.rs +++ b/crates/loon-lang/tests/backend_parity.rs @@ -61,6 +61,42 @@ const CORPUS: &[(&str, &str)] = &[ [fn area [s] [match s [Circle r] [* 3.0 [* r r]] [Rect w h] [* w h] Point 0.0]] \ [fn main [] [println [area [Rect 3.0 4.0]]]]", ), + ( + "match-vec-pattern", + "[fn main [] [println [match #[1 2] #[a b] [+ a b] _ 0]]]", + ), + ( + "match-vec-pattern-exact-length", + "[fn main [] [println [match #[1 2 3] #[a b] 0 #[a b c] [+ a [+ b c]] _ -1]]]", + ), + ( + "match-vec-pattern-nested-literal", + "[fn main [] [println [match #[1 #[2 3]] #[1 #[b c]] [+ b c] _ 0]]]", + ), + ( + "match-vec-pattern-non-sequence-falls-through", + r#"[fn main [] [println [match "xy" #[a b] "seq" _ "not-seq"]]]"#, + ), + ( + "match-empty-vec-pattern", + r#"[fn main [] [println [match #[] #[] "empty" _ "no"]]]"#, + ), + ( + "let-destructure-vec", + "[fn main [] [let [x y] #[7 8]] [println [+ x y]]]", + ), + ( + "let-destructure-nested", + "[fn main [] [let [a [b c]] #[1 #[2 3]]] [println [+ a [+ b c]]]]", + ), + ( + "let-destructure-extra-elements-allowed", + "[fn main [] [let [x y] #[1 2 3 4]] [println [+ x y]]]", + ), + ( + "let-destructure-tuple-from-zip", + "[fn main [] [let [a b] [first [zip #[1 2] #[10 20]]]] [println [+ a b]]]", + ), ( "adt-generic-option", "[type Option T [Some T] None] \ @@ -391,6 +427,42 @@ fn int_divide_by_zero_raises_on_both_backends() { ); } +/// Destructuring `let` with too few elements (or a non-sequence value) must +/// RAISE on BOTH backends — never silently bind `()` (issue #17: vector +/// patterns used to no-op entirely on the EIR VM). Extra elements are allowed +/// (Clojure prior); the ruling is documented in DESIGN.md §6. +#[test] +fn destructure_mismatch_raises_on_both_backends() { + for (src, needle) in [ + ( + "[fn main [] [let [x y z] #[1 2]] [println x]]", + "destructuring expected at least 3 elements, got 2", + ), + ( + "[fn main [] [let [x y] 5] [println x]]", + "destructuring requires a vector or tuple", + ), + ] { + let eir = eir_output(src); + let interp = interp_output(src); + assert!(eir.is_err(), "EIR VM must error on {src:?}, got {eir:?}"); + assert!( + interp.is_err(), + "interp must error on {src:?}, got {interp:?}" + ); + assert!( + eir.as_ref().unwrap_err().contains(needle), + "EIR VM error for {src:?} should mention {needle:?}, got {:?}", + eir.unwrap_err() + ); + assert!( + interp.as_ref().unwrap_err().contains(needle), + "interp error for {src:?} should mention {needle:?}, got {:?}", + interp.unwrap_err() + ); + } +} + /// Known divergences between the backends, PINNED so any change is noticed. /// Each records the program and the (eir, interp) outputs observed today, with /// a note on which backend is correct — a worklist for unification. diff --git a/crates/loon-lang/tests/interp_tests.rs b/crates/loon-lang/tests/interp_tests.rs index e116e81..a3f2c57 100644 --- a/crates/loon-lang/tests/interp_tests.rs +++ b/crates/loon-lang/tests/interp_tests.rs @@ -1725,3 +1725,126 @@ fn eir_parity() { "#, ); } + +// ── Vector/tuple patterns (issue #17) ─────────────────────────────── +// Sequence patterns: `#[a b]` in match matches a vector/tuple of EXACTLY +// that length (Rust slice-pattern prior) and binds elements; destructuring +// `let` requires at least as many elements as binders and errors LOUDLY on +// mismatch — never a silent `()` bind. Ruling documented in DESIGN.md §6. + +#[test] +fn match_vec_pattern_binds_elements() { + let result = run(r#" + [let v #[1 2]] + [match v + #[a b] [+ a b] + _ 0] + "#); + assert_eq!(result, Value::Int(3)); +} + +#[test] +fn match_vec_pattern_exact_length() { + // #[a b] must NOT match a 3-element vector; #[a b c] must. + let result = run(r#" + [match #[1 2 3] + #[a b] 0 + #[a b c] [+ a [+ b c]] + _ -1] + "#); + assert_eq!(result, Value::Int(6)); +} + +#[test] +fn match_vec_pattern_nested_and_literals() { + let result = run(r#" + [match #[1 #[2 3]] + #[1 #[b c]] [+ b c] + _ 0] + "#); + assert_eq!(result, Value::Int(5)); +} + +#[test] +fn match_vec_pattern_non_sequence_falls_through() { + // Strings and scalars are not sequences for pattern purposes. + let result = run(r#" + [match "xy" + #[a b] 1 + _ 2] + "#); + assert_eq!(result, Value::Int(2)); +} + +#[test] +fn match_empty_vec_pattern() { + let result = run(r#" + [match #[] + #[] "empty" + _ "no"] + "#); + assert_eq!(result, Value::Str("empty".into())); +} + +#[test] +fn match_vec_pattern_on_tuple_value() { + // Tuples (from zip) match sequence patterns too. + let result = run(r#" + [match [first [zip #[1 2] #[10 20]]] + #[a b] [+ a b] + _ 0] + "#); + assert_eq!(result, Value::Int(11)); +} + +#[test] +fn let_destructure_vector() { + let result = run(r#" + [let [x y] #[7 8]] + [+ x y] + "#); + assert_eq!(result, Value::Int(15)); +} + +#[test] +fn let_destructure_nested() { + let result = run(r#" + [let [a [b c]] #[1 #[2 3]]] + [+ a [+ b c]] + "#); + assert_eq!(result, Value::Int(6)); +} + +#[test] +fn let_destructure_extra_elements_allowed() { + // Clojure prior: fewer binders than elements is fine. + let result = run(r#" + [let [x y] #[1 2 3 4]] + [+ x y] + "#); + assert_eq!(result, Value::Int(3)); +} + +#[test] +fn let_destructure_too_short_errors_loudly() { + let exprs = parse("[let [x y z] #[1 2]]").expect("parse failed"); + let result = eval_program(&exprs); + assert!(result.is_err(), "short destructure must error, not bind ()"); + let msg = format!("{:?}", result.unwrap_err()); + assert!( + msg.contains("destructuring expected at least 3 elements, got 2"), + "error should name the mismatch, got: {msg}" + ); +} + +#[test] +fn let_destructure_non_sequence_errors_loudly() { + let exprs = parse("[let [x y] 5]").expect("parse failed"); + let result = eval_program(&exprs); + assert!(result.is_err(), "non-sequence destructure must error"); + let msg = format!("{:?}", result.unwrap_err()); + assert!( + msg.contains("destructuring requires a vector or tuple"), + "error should name the requirement, got: {msg}" + ); +}