Skip to content

Commit 3a16888

Browse files
core: match guards through typing + interp + wasm (#67) (#74)
Closes #67. ## What changed Match guards (`if e` after a pattern) now work end-to-end through `ephapax-typing`, `ephapax-interp`, and `ephapax-wasm`. The grammar already carries `MatchArm::guard: Option<Box<Expr>>` from #61 — this PR replaces the `NotYetSupportedInCore` stub in typing, the `Unimplemented` stub in interp, and adds the `i32.eqz + br_if` emission in wasm. ## Per-crate changes ### `ephapax-typing` - `check_match` type-checks the guard under the arm's pattern bindings; unified against `Bool` at the guard's span. Non-Bool guards return `TypeMismatch`. - `check_exhaustiveness` now skips guarded arms when building the Maranget matrix — guards can refute at runtime so they don't contribute to coverage. `Some(v) if v > 0 -> ... | None -> ...` is correctly reported as non-exhaustive. ### `ephapax-interp` - `eval_match` evaluates the guard after applying the pattern bindings. `false` ⇒ restore bindings, continue to next arm. `true` ⇒ run body. - Failed guards must not leak bindings — verified by a test where an outer `v=999` survives a refuted `Some(v)` arm. ### `ephapax-wasm` - `compile_match` emits the guard expression after the destructure. `i32.eqz + br_if 0` falls through to the surrounding `$arm_i_fail` block, reusing the per-arm fall-through machinery from #68. ### `ephapax-linear` - No changes. The N-arm branch-agreement check from #65 already walks `arm.guard`, so guarded-arm linearity is enforced automatically. ## Behaviour table | Code | Outcome | |---|---| | `match x { Some(v) if v > 0 => v \| Some(v) => v \| None => 0 }` | Typechecks; covers Some entirely via the second arm. | | `match x { Some(v) if v > 0 => v \| None => 0 }` | `NonExhaustiveMatch { missing: "Some(_)" }`. | | `Some(_) if 42 => ...` | `TypeMismatch` (guard is `I32`, expected `Bool`). | | `Some(7) if v > 0 => v` | Interp returns 7. | | `Some(-3) if v > 0 => v \| _ => 0` | Interp returns 0 (guard falls through). | ## Test plan - [x] `cargo test -p ephapax-typing --lib` → 73 pass (was 70), +3 guard tests. - [x] `cargo test -p ephapax-interp --lib` → 17 pass (was 14), +3 guard tests. - [x] `cargo test -p ephapax-wasm --lib` → 79 pass (was 77), +2 guard tests (validated via `wasmparser`). - [x] Full workspace lib tests pass. - [ ] CI green on all checks. ## Out of scope - Or-patterns inside a single arm — separate feature. - Guards containing `match` — should fall out for free; no targeted test added. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
1 parent 89217e6 commit 3a16888

3 files changed

Lines changed: 443 additions & 7 deletions

File tree

src/ephapax-interp/src/lib.rs

Lines changed: 152 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -902,11 +902,6 @@ impl Interpreter {
902902
) -> Result<Value, RuntimeError> {
903903
let scrut_val = self.eval(scrutinee)?;
904904
for arm in arms {
905-
if arm.guard.is_some() {
906-
return Err(RuntimeError::Unimplemented(
907-
"match guards not yet implemented in interpreter".into(),
908-
));
909-
}
910905
let mut new_bindings: Vec<(Var, Value)> = Vec::new();
911906
if self.try_match_pattern(&scrut_val, &arm.pattern, &mut new_bindings)? {
912907
let saved: Vec<(Var, Option<Value>, Option<bool>)> = new_bindings
@@ -922,6 +917,40 @@ impl Interpreter {
922917
for (name, value) in new_bindings {
923918
self.env.extend(name, value);
924919
}
920+
921+
// Evaluate guard (if any) under the arm's pattern
922+
// bindings. If it returns `false`, restore prior
923+
// bindings and try the next arm.
924+
let guard_ok = match &arm.guard {
925+
None => true,
926+
Some(g) => match self.eval(g)? {
927+
Value::Bool(b) => b,
928+
other => {
929+
return Err(RuntimeError::TypeError {
930+
expected: "Bool".into(),
931+
found: other.type_name().into(),
932+
});
933+
}
934+
},
935+
};
936+
937+
if !guard_ok {
938+
for (name, prev_val, prev_consumed) in saved.iter().rev() {
939+
match prev_val {
940+
Some(v) => {
941+
self.env.bindings.insert(name.clone(), v.clone());
942+
if let Some(c) = prev_consumed {
943+
self.env.consumed.insert(name.clone(), *c);
944+
}
945+
}
946+
None => {
947+
self.env.remove(name);
948+
}
949+
}
950+
}
951+
continue;
952+
}
953+
925954
let result = self.eval(&arm.body)?;
926955
for (name, prev_val, prev_consumed) in saved.iter().rev() {
927956
match prev_val {
@@ -1701,4 +1730,122 @@ mod tests {
17011730
// with the typing test module pattern).
17021731
let _ = Visibility::Private;
17031732
}
1733+
1734+
// ----- Match guards (#67) -----
1735+
1736+
/// Guard reads pattern-bound var and evaluates to `true` → arm body
1737+
/// runs. `match Some(7) { Some(v) if v > 0 => v | _ => 0 }` = 7.
1738+
#[test]
1739+
fn test_eval_match_guard_pass() {
1740+
let mut interp = Interpreter::new();
1741+
interp.load_module(&option_module());
1742+
let scrut = dummy_expr(ExprKind::Inr {
1743+
ty: Ty::Base(BaseTy::Unit),
1744+
value: Box::new(lit_i32_expr(7)),
1745+
});
1746+
let guard = dummy_expr(ExprKind::BinOp {
1747+
op: BinOp::Gt,
1748+
left: Box::new(dummy_expr(ExprKind::Var("v".into()))),
1749+
right: Box::new(lit_i32_expr(0)),
1750+
});
1751+
let expr = dummy_expr(ExprKind::Match {
1752+
scrutinee: Box::new(scrut),
1753+
arms: vec![
1754+
MatchArm {
1755+
pattern: P::Constructor {
1756+
ctor: "Some".into(),
1757+
args: vec![P::Var("v".into())],
1758+
},
1759+
guard: Some(Box::new(guard)),
1760+
body: dummy_expr(ExprKind::Var("v".into())),
1761+
},
1762+
MatchArm {
1763+
pattern: P::Wildcard,
1764+
guard: None,
1765+
body: lit_i32_expr(0),
1766+
},
1767+
],
1768+
});
1769+
let result = interp.eval(&expr).expect("guarded match should evaluate");
1770+
assert!(matches!(result, Value::I32(7)), "got {:?}", result);
1771+
}
1772+
1773+
/// Guard evaluates to `false` → arm is skipped, next arm runs.
1774+
/// `match Some(-3) { Some(v) if v > 0 => v | _ => 0 }` = 0.
1775+
#[test]
1776+
fn test_eval_match_guard_fail_falls_through() {
1777+
let mut interp = Interpreter::new();
1778+
interp.load_module(&option_module());
1779+
let scrut = dummy_expr(ExprKind::Inr {
1780+
ty: Ty::Base(BaseTy::Unit),
1781+
value: Box::new(lit_i32_expr(-3)),
1782+
});
1783+
let guard = dummy_expr(ExprKind::BinOp {
1784+
op: BinOp::Gt,
1785+
left: Box::new(dummy_expr(ExprKind::Var("v".into()))),
1786+
right: Box::new(lit_i32_expr(0)),
1787+
});
1788+
let expr = dummy_expr(ExprKind::Match {
1789+
scrutinee: Box::new(scrut),
1790+
arms: vec![
1791+
MatchArm {
1792+
pattern: P::Constructor {
1793+
ctor: "Some".into(),
1794+
args: vec![P::Var("v".into())],
1795+
},
1796+
guard: Some(Box::new(guard)),
1797+
body: dummy_expr(ExprKind::Var("v".into())),
1798+
},
1799+
MatchArm {
1800+
pattern: P::Wildcard,
1801+
guard: None,
1802+
body: lit_i32_expr(0),
1803+
},
1804+
],
1805+
});
1806+
let result = interp.eval(&expr).expect("guard fall-through should evaluate");
1807+
assert!(matches!(result, Value::I32(0)), "got {:?}", result);
1808+
}
1809+
1810+
/// Failed guard must not leak its bindings into subsequent arms.
1811+
/// `v` is pre-bound in the enclosing scope; the first arm tries to
1812+
/// rebind `v` and its guard fails — the second arm must see the
1813+
/// original `v`.
1814+
#[test]
1815+
fn test_eval_match_failed_guard_does_not_leak_bindings() {
1816+
let mut interp = Interpreter::new();
1817+
interp.load_module(&option_module());
1818+
interp.env.extend("v".into(), Value::I32(999));
1819+
1820+
let scrut = dummy_expr(ExprKind::Inr {
1821+
ty: Ty::Base(BaseTy::Unit),
1822+
value: Box::new(lit_i32_expr(-1)),
1823+
});
1824+
let guard = dummy_expr(ExprKind::BinOp {
1825+
op: BinOp::Gt,
1826+
left: Box::new(dummy_expr(ExprKind::Var("v".into()))),
1827+
right: Box::new(lit_i32_expr(0)),
1828+
});
1829+
let expr = dummy_expr(ExprKind::Match {
1830+
scrutinee: Box::new(scrut),
1831+
arms: vec![
1832+
MatchArm {
1833+
pattern: P::Constructor {
1834+
ctor: "Some".into(),
1835+
args: vec![P::Var("v".into())],
1836+
},
1837+
guard: Some(Box::new(guard)),
1838+
body: dummy_expr(ExprKind::Var("v".into())),
1839+
},
1840+
MatchArm {
1841+
pattern: P::Wildcard,
1842+
guard: None,
1843+
// Second arm reads the OUTER `v` (still 999).
1844+
body: dummy_expr(ExprKind::Var("v".into())),
1845+
},
1846+
],
1847+
});
1848+
let result = interp.eval(&expr).expect("fall-through should evaluate");
1849+
assert!(matches!(result, Value::I32(999)), "got {:?}", result);
1850+
}
17041851
}

src/ephapax-typing/src/lib.rs

Lines changed: 161 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1473,8 +1473,9 @@ impl TypeChecker {
14731473

14741474
let bound = self.check_pattern(s, &arm.pattern, &scrutinee_ty)?;
14751475

1476-
if arm.guard.is_some() {
1477-
return Err(self.at(s, TypeError::NotYetSupportedInCore("match guards")));
1476+
if let Some(guard) = &arm.guard {
1477+
let guard_ty = self.check(guard)?;
1478+
self.unify(guard.span, &Ty::Base(BaseTy::Bool), &guard_ty)?;
14781479
}
14791480

14801481
let body_ty = self.check(&arm.body)?;
@@ -1651,8 +1652,12 @@ impl TypeChecker {
16511652
arms: &[MatchArm],
16521653
_scrutinee_ty: &Ty,
16531654
) -> Result<(), SpannedTypeError> {
1655+
// Guarded arms can refute at runtime — they don't contribute
1656+
// to coverage. Standard treatment: build the exhaustiveness
1657+
// matrix from arms whose `guard.is_none()`.
16541658
let matrix: Vec<Vec<Pattern>> = arms
16551659
.iter()
1660+
.filter(|a| a.guard.is_none())
16561661
.map(|a| vec![a.pattern.clone()])
16571662
.collect();
16581663
if let Some(witness) = is_useful(&matrix, 1, &self.data_registry) {
@@ -4331,4 +4336,158 @@ mod tests {
43314336
other => panic!("expected NonExhaustiveMatch, got {other:?}"),
43324337
}
43334338
}
4339+
4340+
// ----- Match guards (#67) -----
4341+
4342+
/// `match x { Some(v) if v > 0 => 1 | Some(v) => 2 | None => 0 }`
4343+
/// — guard uses bound pattern var; non-guarded `Some(v)` covers
4344+
/// the remaining case, so exhaustiveness holds.
4345+
#[test]
4346+
fn test_match_guard_typechecks_with_pattern_binding() {
4347+
let scrut = dummy_expr(ExprKind::Inr {
4348+
ty: Ty::Base(BaseTy::Unit),
4349+
value: Box::new(lit_i32(5)),
4350+
});
4351+
let arms = vec![
4352+
MatchArm {
4353+
pattern: P::Constructor {
4354+
ctor: "Some".into(),
4355+
args: vec![P::Var("v".into())],
4356+
},
4357+
guard: Some(Box::new(dummy_expr(ExprKind::BinOp {
4358+
op: BinOp::Gt,
4359+
left: Box::new(dummy_expr(ExprKind::Var("v".into()))),
4360+
right: Box::new(lit_i32(0)),
4361+
}))),
4362+
body: lit_i32(1),
4363+
},
4364+
MatchArm {
4365+
pattern: P::Constructor {
4366+
ctor: "Some".into(),
4367+
args: vec![P::Var("v".into())],
4368+
},
4369+
guard: None,
4370+
body: dummy_expr(ExprKind::Var("v".into())),
4371+
},
4372+
MatchArm {
4373+
pattern: P::Constructor {
4374+
ctor: "None".into(),
4375+
args: vec![],
4376+
},
4377+
guard: None,
4378+
body: lit_i32(0),
4379+
},
4380+
];
4381+
let body = dummy_expr(ExprKind::Match {
4382+
scrutinee: Box::new(scrut),
4383+
arms,
4384+
});
4385+
let module = Module {
4386+
name: "t".into(),
4387+
imports: vec![],
4388+
decls: vec![
4389+
option_data_decl(),
4390+
fn_decl("f", vec![], Ty::Base(BaseTy::I32), body),
4391+
],
4392+
};
4393+
type_check_module(&module).expect("guard binding to v: I32 should typecheck");
4394+
}
4395+
4396+
/// A guard whose expression is not `Bool` is rejected with a
4397+
/// type mismatch.
4398+
#[test]
4399+
fn test_match_guard_non_bool_rejected() {
4400+
let scrut = dummy_expr(ExprKind::Inr {
4401+
ty: Ty::Base(BaseTy::Unit),
4402+
value: Box::new(lit_i32(5)),
4403+
});
4404+
let arms = vec![
4405+
MatchArm {
4406+
pattern: P::Constructor {
4407+
ctor: "Some".into(),
4408+
args: vec![P::Var("v".into())],
4409+
},
4410+
guard: Some(Box::new(lit_i32(42))), // I32, not Bool
4411+
body: lit_i32(1),
4412+
},
4413+
MatchArm {
4414+
pattern: P::Wildcard,
4415+
guard: None,
4416+
body: lit_i32(0),
4417+
},
4418+
];
4419+
let body = dummy_expr(ExprKind::Match {
4420+
scrutinee: Box::new(scrut),
4421+
arms,
4422+
});
4423+
let module = Module {
4424+
name: "t".into(),
4425+
imports: vec![],
4426+
decls: vec![
4427+
option_data_decl(),
4428+
fn_decl("f", vec![], Ty::Base(BaseTy::I32), body),
4429+
],
4430+
};
4431+
let err = type_check_module(&module).unwrap_err();
4432+
assert!(
4433+
matches!(err.error, TypeError::TypeMismatch { .. }),
4434+
"expected TypeMismatch for non-Bool guard, got {:?}",
4435+
err.error
4436+
);
4437+
}
4438+
4439+
/// A guarded arm cannot make the match exhaustive on its own —
4440+
/// its guard could refute at runtime. `Some(v) if v > 0` followed
4441+
/// by `None` is missing the `Some` non-positive case.
4442+
#[test]
4443+
fn test_match_guarded_arm_not_counted_for_exhaustiveness() {
4444+
let scrut = dummy_expr(ExprKind::Inr {
4445+
ty: Ty::Base(BaseTy::Unit),
4446+
value: Box::new(lit_i32(5)),
4447+
});
4448+
let arms = vec![
4449+
MatchArm {
4450+
pattern: P::Constructor {
4451+
ctor: "Some".into(),
4452+
args: vec![P::Var("v".into())],
4453+
},
4454+
guard: Some(Box::new(dummy_expr(ExprKind::BinOp {
4455+
op: BinOp::Gt,
4456+
left: Box::new(dummy_expr(ExprKind::Var("v".into()))),
4457+
right: Box::new(lit_i32(0)),
4458+
}))),
4459+
body: lit_i32(1),
4460+
},
4461+
MatchArm {
4462+
pattern: P::Constructor {
4463+
ctor: "None".into(),
4464+
args: vec![],
4465+
},
4466+
guard: None,
4467+
body: lit_i32(0),
4468+
},
4469+
];
4470+
let body = dummy_expr(ExprKind::Match {
4471+
scrutinee: Box::new(scrut),
4472+
arms,
4473+
});
4474+
let module = Module {
4475+
name: "t".into(),
4476+
imports: vec![],
4477+
decls: vec![
4478+
option_data_decl(),
4479+
fn_decl("f", vec![], Ty::Base(BaseTy::I32), body),
4480+
],
4481+
};
4482+
let err = type_check_module(&module).unwrap_err();
4483+
match err.error {
4484+
TypeError::NonExhaustiveMatch { missing } => {
4485+
assert!(
4486+
missing.as_str().contains("Some"),
4487+
"expected witness mentioning Some, got {missing}"
4488+
);
4489+
}
4490+
other => panic!("expected NonExhaustiveMatch, got {other:?}"),
4491+
}
4492+
}
43344493
}

0 commit comments

Comments
 (0)