From 3060f5ffc565c570765938ef0ac4b4315db7833e Mon Sep 17 00:00:00 2001 From: "Jonathan D.A. Jewell" <6759885+hyperpolymath@users.noreply.github.com> Date: Fri, 15 May 2026 08:34:58 +0100 Subject: [PATCH] =?UTF-8?q?feat(typing,interp,wasm):=20match=20guards=20?= =?UTF-8?q?=E2=80=94=20`if=20e`=20after=20pattern=20(#67)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit End-to-end implementation of `MatchArm::guard` (the `if e` clause that runs after the pattern matches and before the arm body). The typechecker enforces `Ty::Bool`, the interpreter falls through on `false`, codegen emits an `i32.eqz + br_if $arm_i_fail` after the destructure. ## Typing — `ephapax-typing` * `check_match` now type-checks the guard under the arm's pattern bindings; the guard's type is unified with `Bool` at the guard's span. Non-Bool guards produce `TypeMismatch`. * `check_exhaustiveness` skips guarded arms when building the matrix — guards can refute at runtime, so they don't contribute to coverage. `Some(v) if v > 0 -> ... | None -> ...` is non-exhaustive (missing the Some-non-positive case); only `Some(v) -> ... | None -> ...` (no guard) covers Some entirely. ## Interp — `ephapax-interp` * `eval_match` evaluates the guard after applying pattern bindings. If the guard returns `false`, the bindings are restored to their prior state and the next arm is tried. Failed guards must not leak bindings — confirmed by a regression test where an outer `v=999` survives a refuted inner `Some(v)` arm. * Non-Bool guard values raise `RuntimeError::TypeError { expected: "Bool", found: ... }` — should be unreachable for well-typed programs. ## Wasm — `ephapax-wasm` * `compile_match` emits the guard expression after the pattern destructure. The guard pushes an `i32` onto the stack; `i32.eqz` + `br_if 0` falls through to the surrounding `$arm_i_fail` block when the guard is false, exactly like a refuted nested pattern. * The fall-through machinery established by #68 is reused — no new control-flow structure needed. ## Linear — `ephapax-linear` * No changes. Both `linear.rs::walk_expr` and `affine.rs::walk_expr` already walked into `arm.guard` when computing per-arm snapshots (in place since the variant landed); the existing N-arm branch- agreement check correctly enforces consumption parity across guarded and non-guarded arms. ## Tests * `cargo test -p ephapax-typing --lib` → 73 pass (was 70), +3: - `test_match_guard_typechecks_with_pattern_binding` — guard reads `v` bound by `Some(v)`. - `test_match_guard_non_bool_rejected` — guard of type `I32` rejected as `TypeMismatch`. - `test_match_guarded_arm_not_counted_for_exhaustiveness` — `Some(v) if v > 0 | None` produces `NonExhaustiveMatch`. * `cargo test -p ephapax-interp --lib` → 17 pass (was 14), +3: - `test_eval_match_guard_pass` — `Some(7) if v > 0 => v` ⇒ 7. - `test_eval_match_guard_fail_falls_through` — `Some(-3) if v > 0` falls through to wildcard ⇒ 0. - `test_eval_match_failed_guard_does_not_leak_bindings` — refuted arm's binding is reverted before the next arm runs. * `cargo test -p ephapax-wasm --lib` → 79 pass (was 77), +2: - `compile_module_match_guard_simple` — `Some(v) if v > 0 | Some(_) | None`, validates via `wasmparser`. - `compile_module_match_guard_on_wildcard` — guard on a wildcard catch-all, validates via `wasmparser`. ## Out of scope * Or-patterns inside a single arm — separate feature. * Guard expressions that themselves contain `match` — should fall out for free; no targeted test added. Closes #67. --- src/ephapax-interp/src/lib.rs | 157 ++++++++++++++++++++++++++++++-- src/ephapax-typing/src/lib.rs | 163 +++++++++++++++++++++++++++++++++- src/ephapax-wasm/src/lib.rs | 130 +++++++++++++++++++++++++++ 3 files changed, 443 insertions(+), 7 deletions(-) diff --git a/src/ephapax-interp/src/lib.rs b/src/ephapax-interp/src/lib.rs index 0f5a9a6..1f182fa 100644 --- a/src/ephapax-interp/src/lib.rs +++ b/src/ephapax-interp/src/lib.rs @@ -902,11 +902,6 @@ impl Interpreter { ) -> Result { let scrut_val = self.eval(scrutinee)?; for arm in arms { - if arm.guard.is_some() { - return Err(RuntimeError::Unimplemented( - "match guards not yet implemented in interpreter".into(), - )); - } let mut new_bindings: Vec<(Var, Value)> = Vec::new(); if self.try_match_pattern(&scrut_val, &arm.pattern, &mut new_bindings)? { let saved: Vec<(Var, Option, Option)> = new_bindings @@ -922,6 +917,40 @@ impl Interpreter { for (name, value) in new_bindings { self.env.extend(name, value); } + + // Evaluate guard (if any) under the arm's pattern + // bindings. If it returns `false`, restore prior + // bindings and try the next arm. + let guard_ok = match &arm.guard { + None => true, + Some(g) => match self.eval(g)? { + Value::Bool(b) => b, + other => { + return Err(RuntimeError::TypeError { + expected: "Bool".into(), + found: other.type_name().into(), + }); + } + }, + }; + + if !guard_ok { + for (name, prev_val, prev_consumed) in saved.iter().rev() { + match prev_val { + Some(v) => { + self.env.bindings.insert(name.clone(), v.clone()); + if let Some(c) = prev_consumed { + self.env.consumed.insert(name.clone(), *c); + } + } + None => { + self.env.remove(name); + } + } + } + continue; + } + let result = self.eval(&arm.body)?; for (name, prev_val, prev_consumed) in saved.iter().rev() { match prev_val { @@ -1701,4 +1730,122 @@ mod tests { // with the typing test module pattern). let _ = Visibility::Private; } + + // ----- Match guards (#67) ----- + + /// Guard reads pattern-bound var and evaluates to `true` → arm body + /// runs. `match Some(7) { Some(v) if v > 0 => v | _ => 0 }` = 7. + #[test] + fn test_eval_match_guard_pass() { + let mut interp = Interpreter::new(); + interp.load_module(&option_module()); + let scrut = dummy_expr(ExprKind::Inr { + ty: Ty::Base(BaseTy::Unit), + value: Box::new(lit_i32_expr(7)), + }); + let guard = dummy_expr(ExprKind::BinOp { + op: BinOp::Gt, + left: Box::new(dummy_expr(ExprKind::Var("v".into()))), + right: Box::new(lit_i32_expr(0)), + }); + let expr = dummy_expr(ExprKind::Match { + scrutinee: Box::new(scrut), + arms: vec![ + MatchArm { + pattern: P::Constructor { + ctor: "Some".into(), + args: vec![P::Var("v".into())], + }, + guard: Some(Box::new(guard)), + body: dummy_expr(ExprKind::Var("v".into())), + }, + MatchArm { + pattern: P::Wildcard, + guard: None, + body: lit_i32_expr(0), + }, + ], + }); + let result = interp.eval(&expr).expect("guarded match should evaluate"); + assert!(matches!(result, Value::I32(7)), "got {:?}", result); + } + + /// Guard evaluates to `false` → arm is skipped, next arm runs. + /// `match Some(-3) { Some(v) if v > 0 => v | _ => 0 }` = 0. + #[test] + fn test_eval_match_guard_fail_falls_through() { + let mut interp = Interpreter::new(); + interp.load_module(&option_module()); + let scrut = dummy_expr(ExprKind::Inr { + ty: Ty::Base(BaseTy::Unit), + value: Box::new(lit_i32_expr(-3)), + }); + let guard = dummy_expr(ExprKind::BinOp { + op: BinOp::Gt, + left: Box::new(dummy_expr(ExprKind::Var("v".into()))), + right: Box::new(lit_i32_expr(0)), + }); + let expr = dummy_expr(ExprKind::Match { + scrutinee: Box::new(scrut), + arms: vec![ + MatchArm { + pattern: P::Constructor { + ctor: "Some".into(), + args: vec![P::Var("v".into())], + }, + guard: Some(Box::new(guard)), + body: dummy_expr(ExprKind::Var("v".into())), + }, + MatchArm { + pattern: P::Wildcard, + guard: None, + body: lit_i32_expr(0), + }, + ], + }); + let result = interp.eval(&expr).expect("guard fall-through should evaluate"); + assert!(matches!(result, Value::I32(0)), "got {:?}", result); + } + + /// Failed guard must not leak its bindings into subsequent arms. + /// `v` is pre-bound in the enclosing scope; the first arm tries to + /// rebind `v` and its guard fails — the second arm must see the + /// original `v`. + #[test] + fn test_eval_match_failed_guard_does_not_leak_bindings() { + let mut interp = Interpreter::new(); + interp.load_module(&option_module()); + interp.env.extend("v".into(), Value::I32(999)); + + let scrut = dummy_expr(ExprKind::Inr { + ty: Ty::Base(BaseTy::Unit), + value: Box::new(lit_i32_expr(-1)), + }); + let guard = dummy_expr(ExprKind::BinOp { + op: BinOp::Gt, + left: Box::new(dummy_expr(ExprKind::Var("v".into()))), + right: Box::new(lit_i32_expr(0)), + }); + let expr = dummy_expr(ExprKind::Match { + scrutinee: Box::new(scrut), + arms: vec![ + MatchArm { + pattern: P::Constructor { + ctor: "Some".into(), + args: vec![P::Var("v".into())], + }, + guard: Some(Box::new(guard)), + body: dummy_expr(ExprKind::Var("v".into())), + }, + MatchArm { + pattern: P::Wildcard, + guard: None, + // Second arm reads the OUTER `v` (still 999). + body: dummy_expr(ExprKind::Var("v".into())), + }, + ], + }); + let result = interp.eval(&expr).expect("fall-through should evaluate"); + assert!(matches!(result, Value::I32(999)), "got {:?}", result); + } } diff --git a/src/ephapax-typing/src/lib.rs b/src/ephapax-typing/src/lib.rs index 0357cd4..c4da7c9 100644 --- a/src/ephapax-typing/src/lib.rs +++ b/src/ephapax-typing/src/lib.rs @@ -1473,8 +1473,9 @@ impl TypeChecker { let bound = self.check_pattern(s, &arm.pattern, &scrutinee_ty)?; - if arm.guard.is_some() { - return Err(self.at(s, TypeError::NotYetSupportedInCore("match guards"))); + if let Some(guard) = &arm.guard { + let guard_ty = self.check(guard)?; + self.unify(guard.span, &Ty::Base(BaseTy::Bool), &guard_ty)?; } let body_ty = self.check(&arm.body)?; @@ -1651,8 +1652,12 @@ impl TypeChecker { arms: &[MatchArm], _scrutinee_ty: &Ty, ) -> Result<(), SpannedTypeError> { + // Guarded arms can refute at runtime — they don't contribute + // to coverage. Standard treatment: build the exhaustiveness + // matrix from arms whose `guard.is_none()`. let matrix: Vec> = arms .iter() + .filter(|a| a.guard.is_none()) .map(|a| vec![a.pattern.clone()]) .collect(); if let Some(witness) = is_useful(&matrix, 1, &self.data_registry) { @@ -4331,4 +4336,158 @@ mod tests { other => panic!("expected NonExhaustiveMatch, got {other:?}"), } } + + // ----- Match guards (#67) ----- + + /// `match x { Some(v) if v > 0 => 1 | Some(v) => 2 | None => 0 }` + /// — guard uses bound pattern var; non-guarded `Some(v)` covers + /// the remaining case, so exhaustiveness holds. + #[test] + fn test_match_guard_typechecks_with_pattern_binding() { + let scrut = dummy_expr(ExprKind::Inr { + ty: Ty::Base(BaseTy::Unit), + value: Box::new(lit_i32(5)), + }); + let arms = vec![ + MatchArm { + pattern: P::Constructor { + ctor: "Some".into(), + args: vec![P::Var("v".into())], + }, + guard: Some(Box::new(dummy_expr(ExprKind::BinOp { + op: BinOp::Gt, + left: Box::new(dummy_expr(ExprKind::Var("v".into()))), + right: Box::new(lit_i32(0)), + }))), + body: lit_i32(1), + }, + MatchArm { + pattern: P::Constructor { + ctor: "Some".into(), + args: vec![P::Var("v".into())], + }, + guard: None, + body: dummy_expr(ExprKind::Var("v".into())), + }, + MatchArm { + pattern: P::Constructor { + ctor: "None".into(), + args: vec![], + }, + guard: None, + body: lit_i32(0), + }, + ]; + let body = dummy_expr(ExprKind::Match { + scrutinee: Box::new(scrut), + arms, + }); + let module = Module { + name: "t".into(), + imports: vec![], + decls: vec![ + option_data_decl(), + fn_decl("f", vec![], Ty::Base(BaseTy::I32), body), + ], + }; + type_check_module(&module).expect("guard binding to v: I32 should typecheck"); + } + + /// A guard whose expression is not `Bool` is rejected with a + /// type mismatch. + #[test] + fn test_match_guard_non_bool_rejected() { + let scrut = dummy_expr(ExprKind::Inr { + ty: Ty::Base(BaseTy::Unit), + value: Box::new(lit_i32(5)), + }); + let arms = vec![ + MatchArm { + pattern: P::Constructor { + ctor: "Some".into(), + args: vec![P::Var("v".into())], + }, + guard: Some(Box::new(lit_i32(42))), // I32, not Bool + body: lit_i32(1), + }, + MatchArm { + pattern: P::Wildcard, + guard: None, + body: lit_i32(0), + }, + ]; + let body = dummy_expr(ExprKind::Match { + scrutinee: Box::new(scrut), + arms, + }); + let module = Module { + name: "t".into(), + imports: vec![], + decls: vec![ + option_data_decl(), + fn_decl("f", vec![], Ty::Base(BaseTy::I32), body), + ], + }; + let err = type_check_module(&module).unwrap_err(); + assert!( + matches!(err.error, TypeError::TypeMismatch { .. }), + "expected TypeMismatch for non-Bool guard, got {:?}", + err.error + ); + } + + /// A guarded arm cannot make the match exhaustive on its own — + /// its guard could refute at runtime. `Some(v) if v > 0` followed + /// by `None` is missing the `Some` non-positive case. + #[test] + fn test_match_guarded_arm_not_counted_for_exhaustiveness() { + let scrut = dummy_expr(ExprKind::Inr { + ty: Ty::Base(BaseTy::Unit), + value: Box::new(lit_i32(5)), + }); + let arms = vec![ + MatchArm { + pattern: P::Constructor { + ctor: "Some".into(), + args: vec![P::Var("v".into())], + }, + guard: Some(Box::new(dummy_expr(ExprKind::BinOp { + op: BinOp::Gt, + left: Box::new(dummy_expr(ExprKind::Var("v".into()))), + right: Box::new(lit_i32(0)), + }))), + body: lit_i32(1), + }, + MatchArm { + pattern: P::Constructor { + ctor: "None".into(), + args: vec![], + }, + guard: None, + body: lit_i32(0), + }, + ]; + let body = dummy_expr(ExprKind::Match { + scrutinee: Box::new(scrut), + arms, + }); + let module = Module { + name: "t".into(), + imports: vec![], + decls: vec![ + option_data_decl(), + fn_decl("f", vec![], Ty::Base(BaseTy::I32), body), + ], + }; + let err = type_check_module(&module).unwrap_err(); + match err.error { + TypeError::NonExhaustiveMatch { missing } => { + assert!( + missing.as_str().contains("Some"), + "expected witness mentioning Some, got {missing}" + ); + } + other => panic!("expected NonExhaustiveMatch, got {other:?}"), + } + } } diff --git a/src/ephapax-wasm/src/lib.rs b/src/ephapax-wasm/src/lib.rs index 62c90a7..257fe3d 100644 --- a/src/ephapax-wasm/src/lib.rs +++ b/src/ephapax-wasm/src/lib.rs @@ -2408,6 +2408,16 @@ impl Codegen { // produces an i32 on the stack; we br $done (depth 1) to // carry it past the remaining arms. self.compile_top_pattern(func, &arm.pattern, scrut_local, payload_local); + + // Match guard (if any): evaluate under the arm's pattern + // bindings; `br_if $arm_i_fail` (depth 0) when the guard + // is false. + if let Some(guard) = &arm.guard { + self.compile_expr(func, guard); + func.instruction(&Instruction::I32Eqz); + func.instruction(&Instruction::BrIf(0)); + } + self.compile_expr(func, &arm.body); func.instruction(&Instruction::Br(1)); @@ -4922,4 +4932,124 @@ mod tests { assert_wasm_header(&wasm); validate_wasm(&wasm); } + + // ----- Match guards (#67) ----- + + /// Guarded arm with a pattern-bound var: `Some(v) if v > 0 -> v | + /// Some(_) -> 0 | None -> -1`. Codegen emits the guard expression + /// inside `$arm_0_fail`; `i32.eqz + br_if 0` falls through to + /// arm 1 when the guard is false. + #[test] + fn compile_module_match_guard_simple() { + let scrut = e(ExprKind::Inr { + ty: Ty::Base(BaseTy::Unit), + value: Box::new(e(ExprKind::Lit(Literal::I32(5)))), + }); + let guard = e(ExprKind::BinOp { + op: BinOp::Gt, + left: Box::new(e(ExprKind::Var("v".into()))), + right: Box::new(e(ExprKind::Lit(Literal::I32(0)))), + }); + let body = e(ExprKind::Match { + scrutinee: Box::new(scrut), + arms: vec![ + MatchArm { + pattern: P::Constructor { + ctor: "Some".into(), + args: vec![P::Var("v".into())], + }, + guard: Some(Box::new(guard)), + body: e(ExprKind::Var("v".into())), + }, + MatchArm { + pattern: P::Constructor { + ctor: "Some".into(), + args: vec![P::Wildcard], + }, + guard: None, + body: e(ExprKind::Lit(Literal::I32(0))), + }, + MatchArm { + pattern: P::Constructor { + ctor: "None".into(), + args: vec![], + }, + guard: None, + body: e(ExprKind::Lit(Literal::I32(-1))), + }, + ], + }); + let module = AstModule { + name: "t".into(), + imports: vec![], + decls: vec![ + option_data_decl(), + Decl::Fn { + name: "f".into(), + visibility: Visibility::Private, + type_params: vec![], + params: vec![], + ret_ty: Ty::Base(BaseTy::I32), + body, + }, + ], + }; + let wasm = + compile_module(&module).expect("guarded match should compile"); + assert_wasm_header(&wasm); + validate_wasm(&wasm); + } + + /// Guard on a wildcard catch-all: `Some(v) -> v | _ if false -> 0 + /// | _ -> 1`. Confirms the guard machinery works for non-ctor + /// top-level patterns too. + #[test] + fn compile_module_match_guard_on_wildcard() { + let scrut = e(ExprKind::Inl { + ty: Ty::Base(BaseTy::I32), + value: Box::new(e(ExprKind::Lit(Literal::Unit))), + }); + let body = e(ExprKind::Match { + scrutinee: Box::new(scrut), + arms: vec![ + MatchArm { + pattern: P::Constructor { + ctor: "Some".into(), + args: vec![P::Var("v".into())], + }, + guard: None, + body: e(ExprKind::Var("v".into())), + }, + MatchArm { + pattern: P::Wildcard, + guard: Some(Box::new(e(ExprKind::Lit(Literal::Bool(false))))), + body: e(ExprKind::Lit(Literal::I32(0))), + }, + MatchArm { + pattern: P::Wildcard, + guard: None, + body: e(ExprKind::Lit(Literal::I32(1))), + }, + ], + }); + let module = AstModule { + name: "t".into(), + imports: vec![], + decls: vec![ + option_data_decl(), + Decl::Fn { + name: "f".into(), + visibility: Visibility::Private, + type_params: vec![], + params: vec![], + ret_ty: Ty::Base(BaseTy::I32), + body, + }, + ], + }; + let wasm = + compile_module(&module).expect("guard on wildcard should compile"); + assert_wasm_header(&wasm); + validate_wasm(&wasm); + } }