diff --git a/cli/t27b/src/lower.rs b/cli/t27b/src/lower.rs index dfa9f08c8c..4b5fb31127 100644 --- a/cli/t27b/src/lower.rs +++ b/cli/t27b/src/lower.rs @@ -1389,7 +1389,9 @@ impl<'a> Lower<'a> { } self.begin_body(&n.children); self.in_test = true; - self.unanalyzed_fn = false; + // A bench is an uncalled `fn bench_() void` in the reference's + // Zig, so `zig test` never analyzes its body (see `analyzed_fns`). + self.unanalyzed_fn = self.in_bench; self.comptime = invariant; self.ret = None; self.ret_poison = false; @@ -2252,7 +2254,20 @@ impl<'a> Lower<'a> { Ok(()) } _ => { - let (call, _, _) = self.call(c, None)?; + let (call, ret, _) = self.call(c, None)?; + // `f(x);` with a non-void `f`: t27c prints it as is, and Zig + // refuses it ("value of type 'bool' ignored") wherever it + // analyzes the body -- the same rule as `value_stmt`. + if let Some(t) = ret.filter(|_| !self.unanalyzed_fn) { + let t = self.type_name(&t); + return self.reject( + "ExprCall(value ignored) statement", + format!( + "call to `{}`, which returns {}, as a statement where a test, invariant or bench reaches it: the reference's Zig does not compile it (value ignored)", + c.name, t + ), + ); + } out.push(Stmt::Eval(call)); Ok(()) } @@ -2671,6 +2686,7 @@ impl<'a> Lower<'a> { self.reject("ExprArrayLiteral", "array literal with no result type".into()) } NodeKind::ExprIf => self.if_expr(n, None), + NodeKind::ExprSwitch => self.switch_expr(n, None), NodeKind::ExprStructLit => { if n.name.is_empty() { return self.reject("ExprStructLit", "anonymous `.{}` literal with no result type".into()); @@ -2858,8 +2874,16 @@ impl<'a> Lower<'a> { } Err(why) => return self.reject("ExprLiteral(float literal)", format!("`{}`: {}", s, why)), } - } else if s.starts_with('-') && parse_int(&s[1..]).is_some() { - "negative literal" + } else if let Some(v) = s.strip_prefix('-').and_then(parse_int) { + // `const X: i32 = -1;`: the t27c parser folds the minus + // into the literal, and its Zig backend prints it back + // as written -- Zig's comptime_int `-1`. + let suffix = n.extra_type.trim(); + if suffix.is_empty() { + return Ok(Val::Ct(-v)); + } + let ty = self.ty(suffix)?; + return Ok(Val::E(self.coerce(Val::Ct(-v), ty)?)); } else { "literal" }; @@ -3992,9 +4016,10 @@ impl<'a> Lower<'a> { /// Evaluate `n` as a value of type `want`: a struct literal takes its /// type from here, so it may be anonymous (`.{ ... }`). fn expr_as(&mut self, n: &Node, want: <y) -> R { - if n.kind == NodeKind::ExprIf { + if n.kind == NodeKind::ExprIf || n.kind == NodeKind::ExprSwitch { self.see(n); - return match self.if_expr(n, Some(want)) { + let r = if n.kind == NodeKind::ExprIf { self.if_expr(n, Some(want)) } else { self.switch_expr(n, Some(want)) }; + return match r { Err(()) if self.recover => Ok(Val::Poison), r => r, }; @@ -4125,6 +4150,14 @@ impl<'a> Lower<'a> { if a.is_poison() || b.is_poison() { return Err(()); } + self.select_vals(cond, a, b, want, "ExprIf", "both arms are untyped literals") + } + + /// `cond ? a : b` over two arms already lowered (to `want`, when there is + /// one): only the taken arm runs. With no result type, Zig's peer type of + /// the two; an aggregate selects the address of its arm. `what` names the + /// construct in a refusal. + fn select_vals(&mut self, cond: Expr, a: Val, b: Val, want: Option<<y>, what: &str, untyped: &str) -> R { let select = |cond: Expr, x: Expr, y: Expr| Expr { ty: x.ty, kind: ExprKind::Select { cond: Box::new(cond), then: Box::new(x), els: Box::new(y) }, @@ -4134,8 +4167,8 @@ impl<'a> Lower<'a> { Some(_) => (a, b), None if matches!(a, Val::Ct(_) | Val::Cf(..)) && matches!(b, Val::Ct(_) | Val::Cf(..)) => { return self.reject( - "ExprIf(comptime arms)", - "both arms are untyped literals and the condition is runtime: Zig needs a result type".into(), + &format!("{}(comptime arms)", what), + format!("{} and the condition is runtime: Zig needs a result type", untyped), ) } // Peer type of strings (and of a string and a literal) is `[]const u8`. @@ -4144,7 +4177,7 @@ impl<'a> Lower<'a> { (Val::M(p), Val::M(q)) if p.ty == q.ty => (Val::M(p), Val::M(q)), (Val::P(x, t), Val::P(y, u)) if t == u => (Val::P(x, t), Val::P(y, u)), (a, b) => { - let (x, y) = self.peer(a, b, "if")?; + let (x, y) = self.peer(a, b, if what == "ExprIf" { "if" } else { "switch" })?; return Ok(Val::E(select(cond, x, y))); } }, @@ -4158,7 +4191,7 @@ impl<'a> Lower<'a> { (Val::E(x), Val::E(y)) if x.ty == y.ty => Ok(Val::E(select(cond, x, y))), (a, b) => { let (a, b) = (self.val_desc(&a), self.val_desc(&b)); - self.reject("ExprIf", format!("arms of different shapes: {} and {}", a, b)) + self.reject(what, format!("arms of different shapes: {} and {}", a, b)) } } } @@ -4170,6 +4203,199 @@ impl<'a> Lower<'a> { } } + /// `switch (x) { .a => v, 1 => w, else => u }` used as a value. The t27c + /// parser keeps one item per prong (`.name` or a bare name, an integer, + /// `-N`, a char literal, `else`) and its Zig backend prints each as + /// `.name`, the integer, or `else`. Zig's rules, refused where they would + /// not compile: an enum operand takes tag names, every tag or an `else` + /// but not both; an integer operand takes integers of its type and needs + /// an `else` unless every value is listed; no item twice. A + /// compile-time operand lowers only its prong (Zig does not analyze the + /// others). Otherwise every prong takes the result type `want` -- or, + /// with none, the peer type -- and only the taken one runs: a chain of + /// `ExprKind::Select` on `x == item`, the `else` prong (or, with none, + /// the last one) last. An operand with effects is evaluated once, into a + /// frame slot. + fn switch_expr(&mut self, n: &Node, want: Option<<y>) -> R { + let Some((operand, prongs)) = n.children.split_first() else { + return self.reject("ExprSwitch", "no operand".into()); + }; + if prongs.is_empty() { + return self.reject("ExprSwitch(no prongs)", "`switch` with no prongs".into()); + } + for p in prongs { + if p.kind != NodeKind::ConstDecl || p.children.len() != 1 { + return self.reject("ExprSwitch", format!("prong of unexpected shape ({})", kind_name(p))); + } + } + let x = self.expr(operand)?; + if x.is_poison() { + // Recovery mode: still name what the prongs use. + for p in prongs { + let _ = self.if_arm(&p.children[0], want)?; + } + return Err(()); + } + // The operand as a register expression of type `ty`, and the item + // values its prongs name (None: `else`). + let (e, ty, enum_id) = match x { + Val::P(e, LTy::Enum(id, _)) => { + let t = e.ty; + (e, t, Some(id)) + } + Val::E(e) if e.ty.is_int() => { + let t = e.ty; + (e, t, None) + } + Val::Ct(_) => { + return self.reject( + "ExprSwitch(on comptime_int)", + "switch on an untyped integer literal".into(), + ) + } + v => { + let d = match &v { + Val::E(e) => e.ty.name().to_string(), + v => self.val_desc(v), + }; + return self.reject(&format!("ExprSwitch(on {})", d), format!("switch on {}", d)); + } + }; + let mut items: Vec> = Vec::new(); + for p in prongs { + let name = p.name.trim(); + if name.is_empty() || name == "else" { + if items.contains(&None) { + return self.reject("ExprSwitch(two else prongs)", "more than one `else` prong".into()); + } + items.push(None); + continue; + } + if name.starts_with('\'') { + return self.reject( + "ExprSwitch(char prong)", + format!("t27c's Zig backend prints the prong `{}` as `.{}`, which Zig does not parse (#6329)", name, name), + ); + } + let numeric = name.starts_with(|c: char| c.is_ascii_digit()) || (name.starts_with('-') && name.len() > 1); + let v = match (enum_id, numeric) { + (Some(id), false) => { + let def = &self.enums[id as usize]; + match def.value(name) { + Some(v) => v, + None => { + let en = def.name.clone(); + return self.reject("ExprSwitch(prong)", format!("`{}` has no variant `{}`", en, name)); + } + } + } + (Some(id), true) => { + let en = self.enums[id as usize].name.clone(); + return self.reject("ExprSwitch(prong)", format!("integer prong `{}` on enum `{}`", name, en)); + } + (None, false) => { + return self.reject( + "ExprSwitch(prong)", + format!("t27c prints the prong `{}` as `.{}`, an enum literal, on a {} operand", name, name, ty.name()), + ) + } + (None, true) => { + let v = match name.strip_prefix('-') { + Some(r) => parse_int(r).map(|v| -v), + None => parse_int(name), + }; + match v { + Some(v) if ty.fits(v) => v, + _ => { + return self.reject( + "ExprSwitch(prong)", + format!("prong `{}` is not a {} value", name, ty.name()), + ) + } + } + } + }; + if items.contains(&Some(v)) { + return self.reject("ExprSwitch(duplicate prong)", format!("the prong `{}` appears twice", name)); + } + items.push(Some(v)); + } + let listed = items.iter().filter(|i| i.is_some()).count() as i128; + let all = match enum_id { + Some(id) => self.enums[id as usize].variants.len() as i128, + None => ty.max() - ty.min() + 1, + }; + let has_else = items.contains(&None); + if listed < all && !has_else { + return self.reject( + "ExprSwitch(not exhaustive)", + format!("{} of {} values listed and no `else` prong", listed, all), + ); + } + if listed == all && has_else { + return self.reject( + "ExprSwitch(unreachable else)", + "an `else` prong after every value is listed, which Zig refuses".into(), + ); + } + // The prong that runs when no listed item matched: `else`, or the + // last prong when every value is listed. + let fallback = items.iter().position(|i| i.is_none()).unwrap_or(items.len() - 1); + if let ExprKind::Const(k) = e.kind { + let i = items.iter().position(|i| *i == Some(k)).unwrap_or(fallback); + return self.if_arm(&prongs[i].children[0], want); + } + let mut vals = Vec::with_capacity(prongs.len()); + for p in prongs { + vals.push(self.if_arm(&p.children[0], want)?); + } + if vals.iter().any(|v| v.is_poison()) { + return Err(()); + } + let (stmts, key) = if matches!(e.kind, ExprKind::Var(_)) { + (Vec::new(), e) + } else { + let k = self.new_slot(<y::S(ty))?; + let store = Stmt::Store { addr: slot_expr(k), off: 0, value: e }; + (vec![store], Expr { ty, kind: ExprKind::Load { addr: Box::new(slot_expr(k)), off: 0 } }) + }; + let mut vals: Vec> = vals.into_iter().map(Some).collect(); + let mut acc = vals[fallback].take().unwrap(); + for i in (0..prongs.len()).rev() { + if i == fallback { + continue; + } + let Some(item) = items[i] else { unreachable!() }; + let cond = Expr { + ty: Ty::Bool, + kind: ExprKind::Cmp { + op: CmpOp::Eq, + lhs: Box::new(key.clone()), + rhs: Box::new(Expr { ty, kind: ExprKind::Const(item) }), + }, + }; + let v = vals[i].take().unwrap(); + acc = self.select_vals(cond, v, acc, want, "ExprSwitch", "every prong is an untyped literal")?; + } + if stmts.is_empty() { + return Ok(acc); + } + let seq = |stmts: Vec, value: Expr| Expr { ty: value.ty, kind: ExprKind::Seq { stmts, value: Box::new(value) } }; + match acc { + Val::E(x) => Ok(Val::E(seq(stmts, x))), + Val::P(x, t) => Ok(Val::P(seq(stmts, x), t)), + Val::M(mut p) => { + p.addr = seq(stmts, p.addr); + p.temp = None; + Ok(Val::M(p)) + } + v => { + let d = self.val_desc(&v); + self.reject("ExprSwitch(one prong)", format!("a single prong giving {} after an operand with effects", d)) + } + } + } + /// `@floatFromInt(x)` / `@intFromFloat(x)` with result type `ty`, Zig's /// spelling: the type comes from the context (`@as`, a typed binding, a /// parameter, a return), never from the call. @@ -5746,10 +5972,6 @@ fn names_in(ns: &[Node], out: &mut HashSet) { } } -/// The fns Zig's lazy analysis may reach in `zig test`: the roots are every -/// top-level item that is not a fn (tests; invariants, which t27c emits as -/// `comptime` blocks; benches; constants and vars), and a fn is reached when -/// a reached body names it. `pub` does not make a fn a root, nor does `main`. /// Collect the `if` expressions the reference prints unparenthesized where the /// source parenthesized them (see `Lower::misprinted_if`): the first child of /// a binary operator, a field access or an index. An `if` on the right of a @@ -5767,13 +5989,19 @@ fn misprinted_ifs(n: &Node, out: &mut HashSet) { } } +/// The fns Zig's lazy analysis may reach in `zig test`: the roots are every +/// top-level item that is not a fn (tests; invariants, which t27c emits as +/// `comptime` blocks; constants and vars), and a fn is reached when a reached +/// body names it. `pub` does not make a fn a root, nor does `main`. A bench +/// is not a root: t27c emits it as `fn bench_() void`, which nothing +/// calls, so neither its body nor a fn only it names is analyzed. fn analyzed_fns(items: &[&Node]) -> HashSet { let mut bodies: HashMap<&str, Vec<&Node>> = HashMap::new(); let mut work: HashSet = HashSet::new(); for item in items { if item.kind == NodeKind::FnDecl { bodies.entry(item.name.as_str()).or_default().push(item); - } else { + } else if item.kind != NodeKind::BenchBlock { names_in(std::slice::from_ref(*item), &mut work); } } diff --git a/cli/t27b/tests/source.rs b/cli/t27b/tests/source.rs index dc1c7239f7..a2ca6c70a5 100644 --- a/cli/t27b/tests/source.rs +++ b/cli/t27b/tests/source.rs @@ -1126,7 +1126,7 @@ fn enum_rejections_are_precise() { ("const E = enum(f32) { a, b };\ntest t { assert(E.a == E.a); }", "EnumDecl(tag type)", "`E` has tag type `f32`"), ("const E = enum(u8) { a = 300, b };\ntest t { assert(E.a == E.a); }", "EnumDecl", "`E.a` = 300 does not fit the tag type u8"), ("const E = enum(u8) { a = 1, b = 1 };\ntest t { assert(E.a == E.b); }", "EnumDecl", "`E.a` and `E.b` have the same tag 1"), - ("fn f(d: Dir) u8 {\n return switch (d) {\n .north => 1,\n else => 2,\n };\n}\ntest t { assert(f(.north) == 1); }", "ExprSwitch", ""), + ("fn f(d: Dir) u8 {\n return switch (d) {\n .north => 1,\n .east => 1,\n .south => 1,\n .west => 1,\n else => 2,\n };\n}\ntest t { assert(f(.north) == 1); }", "ExprSwitch(unreachable else)", "after every value is listed"), ("fn f(a: Dir, b: Dir) bool { return a < b; }\ntest t { assert(f(.north, .east)); }", "ExprBinary(<) on enum", "two `Dir` values, which Zig does not order"), ("fn f(d: Dir) bool { return d < .west; }\ntest t { assert(f(.north)); }", "ExprBinary(<) on enum", "which Zig does not order"), ("test t { assert(Dir.north + 1 == 1); }", "ExprBinary(+) on enum", "arithmetic on an enum"), @@ -1732,6 +1732,181 @@ test peers_and_temps { } } +#[test] +fn switch_runs_only_the_taken_prong() { + // Verdicts match `t27c test-report` on the same source: five pass and + // `taken_prong_traps` fails on the `x - 10` overflow. + let src = "module sw; + +pub const NEG: i32 = -3; + +pub enum Trit { + neg, + zero, + pos, +} + +fn negate(a: Trit) Trit { + return switch (a) { + .neg => .pos, + .zero => .zero, + .pos => .neg, + }; +} + +fn add(a: Trit, b: Trit) Trit { + return switch (a) { + .neg => switch (b) { + .neg => .neg, + .zero => .neg, + .pos => .zero, + }, + .zero => b, + .pos => switch (b) { + .neg => .zero, + .zero => .pos, + .pos => .pos, + }, + }; +} + +fn name(a: Trit) []const u8 { + return switch (a) { + .neg => \"neg\", + .zero => \"zero\", + .pos => \"pos\", + }; +} + +fn code(x: i32) u32 { + return switch (x) { + -1 => 10, + 0 => 20, + 7 => 30, + else => 40, + }; +} + +var calls: u32 = 0; + +fn bump(x: u8) u8 { + calls += 1; + return x; +} + +fn once(x: u8) u32 { + return switch (bump(x)) { + 1 => 100, + 2 => 200, + else => 300, + }; +} + +fn sub(x: u32) u32 { + return switch (x) { + 0 => 0, + else => x - 10, + }; +} + +fn peer(a: Trit, x: u8, w: u16) u16 { + const v = switch (a) { + .neg => x, + .zero => w, + else => 5, + }; + return v; +} + +test enum_switch { + assert(negate(.neg) == .pos); + assert(negate(.zero) == .zero); + assert(add(.pos, .pos) == .pos); + assert(add(.neg, .pos) == .zero); + assert(add(.zero, .neg) == .neg); + assert(name(.zero).len == 4); +} + +test int_switch { + assert(code(-1) == 10); + assert(code(0) == 20); + assert(code(7) == 30); + assert(code(NEG) == 40); + assert(NEG == -3); +} + +test operand_runs_once { + assert(once(2) == 200); + assert(once(9) == 300); + assert(calls == 2); +} + +test peer_type { + assert(peer(.neg, 7, 1000) == 7); + assert(peer(.zero, 7, 1000) == 1000); + assert(peer(.pos, 7, 1000) == 5); +} + +test untaken_prong_does_not_trap { + assert(sub(0) == 0); + assert(sub(12) == 2); +} + +test taken_prong_traps { + assert(sub(3) == 0); +} +"; + let r = run(src); + assert_eq!( + names_ok(&r), + vec![ + ("enum_switch", false, true), + ("int_switch", false, true), + ("operand_runs_once", false, true), + ("peer_type", false, true), + ("untaken_prong_does_not_trap", false, true), + ("taken_prong_traps", false, false), + ] + ); + // t27c gives expression nodes no line, so the trap lands on the + // statement that holds the switch, as it does for `if`. + assert_eq!(r[5].2, Err((TrapKind::Overflow, line_of(src, "fn sub") + 1))); + // Every shape below is BLOCKED in the reference too (Zig refuses it, or + // t27c misprints the prong), so each is refused under its own name. + let cases = [ + ("a: Trit", "switch (a) { .neg => 1, .zero => 2, }", "ExprSwitch(not exhaustive)"), + ("x: u8", "switch (x) { 0 => 1, 1 => 2, }", "ExprSwitch(not exhaustive)"), + ("a: Trit", "switch (a) { .neg => 1, .zero => 2, .pos => 3, else => 4, }", "ExprSwitch(unreachable else)"), + ("x: u8", "switch (x) { 'a' => 1, else => 2, }", "ExprSwitch(char prong)"), + ("x: u8", "switch (x) { 1 => 1, 1 => 2, else => 3, }", "ExprSwitch(duplicate prong)"), + ("a: Trit", "switch (a) { .neg => 1, .zero => 2, .up => 3, }", "ExprSwitch(prong)"), + ("x: u8", "switch (x) { 300 => 1, else => 2, }", "ExprSwitch(prong)"), + ("a: Trit", "switch (a) { 0 => 1, else => 2, }", "ExprSwitch(prong)"), + ("x: u8", "switch (x) { zero => 1, else => 2, }", "ExprSwitch(prong)"), + ]; + for (param, body, construct) in cases { + let m = rejected(&format!( + "module rj;\n\npub enum Trit {{\n neg,\n zero,\n pos,\n}}\n\nfn f({}) u32 {{\n return {};\n}}\n\ntest t {{ assert(f(0) == 1); }}\n", + param, body + )); + assert!(m.starts_with(&format!("t27b: unsupported construct {} at line", construct)), "{}: {}", body, m); + } +} + +#[test] +fn discarded_call_result_is_refused_where_reached() { + // t27c prints `g();` as is; Zig refuses the dropped u32 in a body it + // analyzes and ignores it in one nothing reaches: `never`, a bench (an + // uncalled `fn bench_b()` in the reference's Zig) and a fn only the bench + // names. Verdicts match `t27c test-report`: the first source passes, the + // second is BLOCKED. + let src = "module vi;\n\nvar n: u32 = 0;\n\nfn g() u32 {\n n += 1;\n return n;\n}\n\nfn h() void {\n n += 1;\n}\n\nfn never() void {\n g();\n}\n\nfn bench_only() void {\n g();\n}\n\ntest void_call {\n h();\n assert(n == 1);\n}\n\nbench b {\n g();\n bench_only();\n}\n"; + assert_eq!(names_ok(&run(src)), vec![("void_call", false, true)]); + let m = rejected("module vj;\n\nfn g() u32 {\n return 1;\n}\n\ntest t {\n g();\n assert(true);\n}\n"); + assert!(m.starts_with("t27b: unsupported construct ExprCall(value ignored) statement at line 8"), "{}", m); + assert!(m.contains("`g`, which returns u32"), "{}", m); +} + #[test] fn char_literals_are_comptime_ints() { let src = "module cl; diff --git a/docs/now/2026-10-05-t27b-switch.md b/docs/now/2026-10-05-t27b-switch.md new file mode 100644 index 0000000000..1de83dcde1 --- /dev/null +++ b/docs/now/2026-10-05-t27b-switch.md @@ -0,0 +1,11 @@ +# NOW -- t27b: switch expressions and negative const literals lower like the reference (2026-10-05) + +## switch, negative literals, dropped call results (Closes #6352) + +- `cli/t27b` now lowers a `switch` expression on an enum or an integer operand as a lazy chain of selects, so only the taken prong runs (#6328). The operand is evaluated once, and a const operand lowers only its prong. +- A `const X: i32 = -1;` literal, which the t27c parser folds into one token and its Zig backend prints back as written, now lowers as a comptime int instead of being refused. +- Every switch shape the reference cannot compile is refused by name, each confirmed BLOCKED under `t27c test-report`: `ExprSwitch(not exhaustive)`, `(unreachable else)`, `(duplicate prong)`, `(prong)` and `(char prong)` (#6329). +- New refusal `ExprCall(value ignored) statement`: `f(x);` with a non-void `f`, where `zig test` analyzes the body. It is the reason `specs/fpga/bpsk.t27` is BLOCKED in the reference; without it t27b passed that file once its negative literals lowered. +- Benches are no longer analysis roots. t27c emits a bench as an uncalled `fn bench_() void`, so neither its body nor a fn only it names is analyzed by Zig. +- `t27b corpus specs --blockers --reference t27c` at master f25dbeee8: counted pass 623 -> 629 of 720, checked pass 346 -> 352, rejected 569 -> 564, mismatch 0 -> 0, no file regressed. +- New tests in `cli/t27b/tests/source.rs`: `switch_runs_only_the_taken_prong` and `discarded_call_result_is_refused_where_reached`. Their verdicts equal `t27c test-report` on the same sources.