Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 5 additions & 5 deletions crates/loon-cli/tests/conformance/and-or-value.oo
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
; A bare `and`/`or` in VALUE position is not desugared. The legacy
; interpreter resolves it to its eager variadic env builtin (prints false
; here); the EIR VM and wasm have no callable builtin VALUES (pre-existing
; limit), so the oracle fails with "value is not callable".
; expect-fail: legacy — the eager builtin value works there, so it prints
; false and exits 0 while the VM oracle errors.
; interpreter resolves it to its eager variadic env builtin, and the EIR VM
; now wraps first-class binary operators in an arity-2 closure — both print
; false. The wasm codegen has no first-class operator values yet, so it
; rejects at compile time with "unbound symbol 'and'".
; expect-fail: wasm — no first-class operator values in the wasm codegen.
[fn main []
[let f and]
[println [f true false]]]
136 changes: 111 additions & 25 deletions crates/loon-lang/src/eir/lower.rs
Original file line number Diff line number Diff line change
Expand Up @@ -587,6 +587,12 @@ impl<'a> Lower<'a> {
self.emit(Op::Close(r, fid, Vec::new(), expr.span));
return r;
}
// Physics constants (same set the interpreter registers).
if let Some(v) = Self::physics_const_value(&path) {
let r = self.reg();
self.emit(Op::Lit(r, Lit::Float(v), expr.span));
return r;
}
}
// Field access
let obj = self.lower_expr(inner);
Expand All @@ -604,6 +610,19 @@ impl<'a> Lower<'a> {
}
}

/// Namespaced physics constants, mirroring the interpreter's
/// `Const.*` registrations in `interp/builtins.rs`.
fn physics_const_value(path: &str) -> Option<f64> {
match path {
"Const.c" => Some(299_792_458.0),
"Const.G" => Some(6.674_30e-11),
"Const.h" => Some(6.626_070_15e-34),
"Const.k-B" => Some(1.380_649e-23),
"Const.e-charge" => Some(1.602_176_634e-19),
_ => None,
}
}

fn lower_symbol(&mut self, name: &str, span: Span) -> Reg {
// Check local scope first
if let Some(r) = self.lookup(name) {
Expand All @@ -622,6 +641,31 @@ impl<'a> Lower<'a> {
self.emit(Op::Adt(r, tag, Vec::new(), span));
return r;
}
// First-class use of an arity-N constructor (e.g. `[map Some xs]`)
// — wrap it as a closure, like builtins below.
let saved_func = self.cur_func;
let saved_block = self.cur_block;
let saved_next_reg = self.next_reg;
let saved_scopes = std::mem::take(&mut self.scopes);

let func_id = self.begin_func(None, span);
self.scopes = vec![HashMap::new()];
let params: Vec<Reg> = (0..arity).map(|i| Reg(i as u32)).collect();
self.next_reg = arity as u32;
self.module.funcs[func_id.0 as usize].params = vec![Ty::Any; arity as usize];

let result = self.reg();
self.emit(Op::Adt(result, tag, params, span));
self.seal(End::Ret(result));

self.cur_func = saved_func;
self.cur_block = saved_block;
self.next_reg = saved_next_reg;
self.scopes = saved_scopes;

let r = self.reg();
self.emit(Op::Close(r, func_id, Vec::new(), span));
return r;
}
// Check if it's a builtin — wrap as a closure for first-class use
if let Some(built) = self.resolve_builtin(name) {
Expand Down Expand Up @@ -653,10 +697,43 @@ impl<'a> Lower<'a> {
return r;
}

// Fallback: emit as a string literal (will be resolved by the VM)
let r = self.reg();
// First-class use of a binary operator (e.g. `[fold xs 0 +]`) —
// wrap it as an arity-2 closure, mirroring the interpreter's
// variadic operator dispatch for the binary case.
if let Some(binop) = binop_for(name) {
let saved_func = self.cur_func;
let saved_block = self.cur_block;
let saved_next_reg = self.next_reg;
let saved_scopes = std::mem::take(&mut self.scopes);

let func_id = self.begin_func(None, span);
self.scopes = vec![HashMap::new()];
self.next_reg = 2;
self.module.funcs[func_id.0 as usize].params = vec![Ty::Any, Ty::Any];

let result = self.reg();
self.emit(Op::Bin(result, binop, Reg(0), Reg(1), span));
self.seal(End::Ret(result));

self.cur_func = saved_func;
self.cur_block = saved_block;
self.next_reg = saved_next_reg;
self.scopes = saved_scopes;

let r = self.reg();
self.emit(Op::Close(r, func_id, Vec::new(), span));
return r;
}

// Unbound: nothing in scope, no function, ctor, or builtin by this
// name. Emit a runtime trap carrying the name so evaluating the
// symbol errors loudly ("unbound symbol '<name>'") instead of
// silently producing a string value the program never asked for.
let name_reg = self.reg();
let sid = self.intern(name);
self.emit(Op::Lit(r, Lit::Str(sid), span));
self.emit(Op::Lit(name_reg, Lit::Str(sid), span));
let r = self.reg();
self.emit(Op::Builtin(r, Built::UnboundSym, vec![name_reg], span));
r
}

Expand Down Expand Up @@ -1358,11 +1435,14 @@ impl<'a> Lower<'a> {
self.seal(End::Jmp(merge_block, vec![val]));
}

// Default block
// Default block: no arm matched. On the VM this raises a hard
// "no match arm matched value" error (MatchFail carries the
// scrutinee for the diagnostic); wasm/native compile MatchFail to
// unit via their builtin fallback, preserving prior behavior there.
self.switch_to(default_block);
let unit = self.reg();
self.emit(Op::Lit(unit, Lit::Unit, span));
self.seal(End::Jmp(merge_block, vec![unit]));
let fail = self.reg();
self.emit(Op::Builtin(fail, Built::MatchFail, vec![scrutinee], span));
self.seal(End::Jmp(merge_block, vec![fail]));

// Merge block
self.switch_to(merge_block);
Expand Down Expand Up @@ -2115,25 +2195,10 @@ impl<'a> Lower<'a> {
return r;
}

// Check for binary operators
// Check for binary operators ("str" is handled as Built::Str
// (variadic), not BinOp)
if arg_exprs.len() == 2 {
if let Some(binop) = match name.as_str() {
"+" => Some(BinOp::Add),
"-" => Some(BinOp::Sub),
"*" => Some(BinOp::Mul),
"/" => Some(BinOp::Div),
"%" => Some(BinOp::Rem),
"=" => Some(BinOp::Eq),
"!=" => Some(BinOp::Ne),
"<" => Some(BinOp::Lt),
">" => Some(BinOp::Gt),
"<=" => Some(BinOp::Le),
">=" => Some(BinOp::Ge),
"and" => Some(BinOp::And),
"or" => Some(BinOp::Or),
// "str" is handled as Built::Str (variadic), not BinOp
_ => None,
} {
if let Some(binop) = binop_for(name.as_str()) {
let a = self.lower_expr(&arg_exprs[0]);
let b = self.lower_expr(&arg_exprs[1]);
let r = self.reg();
Expand Down Expand Up @@ -2551,6 +2616,27 @@ pub(crate) fn is_special_form(name: &str) -> bool {
)
}

/// Binary operator tag for a symbol, shared by call-position lowering and
/// first-class operator wrappers in `lower_symbol`.
fn binop_for(name: &str) -> Option<BinOp> {
match name {
"+" => Some(BinOp::Add),
"-" => Some(BinOp::Sub),
"*" => Some(BinOp::Mul),
"/" => Some(BinOp::Div),
"%" => Some(BinOp::Rem),
"=" => Some(BinOp::Eq),
"!=" => Some(BinOp::Ne),
"<" => Some(BinOp::Lt),
">" => Some(BinOp::Gt),
"<=" => Some(BinOp::Le),
">=" => Some(BinOp::Ge),
"and" => Some(BinOp::And),
"or" => Some(BinOp::Or),
_ => None,
}
}

pub(crate) fn is_operator(name: &str) -> bool {
matches!(
name,
Expand Down
8 changes: 8 additions & 0 deletions crates/loon-lang/src/eir/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -373,6 +373,14 @@ pub enum Built {
SomeP,
/// `none?` — true for None/unit (complement of `some?`).
NoneP,
/// Non-exhaustive `match` fell through every arm. Takes the scrutinee;
/// the VM raises a "no match arm matched value" error. Wasm/native fall
/// back to unit (their builtin fallback), preserving prior behavior there.
MatchFail,
/// A symbol that resolved to nothing at lowering time. Takes the interned
/// name as a string; the VM raises "unbound symbol '<name>'" the moment
/// the symbol is evaluated, instead of silently producing a string value.
UnboundSym,
/// 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.
Expand Down
117 changes: 91 additions & 26 deletions crates/loon-lang/src/eir/vm.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1151,9 +1151,15 @@ impl Vm {
}
}

Op::Builtin(dst, built, args, _) => {
Op::Builtin(dst, built, args, span) => {
let vals = self.read_regs(args);
let result = self.exec_builtin(*built, &vals)?;
let result = self.exec_builtin(*built, &vals).map_err(|e| {
if e.span.is_none() {
e.with_span(*span)
} else {
e
}
})?;
self.w(*dst, result);
}

Expand Down Expand Up @@ -1510,7 +1516,11 @@ impl Vm {
map.insert(key, val); // O(log₃₂ n) with structural sharing
Ok(self.alloc(Obj::Map(map)))
}
_ => Ok(Val::UNIT),
// Same wording as the interpreter — never a silent `()`
// (issue #21: assoc on a vector used to return unit).
_ => Err(VmError::new(VmErrorKind::BuiltinType(
"assoc requires a map".to_string(),
))),
}
}
Built::Range => {
Expand Down Expand Up @@ -1639,33 +1649,50 @@ impl Vm {
_ => Ok(Val::int(0)),
}
}
Built::Min => {
Built::Min | Built::Max => {
// Vector-only, like the interpreter: binary `[min 1 2]` and
// an empty vector are hard errors, never a silent `()`.
let name = if built == Built::Min { "min" } else { "max" };
let coll = args.first().copied().unwrap_or(Val::UNIT);
match self.get_obj(coll) {
Some(Obj::Vec(items)) => {
let min = items
.iter()
.filter(|v| v.is_int())
.map(|v| v.as_int())
.min();
Ok(min.map(Val::int).unwrap_or(Val::UNIT))
}
_ => Ok(Val::UNIT),
let Some(Obj::Vec(items)) = self.get_obj(coll) else {
return Err(VmError::new(VmErrorKind::BuiltinType(format!(
"{name} requires a vector"
))));
};
if items.is_empty() {
return Err(VmError::new(VmErrorKind::BuiltinType(format!(
"{name}: empty vector"
))));
}
}
Built::Max => {
let coll = args.first().copied().unwrap_or(Val::UNIT);
match self.get_obj(coll) {
Some(Obj::Vec(items)) => {
let max = items
.iter()
.filter(|v| v.is_int())
.map(|v| v.as_int())
.max();
Ok(max.map(Val::int).unwrap_or(Val::UNIT))
// Numeric comparison over ints and floats (mixed allowed).
// Anything else is a hard error — a NaN-style silent skip
// would return the wrong element for mixed vectors.
let key = |v: Val| -> Result<f64, VmError> {
if v.is_int() {
Ok(v.as_int() as f64)
} else if v.is_float() {
Ok(v.as_float())
} else {
Err(VmError::new(VmErrorKind::BuiltinType(format!(
"{name}: non-numeric element"
))))
}
};
let mut best = *items.front().unwrap();
let mut best_key = key(best)?;
for &item in items.iter().skip(1) {
Comment thread
ecto marked this conversation as resolved.
let item_key = key(item)?;
let better = if built == Built::Min {
item_key < best_key
} else {
item_key > best_key
};
if better {
best = item;
best_key = item_key;
}
_ => Ok(Val::UNIT),
}
Ok(best)
}
Built::Cons => {
let val = args.first().copied().unwrap_or(Val::UNIT);
Expand Down Expand Up @@ -1799,6 +1826,19 @@ impl Vm {
let v = args.first().copied().unwrap_or(Val::UNIT);
Ok(Val::bool(v.is_none() || v.is_unit()))
}
Built::MatchFail => {
let scrutinee = args.first().copied().unwrap_or(Val::UNIT);
let rendered = self.val_to_string_inner(scrutinee, true);
Err(VmError::new(VmErrorKind::NoMatch(rendered)))
}
Built::UnboundSym => {
let name = args
.first()
.and_then(|v| self.get_str(*v))
.unwrap_or_default()
.to_string();
Err(VmError::new(VmErrorKind::UnboundSymbol(name)))
}
Built::Keys => {
let m = args.first().copied().unwrap_or(Val::UNIT);
match self.get_obj(m).cloned() {
Expand Down Expand Up @@ -3090,6 +3130,19 @@ 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),
/// Evaluated a symbol that resolved to nothing at lowering time.
/// Silently treating it as a string value would let programs run with
/// misspelled or missing names. Wording matches the interpreter's
/// "unbound symbol '<name>'" so both backends fail the same way.
UnboundSymbol(String),
/// A `match` fell through every arm. Silently returning `()` would hide
/// non-exhaustive matches. The `String` is the rendered scrutinee.
/// Wording matches the interpreter's "no match arm matched value: <v>".
NoMatch(String),
/// A builtin was given an argument type/shape it does not support (e.g.
/// binary `[min 1 2]`, `assoc` on a vector). The `String` is the full
/// message, matching the interpreter's wording ("min requires a vector").
BuiltinType(String),
/// 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.
Expand Down Expand Up @@ -3124,6 +3177,9 @@ impl VmErrorKind {
VmErrorKind::ReplayDivergence(_) => "replay-divergence",
VmErrorKind::UnhandledEffect(_) => "unhandled-effect",
VmErrorKind::DivideByZero(_) => "divide-by-zero",
VmErrorKind::UnboundSymbol(_) => "unbound-symbol",
VmErrorKind::NoMatch(_) => "no-match",
VmErrorKind::BuiltinType(_) => "builtin-type-error",
VmErrorKind::DestructureMismatch(_) => "destructure-mismatch",
}
}
Expand Down Expand Up @@ -3172,6 +3228,15 @@ impl std::fmt::Display for VmError {
// "modulo by zero" so both backends fail the same way.
write!(f, "{kind} by zero")
}
VmErrorKind::UnboundSymbol(name) => {
// Same wording as the interpreter, so both backends fail
// the same way.
write!(f, "unbound symbol '{name}'")
}
VmErrorKind::NoMatch(scrutinee) => {
write!(f, "no match arm matched value: {scrutinee}")
}
VmErrorKind::BuiltinType(msg) => write!(f, "{msg}"),
VmErrorKind::DestructureMismatch(msg) => {
// Wording matches the interpreter's destructuring errors so
// both backends fail the same way.
Expand Down
Loading
Loading