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
30 changes: 30 additions & 0 deletions examples/mget-bang.ilo
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
-- mget! — one-step Optional unwrap with nil propagation.
--
-- `mget m k` returns `O v` (nil if the key is missing, the value if present).
-- Before mget! existed, every reader had to write a two-step bind:
-- r = mget m "k"
-- v = r ?? 0
-- to extract a value, even when the key was known to be present.
--
-- mget! collapses that to a single call: if the key is present, the call
-- yields the value; if missing, nil propagates out of the enclosing function
-- (which therefore must return an Optional).
--
-- Parallels the existing `!` on R-returning calls: Ok→v, Err→propagate.

-- Present key: mget! yields the inner value (5).
present>O n;m=mset mmap "k" 5;v=mget! m "k";v

-- Missing key: mget! propagates nil immediately. The `+v 99` is never reached.
missing>O n;m=mmap;v=mget! m "k";+v 99

-- The two-step `??` idiom still works when a default is preferred over
-- propagation.
defaulted>n;m=mmap;r=mget m "k";v=r??42;v

-- run: present
-- out: 5
-- run: missing
-- out: nil
-- run: defaulted
-- out: 42
27 changes: 26 additions & 1 deletion src/interpreter/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2060,7 +2060,14 @@ fn eval_expr(env: &mut Env, expr: &Expr) -> Result<Value> {
propagate_value: Some(Box::new(Value::Err(e))),
..RuntimeError::new("ILO-R014", "auto-unwrap propagating Err")
}),
other => Ok(other), // non-Result values pass through
// Optional auto-unwrap: nil propagates as the function's return.
// Non-nil values pass through (Optional<T> is represented inline,
// so Some(v) is just v at runtime).
Value::Nil => Err(RuntimeError {
propagate_value: Some(Box::new(Value::Nil)),
..RuntimeError::new("ILO-R014", "auto-unwrap propagating nil")
}),
other => Ok(other), // non-Result/non-nil values pass through
}
} else {
Ok(result)
Expand Down Expand Up @@ -6969,4 +6976,22 @@ mod tests {
let result = run_str(source, Some("f"), vec![Value::Nil]);
assert_eq!(result, Value::Text("none".to_string()));
}

// ── `!` auto-unwrap on Optional: nil propagates as the function's return ──

#[test]
fn interp_mget_bang_missing_propagates_nil() {
// mget on an empty map returns nil; `!` propagates nil out of f.
let source = r#"f>O n;m=mmap;v=mget! m "missing";+v 99"#;
let result = run_str(source, Some("f"), vec![]);
assert_eq!(result, Value::Nil);
}

#[test]
fn interp_mget_bang_present_returns_inner() {
// mget on a present key returns the inner value via `!`.
let source = r#"f>O n;m=mset mmap "k" 5;v=mget! m "k";v"#;
let result = run_str(source, Some("f"), vec![]);
assert_eq!(result, Value::Number(5.0));
}
}
67 changes: 64 additions & 3 deletions src/verify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1950,7 +1950,9 @@ impl VerifyContext {
Ty::Unknown
};

// Auto-unwrap: func! args — callee must return Result, enclosing must return Result
// Auto-unwrap: func! args — callee must return Result or Optional,
// and the enclosing function must return a matching type so the
// propagation (Err / nil) can early-return through it.
if *unwrap {
match &call_ty {
Ty::Result(ok_ty, _err_ty) => {
Expand All @@ -1977,13 +1979,36 @@ impl VerifyContext {
}
*ok_ty.clone()
}
Ty::Optional(inner_ty) => {
// Optional unwrap propagates nil as the function's return.
// The enclosing function's return type must accept nil:
// Optional, Nil, or Unknown (inferred).
let enc_rt =
self.functions.get(func).map(|sig| sig.return_type.clone());
#[allow(for_loops_over_fallibles)]
for rt in enc_rt {
match rt {
Ty::Optional(_) | Ty::Nil | Ty::Unknown => {}
other => {
self.err(
"ILO-T026",
func,
format!("'!' used in function '{func}' which returns {other}, not an Optional"),
Some("the enclosing function must return O to propagate nil".to_string()),
Some(span),
);
}
}
}
*inner_ty.clone()
}
Ty::Unknown => Ty::Unknown,
other => {
self.err(
"ILO-T025",
func,
format!("'!' used on call to '{callee}' which returns {other}, not a Result"),
Some("'!' auto-unwraps Result types: Ok(v)→v, Err(e)→propagate".to_string()),
format!("'!' used on call to '{callee}' which returns {other}, not a Result or Optional"),
Some("'!' auto-unwraps R (Ok→v, Err→propagate) or O (Some→v, Nil→propagate)".to_string()),
Some(span),
);
Ty::Unknown
Expand Down Expand Up @@ -6124,4 +6149,40 @@ mod tests {
let code = "alias vec L n\nf xs:L vec>L n;map avg xs";
assert!(parse_and_verify(code).is_ok());
}

// ── `!` auto-unwrap on Optional (Ty::Optional arm) ────────────────────

#[test]
fn verify_mget_bang_in_optional_returning_fn() {
// Enclosing returns Optional — accepts nil propagation.
let code = r#"f>O n;m=mmap;v=mget! m "k";v"#;
assert!(parse_and_verify(code).is_ok());
}

#[test]
fn verify_mget_bang_in_number_returning_fn_errors() {
// Enclosing returns plain n — must produce ILO-T026.
let code = r#"f>n;m=mmap;v=mget! m "k";v"#;
let errs = parse_and_verify(code).unwrap_err();
assert!(
errs.iter()
.any(|e| e.code == "ILO-T026" && e.message.contains("not an Optional")),
"expected ILO-T026, got: {:?}",
errs
);
}

#[test]
fn verify_bang_on_non_result_non_optional_errors() {
// Calling `!` on a builtin returning plain n triggers ILO-T025
// ("not a Result or Optional") — new wording from this PR.
let code = "f>n;v=abs! -3;v";
let errs = parse_and_verify(code).unwrap_err();
assert!(
errs.iter()
.any(|e| e.code == "ILO-T025" && e.message.contains("not a Result or Optional")),
"expected ILO-T025 with new wording, got: {:?}",
errs
);
}
}
79 changes: 71 additions & 8 deletions src/vm/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1902,6 +1902,19 @@ impl RegCompiler {
let rc = self.compile_expr(&args[1]);
let ra = self.alloc_reg();
self.emit_abc(OP_MGET, ra, rb, rc);
// mget returns O v — handle auto-unwrap:
// if result is non-nil, fall through (value is in ra)
// if nil, RET ra (propagate nil to enclosing fn)
// Trailing OP_MOVE ra, ra acts as a barrier so the
// function-emit check (`last_is_ret`) doesn't treat
// the propagate-RET as the function's tail return.
if *unwrap {
let skip_ret = self.emit_abx(OP_JMPNN, ra, 0);
self.emit_abx(OP_RET, ra, 0);
self.current.patch_jump(skip_ret);
self.emit_abc(OP_MOVE, ra, ra, 0);
self.next_reg = ra + 1;
}
return ra;
}
(Builtin::Mset, 3) => {
Expand Down Expand Up @@ -2003,15 +2016,31 @@ impl RegCompiler {
// After call, only the result register is live
self.next_reg = a + 1;

// Auto-unwrap: Ok(v)→v, Err(e)→return Err to caller
// Auto-unwrap:
// Result: Ok(v)→v, Err(e)→return Err to caller
// Optional: Some(v)→v, Nil→return Nil to caller
if *unwrap {
let check_reg = self.alloc_reg();
self.emit_abc(OP_ISOK, check_reg, a, 0);
let skip_ret = self.emit_jmpt(check_reg);
self.emit_abx(OP_RET, a, 0); // propagate Err
self.current.patch_jump(skip_ret);
self.emit_abc(OP_UNWRAP, a, a, 0); // extract Ok inner
self.next_reg = a + 1; // only result register live
let is_optional = func_idx < self.func_return_types.len()
&& matches!(self.func_return_types[func_idx], Type::Optional(_));
if is_optional {
// Optional propagation: jump over the RET if non-nil.
// Trailing OP_MOVE a, a is a no-op barrier so the
// function-emit `last_is_ret` check doesn't mistake the
// propagate-RET for the function's tail return.
let skip_ret = self.emit_abx(OP_JMPNN, a, 0);
self.emit_abx(OP_RET, a, 0); // propagate nil
self.current.patch_jump(skip_ret);
self.emit_abc(OP_MOVE, a, a, 0);
self.next_reg = a + 1;
} else {
let check_reg = self.alloc_reg();
self.emit_abc(OP_ISOK, check_reg, a, 0);
let skip_ret = self.emit_jmpt(check_reg);
self.emit_abx(OP_RET, a, 0); // propagate Err
self.current.patch_jump(skip_ret);
self.emit_abc(OP_UNWRAP, a, a, 0); // extract Ok inner
self.next_reg = a + 1; // only result register live
}
}

a
Expand Down Expand Up @@ -20774,4 +20803,38 @@ f>n;r=mk 10 20;+r.x r.y";
assert!(v.is_number());
assert!((v.as_number() - 1.0).abs() < 1e-10);
}

// ── `!` auto-unwrap on Optional in the VM ─────────────────────────────

#[test]
fn vm_mget_bang_missing_propagates_nil() {
// mget! on an empty map propagates nil through f.
let source = r#"f>O n;m=mmap;v=mget! m "missing";+v 99"#;
let result = vm_run(source, Some("f"), vec![]);
assert_eq!(result, Value::Nil);
}

#[test]
fn vm_mget_bang_present_returns_inner() {
let source = r#"f>O n;m=mset mmap "k" 7;v=mget! m "k";v"#;
let result = vm_run(source, Some("f"), vec![]);
assert_eq!(result, Value::Number(7.0));
}

#[test]
fn vm_optional_user_fn_bang_present() {
// User-defined Optional-returning fn called with `!`. Exercises the
// generic Optional unwrap path at the Call site (not mget special-case).
let source = "g x:n>O n;?>x 0 x nil\nf>O n;v=g! 5;+v 1";
let result = vm_run(source, Some("f"), vec![]);
assert_eq!(result, Value::Number(6.0));
}

#[test]
fn vm_optional_user_fn_bang_propagates() {
// User fn returns nil — `!` propagates nil out of f.
let source = "g x:n>O n;?>x 0 x nil\nf>O n;v=g! -3;+v 1";
let result = vm_run(source, Some("f"), vec![]);
assert_eq!(result, Value::Nil);
}
}
Loading
Loading