From b8f3d6fd7896b822389c584345e649ec171e85ba Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 17 Jun 2026 19:29:50 +0300 Subject: [PATCH] comptime VM: flat-memory machine + executor + Reg<->Value bridge + tryEval Phase 1 of the flat-memory comptime VM (current/PLAN-COMPILER-VM.md), built standalone + unit-tested with the legacy interpreter still live and the corpus untouched (688 green). src/ir/comptime_vm.zig: - Machine: one linear byte memory (comptime stack+heap) with a bump/stack allocator (mark/reset), scalar readWord/writeWord (1/2/4/8 LE) + byte views; addr 0 reserved as null_addr. Frame: a Ref-indexed register file (Reg = raw u64: immediate scalar bits OR an Addr). Target-aware layout comes from the type table, so cross-compilation stays correct. - Vm executor over the SAME SSA IR, mirroring the legacy interp's scalar semantics (i64 wrapping/signed, f64). Ported: constants, arithmetic, comparison, logical, conversions, control flow (br/cond_br/ret + block params); structs (alloca/load/store/struct_init/get/gep at target offsets); tuples; arrays (index_get/gep, length); slices+strings as {ptr,len} fat pointers (const_string, data_ptr, subslice, array_to_slice, str_eq/ne, index-through-slice); optionals (pointer and {T,i1} shapes); payloadless enums; deref/addr_of; direct + recursive call over the shared flat memory (depth-guarded). The value model: a word for scalars/pointers, by-address for aggregates (a struct's value IS its Addr). Any unported op bails loudly (error.Unsupported + detail). - Reg<->Value boundary bridge (valueToReg / regToValue) + tryEval, the hybrid-wiring entry point: run a comptime fn on the VM, return a legacy Value or null to fall back. Transitional, for the legacy interop edge. Registered in the ir.zig barrel. --- src/ir/comptime_vm.test.zig | 817 ++++++++++++++++++++++++++++++++ src/ir/comptime_vm.zig | 915 ++++++++++++++++++++++++++++++++++++ src/ir/ir.zig | 2 + 3 files changed, 1734 insertions(+) create mode 100644 src/ir/comptime_vm.test.zig create mode 100644 src/ir/comptime_vm.zig diff --git a/src/ir/comptime_vm.test.zig b/src/ir/comptime_vm.test.zig new file mode 100644 index 00000000..1932ca9f --- /dev/null +++ b/src/ir/comptime_vm.test.zig @@ -0,0 +1,817 @@ +// Tests for the flat-memory comptime machine (Phase 1 of PLAN-COMPILER-VM.md). + +const std = @import("std"); +const vm = @import("comptime_vm.zig"); +const inst_mod = @import("inst.zig"); +const types = @import("types.zig"); +const Inst = inst_mod.Inst; +const Op = inst_mod.Op; +const Ref = inst_mod.Ref; +const BlockId = inst_mod.BlockId; +const FuncId = inst_mod.FuncId; +const Function = inst_mod.Function; +const Block = inst_mod.Block; +const Module = @import("module.zig").Module; +const Value = @import("interp.zig").Value; +const TypeId = types.TypeId; + +const dummy: types.StringId = @enumFromInt(0); + +fn ref(i: u32) Ref { + return Ref.fromIndex(i); +} +fn param(ty: TypeId) Function.Param { + return .{ .name = dummy, .ty = ty }; +} +fn inst(op: Op, ty: TypeId) Inst { + return .{ .op = op, .ty = ty }; +} +fn fromI64(v: i64) vm.Reg { + return @bitCast(v); +} +fn toI64(w: vm.Reg) i64 { + return @bitCast(w); +} +fn fromF64(v: f64) vm.Reg { + return @bitCast(v); +} +fn toF64(w: vm.Reg) f64 { + return @bitCast(w); +} + +/// Minimal hand-builder for tiny IR functions. Blocks MUST be fully populated in +/// order (a block's `first_ref` is fixed at creation from the running ref count), +/// and branch targets reference block indices (0,1,2,…) which are sequential. +const Fb = struct { + alloc: std.mem.Allocator, + func: Function, + next_ref: u32, + + fn init(alloc: std.mem.Allocator, params: []const Function.Param, ret: TypeId) Fb { + return .{ .alloc = alloc, .func = Function.init(dummy, params, ret), .next_ref = @intCast(params.len) }; + } + fn deinit(self: *Fb) void { + self.func.deinit(self.alloc); + } + /// Create a block (with `bparams` block-parameter types); returns its index. + fn block(self: *Fb, bparams: []const TypeId) u32 { + var blk = Block.init(dummy, bparams); + blk.first_ref = self.next_ref; + self.func.blocks.append(self.alloc, blk) catch @panic("OOM"); + return @intCast(self.func.blocks.items.len - 1); + } + /// Append an instruction to block `b`; returns the Ref index of its result. + fn add(self: *Fb, b: u32, i: Inst) u32 { + self.func.blocks.items[b].insts.append(self.alloc, i) catch @panic("OOM"); + const r = self.next_ref; + self.next_ref += 1; + return r; + } +}; + +test "comptime_vm exec: integer add of two params" { + const params = [_]Function.Param{ param(.i64), param(.i64) }; + var fb = Fb.init(std.testing.allocator, ¶ms, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const sum = fb.add(b0, inst(.{ .add = .{ .lhs = ref(0), .rhs = ref(1) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(sum) } }, .void)); + + var v = vm.Vm.init(std.testing.allocator); + defer v.deinit(); + const out = try v.run(&fb.func, &.{ fromI64(3), fromI64(40) }); + try std.testing.expectEqual(@as(i64, 43), toI64(out)); +} + +test "comptime_vm exec: f64 arithmetic (a*2.0 + 1.0)" { + const params = [_]Function.Param{param(.f64)}; + var fb = Fb.init(std.testing.allocator, ¶ms, .f64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const two = fb.add(b0, inst(.{ .const_float = 2.0 }, .f64)); + const prod = fb.add(b0, inst(.{ .mul = .{ .lhs = ref(0), .rhs = ref(two) } }, .f64)); + const one = fb.add(b0, inst(.{ .const_float = 1.0 }, .f64)); + const res = fb.add(b0, inst(.{ .add = .{ .lhs = ref(prod), .rhs = ref(one) } }, .f64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(res) } }, .void)); + + var v = vm.Vm.init(std.testing.allocator); + defer v.deinit(); + const out = try v.run(&fb.func, &.{fromF64(3.0)}); + try std.testing.expectEqual(@as(f64, 7.0), toF64(out)); +} + +test "comptime_vm exec: comparison + cond_br selects a branch" { + // f(a) = if a < 10 then 100 else 200 + const params = [_]Function.Param{param(.i64)}; + var fb = Fb.init(std.testing.allocator, ¶ms, .i64); + defer fb.deinit(); + + const b0 = fb.block(&.{}); + const ten = fb.add(b0, inst(.{ .const_int = 10 }, .i64)); + const c = fb.add(b0, inst(.{ .cmp_lt = .{ .lhs = ref(0), .rhs = ref(ten) } }, .bool)); + _ = fb.add(b0, inst(.{ .cond_br = .{ .cond = ref(c), .then_target = BlockId.fromIndex(1), .then_args = &.{}, .else_target = BlockId.fromIndex(2), .else_args = &.{} } }, .void)); + + const b1 = fb.block(&.{}); + const x = fb.add(b1, inst(.{ .const_int = 100 }, .i64)); + _ = fb.add(b1, inst(.{ .ret = .{ .operand = ref(x) } }, .void)); + + const b2 = fb.block(&.{}); + const y = fb.add(b2, inst(.{ .const_int = 200 }, .i64)); + _ = fb.add(b2, inst(.{ .ret = .{ .operand = ref(y) } }, .void)); + + var v = vm.Vm.init(std.testing.allocator); + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 100), toI64(try v.run(&fb.func, &.{fromI64(5)}))); + try std.testing.expectEqual(@as(i64, 200), toI64(try v.run(&fb.func, &.{fromI64(15)}))); +} + +test "comptime_vm exec: loop with block params sums i..1" { + // sum=0; i=n; while i>0 { sum+=i; i-=1 } return sum → n*(n+1)/2 + const params = [_]Function.Param{param(.i64)}; + var fb = Fb.init(std.testing.allocator, ¶ms, .i64); + defer fb.deinit(); + const loop_p = [_]TypeId{ .i64, .i64 }; // (sum, i) + const exit_p = [_]TypeId{.i64}; // (sum) + + // b0 entry: br b1(0, n) + const b0 = fb.block(&.{}); + const zero = fb.add(b0, inst(.{ .const_int = 0 }, .i64)); + _ = fb.add(b0, inst(.{ .br = .{ .target = BlockId.fromIndex(1), .args = &.{ ref(zero), ref(0) } } }, .void)); + + // b1 header(sum, i): if i>0 -> b2(sum,i) else b3(sum) + const b1 = fb.block(&loop_p); + const sum_h = fb.add(b1, inst(.{ .block_param = .{ .block = BlockId.fromIndex(1), .param_index = 0 } }, .i64)); + const i_h = fb.add(b1, inst(.{ .block_param = .{ .block = BlockId.fromIndex(1), .param_index = 1 } }, .i64)); + const z2 = fb.add(b1, inst(.{ .const_int = 0 }, .i64)); + const cond = fb.add(b1, inst(.{ .cmp_gt = .{ .lhs = ref(i_h), .rhs = ref(z2) } }, .bool)); + _ = fb.add(b1, inst(.{ .cond_br = .{ .cond = ref(cond), .then_target = BlockId.fromIndex(2), .then_args = &.{ ref(sum_h), ref(i_h) }, .else_target = BlockId.fromIndex(3), .else_args = &.{ref(sum_h)} } }, .void)); + + // b2 body(sum, i): br b1(sum+i, i-1) + const b2 = fb.block(&loop_p); + const sum_b = fb.add(b2, inst(.{ .block_param = .{ .block = BlockId.fromIndex(2), .param_index = 0 } }, .i64)); + const i_b = fb.add(b2, inst(.{ .block_param = .{ .block = BlockId.fromIndex(2), .param_index = 1 } }, .i64)); + const ns = fb.add(b2, inst(.{ .add = .{ .lhs = ref(sum_b), .rhs = ref(i_b) } }, .i64)); + const one = fb.add(b2, inst(.{ .const_int = 1 }, .i64)); + const ni = fb.add(b2, inst(.{ .sub = .{ .lhs = ref(i_b), .rhs = ref(one) } }, .i64)); + _ = fb.add(b2, inst(.{ .br = .{ .target = BlockId.fromIndex(1), .args = &.{ ref(ns), ref(ni) } } }, .void)); + + // b3 exit(sum): ret sum + const b3 = fb.block(&exit_p); + const sum_e = fb.add(b3, inst(.{ .block_param = .{ .block = BlockId.fromIndex(3), .param_index = 0 } }, .i64)); + _ = fb.add(b3, inst(.{ .ret = .{ .operand = ref(sum_e) } }, .void)); + + var v = vm.Vm.init(std.testing.allocator); + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 15), toI64(try v.run(&fb.func, &.{fromI64(5)}))); // 5+4+3+2+1 + try std.testing.expectEqual(@as(i64, 55), toI64(try v.run(&fb.func, &.{fromI64(10)}))); + try std.testing.expectEqual(@as(i64, 0), toI64(try v.run(&fb.func, &.{fromI64(0)}))); +} + +test "comptime_vm exec: struct_init + struct_get round-trips a flat struct" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + // Point :: struct { x: i64, y: i64 } + const pfields = [_]types.TypeInfo.StructInfo.Field{ + .{ .name = table.internString("x"), .ty = .i64 }, + .{ .name = table.internString("y"), .ty = .i64 }, + }; + const point = table.intern(.{ .@"struct" = .{ .name = table.internString("Point"), .fields = &pfields } }); + + // f() -> i64 { p := Point.{ x = 7, y = 9 }; return p.x + p.y } + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const x = fb.add(b0, inst(.{ .const_int = 7 }, .i64)); + const y = fb.add(b0, inst(.{ .const_int = 9 }, .i64)); + const finit = [_]Ref{ ref(x), ref(y) }; + const p = fb.add(b0, inst(.{ .struct_init = .{ .fields = &finit } }, point)); + const px = fb.add(b0, inst(.{ .struct_get = .{ .base = ref(p), .field_index = 0, .base_type = point } }, .i64)); + const py = fb.add(b0, inst(.{ .struct_get = .{ .base = ref(p), .field_index = 1, .base_type = point } }, .i64)); + const s = fb.add(b0, inst(.{ .add = .{ .lhs = ref(px), .rhs = ref(py) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(s) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 16), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: alloca + struct_gep + store + load" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const pfields = [_]types.TypeInfo.StructInfo.Field{ + .{ .name = table.internString("x"), .ty = .i64 }, + .{ .name = table.internString("y"), .ty = .i64 }, + }; + const point = table.intern(.{ .@"struct" = .{ .name = table.internString("Point"), .fields = &pfields } }); + const pptr = table.intern(.{ .pointer = .{ .pointee = point } }); + + // p := alloca Point; p.x = 5; p.y = 11; return load p.x + load p.y + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const p = fb.add(b0, inst(.{ .alloca = point }, pptr)); + const gx = fb.add(b0, inst(.{ .struct_gep = .{ .base = ref(p), .field_index = 0, .base_type = point } }, pptr)); + const c5 = fb.add(b0, inst(.{ .const_int = 5 }, .i64)); + _ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(gx), .val = ref(c5), .val_ty = .i64 } }, .void)); + const gy = fb.add(b0, inst(.{ .struct_gep = .{ .base = ref(p), .field_index = 1, .base_type = point } }, pptr)); + const c11 = fb.add(b0, inst(.{ .const_int = 11 }, .i64)); + _ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(gy), .val = ref(c11), .val_ty = .i64 } }, .void)); + const lx = fb.add(b0, inst(.{ .load = .{ .operand = ref(gx) } }, .i64)); + const ly = fb.add(b0, inst(.{ .load = .{ .operand = ref(gy) } }, .i64)); + const s = fb.add(b0, inst(.{ .add = .{ .lhs = ref(lx), .rhs = ref(ly) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(s) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 16), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: nested struct (aggregate field copy + nested read)" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const pfields = [_]types.TypeInfo.StructInfo.Field{ + .{ .name = table.internString("x"), .ty = .i64 }, + .{ .name = table.internString("y"), .ty = .i64 }, + }; + const point = table.intern(.{ .@"struct" = .{ .name = table.internString("Point"), .fields = &pfields } }); + const lfields = [_]types.TypeInfo.StructInfo.Field{ + .{ .name = table.internString("a"), .ty = point }, + .{ .name = table.internString("b"), .ty = point }, + }; + const line = table.intern(.{ .@"struct" = .{ .name = table.internString("Line"), .fields = &lfields } }); + + // L := Line.{ a = Point.{1,2}, b = Point.{3,4} }; return L.a.x + L.b.y → 1 + 4 = 5 + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const c1 = fb.add(b0, inst(.{ .const_int = 1 }, .i64)); + const c2 = fb.add(b0, inst(.{ .const_int = 2 }, .i64)); + const pr = [_]Ref{ ref(c1), ref(c2) }; + const p = fb.add(b0, inst(.{ .struct_init = .{ .fields = &pr } }, point)); + const c3 = fb.add(b0, inst(.{ .const_int = 3 }, .i64)); + const c4 = fb.add(b0, inst(.{ .const_int = 4 }, .i64)); + const qr = [_]Ref{ ref(c3), ref(c4) }; + const q = fb.add(b0, inst(.{ .struct_init = .{ .fields = &qr } }, point)); + const lr = [_]Ref{ ref(p), ref(q) }; + const l = fb.add(b0, inst(.{ .struct_init = .{ .fields = &lr } }, line)); + const la = fb.add(b0, inst(.{ .struct_get = .{ .base = ref(l), .field_index = 0, .base_type = line } }, point)); + const lax = fb.add(b0, inst(.{ .struct_get = .{ .base = ref(la), .field_index = 0, .base_type = point } }, .i64)); + const lb = fb.add(b0, inst(.{ .struct_get = .{ .base = ref(l), .field_index = 1, .base_type = line } }, point)); + const lby = fb.add(b0, inst(.{ .struct_get = .{ .base = ref(lb), .field_index = 1, .base_type = point } }, .i64)); + const s = fb.add(b0, inst(.{ .add = .{ .lhs = ref(lax), .rhs = ref(lby) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(s) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 5), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: tuple_init + tuple_get (mixed i64/f64)" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const tfields = [_]TypeId{ .i64, .f64 }; + const tup = table.intern(.{ .tuple = .{ .fields = &tfields, .names = null } }); + + // t := (5, 2.5); return t.0 + int(t.1) → 5 + 2 = 7 + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const a = fb.add(b0, inst(.{ .const_int = 5 }, .i64)); + const b = fb.add(b0, inst(.{ .const_float = 2.5 }, .f64)); + const tinit = [_]Ref{ ref(a), ref(b) }; + const t = fb.add(b0, inst(.{ .tuple_init = .{ .fields = &tinit } }, tup)); + const t0 = fb.add(b0, inst(.{ .tuple_get = .{ .base = ref(t), .field_index = 0, .base_type = tup } }, .i64)); + const t1 = fb.add(b0, inst(.{ .tuple_get = .{ .base = ref(t), .field_index = 1, .base_type = tup } }, .f64)); + const t1i = fb.add(b0, inst(.{ .float_to_int = .{ .operand = ref(t1), .from = .f64, .to = .i64 } }, .i64)); + const s = fb.add(b0, inst(.{ .add = .{ .lhs = ref(t0), .rhs = ref(t1i) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(s) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 7), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: array index_gep/store + index_get sum, and length" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const arr = table.intern(.{ .array = .{ .element = .i64, .length = 3 } }); + const aptr = table.intern(.{ .pointer = .{ .pointee = arr } }); + const i64ptr = table.intern(.{ .pointer = .{ .pointee = .i64 } }); + + // a := alloca [3]i64; a[0]=10; a[1]=20; a[2]=12; return a[0]+a[1]+a[2] → 42 + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const a = fb.add(b0, inst(.{ .alloca = arr }, aptr)); + const vals = [_]i64{ 10, 20, 12 }; + var gep: [3]u32 = undefined; + inline for (0..3) |k| { + const ik = fb.add(b0, inst(.{ .const_int = @intCast(k) }, .i64)); + gep[k] = fb.add(b0, inst(.{ .index_gep = .{ .lhs = ref(a), .rhs = ref(ik) } }, i64ptr)); + const cv = fb.add(b0, inst(.{ .const_int = vals[k] }, .i64)); + _ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(gep[k]), .val = ref(cv), .val_ty = .i64 } }, .void)); + } + const idx0 = fb.add(b0, inst(.{ .const_int = 0 }, .i64)); + const e0 = fb.add(b0, inst(.{ .index_get = .{ .lhs = ref(a), .rhs = ref(idx0) } }, .i64)); + const idx1 = fb.add(b0, inst(.{ .const_int = 1 }, .i64)); + const e1 = fb.add(b0, inst(.{ .index_get = .{ .lhs = ref(a), .rhs = ref(idx1) } }, .i64)); + const idx2 = fb.add(b0, inst(.{ .const_int = 2 }, .i64)); + const e2 = fb.add(b0, inst(.{ .index_get = .{ .lhs = ref(a), .rhs = ref(idx2) } }, .i64)); + const s01 = fb.add(b0, inst(.{ .add = .{ .lhs = ref(e0), .rhs = ref(e1) } }, .i64)); + const s = fb.add(b0, inst(.{ .add = .{ .lhs = ref(s01), .rhs = ref(e2) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(s) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 42), toI64(try v.run(&fb.func, &.{}))); + + // length(array value) → static length 3 + var fb2 = Fb.init(alloc, &.{}, .i64); + defer fb2.deinit(); + const c0 = fb2.block(&.{}); + const a2 = fb2.add(c0, inst(.{ .alloca = arr }, aptr)); + const av = fb2.add(c0, inst(.{ .load = .{ .operand = ref(a2) } }, arr)); + const len = fb2.add(c0, inst(.{ .length = .{ .operand = ref(av) } }, .i64)); + _ = fb2.add(c0, inst(.{ .ret = .{ .operand = ref(len) } }, .void)); + try std.testing.expectEqual(@as(i64, 3), toI64(try v.run(&fb2.func, &.{}))); +} + +test "comptime_vm exec: const_string length + str_eq/str_ne" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const foo = table.internString("foo"); + const foo2 = table.internString("foo"); // interns to the same id, but distinct const_string sites + const bar = table.internString("bar"); + + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const a = fb.add(b0, inst(.{ .const_string = foo }, .string)); + const b = fb.add(b0, inst(.{ .const_string = foo2 }, .string)); + const c = fb.add(b0, inst(.{ .const_string = bar }, .string)); + const la = fb.add(b0, inst(.{ .length = .{ .operand = ref(a) } }, .i64)); // 3 + const eq = fb.add(b0, inst(.{ .str_eq = .{ .lhs = ref(a), .rhs = ref(b) } }, .bool)); // true + const ne = fb.add(b0, inst(.{ .str_ne = .{ .lhs = ref(a), .rhs = ref(c) } }, .bool)); // true + const both = fb.add(b0, inst(.{ .bool_and = .{ .lhs = ref(eq), .rhs = ref(ne) } }, .bool)); + // return length(a) when both predicates hold, else 0 → 3 + const z = fb.add(b0, inst(.{ .const_int = 0 }, .i64)); + const sel = fb.add(b0, inst(.{ .mul = .{ .lhs = ref(la), .rhs = ref(both) } }, .i64)); // 3 * 1 + _ = z; + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(sel) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 3), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: array_to_slice + index through slice + slice length" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const arr = table.intern(.{ .array = .{ .element = .i64, .length = 3 } }); + const aptr = table.intern(.{ .pointer = .{ .pointee = arr } }); + const i64ptr = table.intern(.{ .pointer = .{ .pointee = .i64 } }); + const sl = table.intern(.{ .slice = .{ .element = .i64 } }); + + // a := alloca [3]i64 = {10,20,12}; s := a[..]; return len(s) + s[1] → 3 + 20 = 23 + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const a = fb.add(b0, inst(.{ .alloca = arr }, aptr)); + const vals = [_]i64{ 10, 20, 12 }; + inline for (0..3) |k| { + const ik = fb.add(b0, inst(.{ .const_int = @intCast(k) }, .i64)); + const g = fb.add(b0, inst(.{ .index_gep = .{ .lhs = ref(a), .rhs = ref(ik) } }, i64ptr)); + const cv = fb.add(b0, inst(.{ .const_int = vals[k] }, .i64)); + _ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(g), .val = ref(cv), .val_ty = .i64 } }, .void)); + } + const av = fb.add(b0, inst(.{ .load = .{ .operand = ref(a) } }, arr)); + const s = fb.add(b0, inst(.{ .array_to_slice = .{ .operand = ref(av) } }, sl)); + const slen = fb.add(b0, inst(.{ .length = .{ .operand = ref(s) } }, .i64)); + const one = fb.add(b0, inst(.{ .const_int = 1 }, .i64)); + const e1 = fb.add(b0, inst(.{ .index_get = .{ .lhs = ref(s), .rhs = ref(one) } }, .i64)); + const sum = fb.add(b0, inst(.{ .add = .{ .lhs = ref(slen), .rhs = ref(e1) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(sum) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 23), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: subslice of an array" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const arr = table.intern(.{ .array = .{ .element = .i64, .length = 5 } }); + const aptr = table.intern(.{ .pointer = .{ .pointee = arr } }); + const i64ptr = table.intern(.{ .pointer = .{ .pointee = .i64 } }); + const sl = table.intern(.{ .slice = .{ .element = .i64 } }); + + // a := {0,10,20,30,40}; s := a[1..4] = {10,20,30}; return len(s) + s[0] + s[2] → 3+10+30 = 43 + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const a = fb.add(b0, inst(.{ .alloca = arr }, aptr)); + inline for (0..5) |k| { + const ik = fb.add(b0, inst(.{ .const_int = @intCast(k) }, .i64)); + const g = fb.add(b0, inst(.{ .index_gep = .{ .lhs = ref(a), .rhs = ref(ik) } }, i64ptr)); + const cv = fb.add(b0, inst(.{ .const_int = @as(i64, @intCast(k)) * 10 }, .i64)); + _ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(g), .val = ref(cv), .val_ty = .i64 } }, .void)); + } + const av = fb.add(b0, inst(.{ .load = .{ .operand = ref(a) } }, arr)); + const lo = fb.add(b0, inst(.{ .const_int = 1 }, .i64)); + const hi = fb.add(b0, inst(.{ .const_int = 4 }, .i64)); + const s = fb.add(b0, inst(.{ .subslice = .{ .base = ref(av), .lo = ref(lo), .hi = ref(hi), .base_ty = arr } }, sl)); + const slen = fb.add(b0, inst(.{ .length = .{ .operand = ref(s) } }, .i64)); + const z = fb.add(b0, inst(.{ .const_int = 0 }, .i64)); + const e0 = fb.add(b0, inst(.{ .index_get = .{ .lhs = ref(s), .rhs = ref(z) } }, .i64)); + const two = fb.add(b0, inst(.{ .const_int = 2 }, .i64)); + const e2 = fb.add(b0, inst(.{ .index_get = .{ .lhs = ref(s), .rhs = ref(two) } }, .i64)); + const t = fb.add(b0, inst(.{ .add = .{ .lhs = ref(slen), .rhs = ref(e0) } }, .i64)); + const sum = fb.add(b0, inst(.{ .add = .{ .lhs = ref(t), .rhs = ref(e2) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(sum) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 43), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: non-pointer optional wrap/unwrap/has_value/coalesce" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const opt_i64 = table.intern(.{ .optional = .{ .child = .i64 } }); + + // o := ?i64(42); n := null; return (unwrap o + (n ?? 7) + (o ?? 7)) * has_value(o) + // = (42 + 7 + 42) * 1 = 91 + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const c = fb.add(b0, inst(.{ .const_int = 42 }, .i64)); + const o = fb.add(b0, inst(.{ .optional_wrap = .{ .operand = ref(c) } }, opt_i64)); + const n = fb.add(b0, inst(.const_null, opt_i64)); + const h = fb.add(b0, inst(.{ .optional_has_value = .{ .operand = ref(o) } }, .bool)); + const u = fb.add(b0, inst(.{ .optional_unwrap = .{ .operand = ref(o) } }, .i64)); + const fb7 = fb.add(b0, inst(.{ .const_int = 7 }, .i64)); + const co_n = fb.add(b0, inst(.{ .optional_coalesce = .{ .lhs = ref(n), .rhs = ref(fb7) } }, .i64)); + const co_o = fb.add(b0, inst(.{ .optional_coalesce = .{ .lhs = ref(o), .rhs = ref(fb7) } }, .i64)); + const s1 = fb.add(b0, inst(.{ .add = .{ .lhs = ref(u), .rhs = ref(co_n) } }, .i64)); + const s2 = fb.add(b0, inst(.{ .add = .{ .lhs = ref(s1), .rhs = ref(co_o) } }, .i64)); + const s = fb.add(b0, inst(.{ .mul = .{ .lhs = ref(s2), .rhs = ref(h) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(s) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 91), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: pointer optional (null == 0)" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const i64ptr = table.intern(.{ .pointer = .{ .pointee = .i64 } }); + const opt_ptr = table.intern(.{ .optional = .{ .child = i64ptr } }); + + // p := alloca i64; *p = 99; op := ?*i64(p); n := null; + // return load(unwrap op) * has_value(op) + has_value(n) → 99 * 1 + 0 = 99 + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const p = fb.add(b0, inst(.{ .alloca = .i64 }, i64ptr)); + const c = fb.add(b0, inst(.{ .const_int = 99 }, .i64)); + _ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(p), .val = ref(c), .val_ty = .i64 } }, .void)); + const op = fb.add(b0, inst(.{ .optional_wrap = .{ .operand = ref(p) } }, opt_ptr)); + const h = fb.add(b0, inst(.{ .optional_has_value = .{ .operand = ref(op) } }, .bool)); + const up = fb.add(b0, inst(.{ .optional_unwrap = .{ .operand = ref(op) } }, i64ptr)); + const val = fb.add(b0, inst(.{ .load = .{ .operand = ref(up) } }, .i64)); + const n = fb.add(b0, inst(.const_null, opt_ptr)); + const hn = fb.add(b0, inst(.{ .optional_has_value = .{ .operand = ref(n) } }, .bool)); + const prod = fb.add(b0, inst(.{ .mul = .{ .lhs = ref(val), .rhs = ref(h) } }, .i64)); + const s = fb.add(b0, inst(.{ .add = .{ .lhs = ref(prod), .rhs = ref(hn) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(s) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 99), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: payloadless enum_init + enum_tag" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const variants = [_]types.StringId{ table.internString("red"), table.internString("green"), table.internString("blue") }; + const color = table.intern(.{ .@"enum" = .{ .name = table.internString("Color"), .variants = &variants } }); + + // g := Color.green (tag 1); return enum_tag(g) + 10 → 11 + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const g = fb.add(b0, inst(.{ .enum_init = .{ .tag = 1, .payload = Ref.none } }, color)); + const t = fb.add(b0, inst(.{ .enum_tag = .{ .operand = ref(g) } }, .i64)); + const ten = fb.add(b0, inst(.{ .const_int = 10 }, .i64)); + const s = fb.add(b0, inst(.{ .add = .{ .lhs = ref(t), .rhs = ref(ten) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(s) } }, .void)); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 11), toI64(try v.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: deref a pointer; addr_of passes through a struct address" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const i64ptr = table.intern(.{ .pointer = .{ .pointee = .i64 } }); + const pfields = [_]types.TypeInfo.StructInfo.Field{ + .{ .name = table.internString("x"), .ty = .i64 }, + .{ .name = table.internString("y"), .ty = .i64 }, + }; + const point = table.intern(.{ .@"struct" = .{ .name = table.internString("Point"), .fields = &pfields } }); + + // p := alloca i64; *p = 77; v := p.*; (deref) + // pt := Point.{3,4}; pa := @pt; px := pa.x (addr_of pass-through + field read) + // return v + px → 77 + 3 = 80 + var fb = Fb.init(alloc, &.{}, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const p = fb.add(b0, inst(.{ .alloca = .i64 }, i64ptr)); + const c = fb.add(b0, inst(.{ .const_int = 77 }, .i64)); + _ = fb.add(b0, inst(.{ .store = .{ .ptr = ref(p), .val = ref(c), .val_ty = .i64 } }, .void)); + const v = fb.add(b0, inst(.{ .deref = .{ .operand = ref(p) } }, .i64)); + const x = fb.add(b0, inst(.{ .const_int = 3 }, .i64)); + const y = fb.add(b0, inst(.{ .const_int = 4 }, .i64)); + const finit = [_]Ref{ ref(x), ref(y) }; + const pt = fb.add(b0, inst(.{ .struct_init = .{ .fields = &finit } }, point)); + const pa = fb.add(b0, inst(.{ .addr_of = .{ .operand = ref(pt) } }, point)); + const px = fb.add(b0, inst(.{ .struct_get = .{ .base = ref(pa), .field_index = 0, .base_type = point } }, .i64)); + const s = fb.add(b0, inst(.{ .add = .{ .lhs = ref(v), .rhs = ref(px) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(s) } }, .void)); + + var vm_ = vm.Vm.init(alloc); + vm_.table = &table; + defer vm_.deinit(); + try std.testing.expectEqual(@as(i64, 80), toI64(try vm_.run(&fb.func, &.{}))); +} + +test "comptime_vm exec: direct call to another function" { + const alloc = std.testing.allocator; + var module = Module.init(alloc); + defer module.deinit(); + + // fn add(a, b) = a + b (FuncId 0) + const add_params = [_]Function.Param{ .{ .name = dummy, .ty = .i64 }, .{ .name = dummy, .ty = .i64 } }; + var cb = Fb.init(alloc, &add_params, .i64); + const cbb = cb.block(&.{}); + const csum = cb.add(cbb, inst(.{ .add = .{ .lhs = ref(0), .rhs = ref(1) } }, .i64)); + _ = cb.add(cbb, inst(.{ .ret = .{ .operand = ref(csum) } }, .void)); + const add_id = module.addFunction(cb.func); // module now owns it (no cb.deinit) + + // fn main() = add(20, 22) + 100 (FuncId 1) + var fb = Fb.init(alloc, &.{}, .i64); + const b0 = fb.block(&.{}); + const a20 = fb.add(b0, inst(.{ .const_int = 20 }, .i64)); + const a22 = fb.add(b0, inst(.{ .const_int = 22 }, .i64)); + const cargs = [_]Ref{ ref(a20), ref(a22) }; + const r = fb.add(b0, inst(.{ .call = .{ .callee = add_id, .args = &cargs } }, .i64)); + const c100 = fb.add(b0, inst(.{ .const_int = 100 }, .i64)); + const sum = fb.add(b0, inst(.{ .add = .{ .lhs = ref(r), .rhs = ref(c100) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(sum) } }, .void)); + const main_id = module.addFunction(fb.func); + + var v = vm.Vm.init(alloc); + v.table = &module.types; + v.module = &module; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 142), toI64(try v.run(module.getFunction(main_id), &.{}))); +} + +test "comptime_vm exec: recursive call (sum 0..n)" { + const alloc = std.testing.allocator; + var module = Module.init(alloc); + defer module.deinit(); + + // fn sum(n) = if n == 0 then 0 else n + sum(n-1) (FuncId 0 — references itself) + const self_id = FuncId.fromIndex(0); + const params = [_]Function.Param{.{ .name = dummy, .ty = .i64 }}; + var fb = Fb.init(alloc, ¶ms, .i64); + const b0 = fb.block(&.{}); + const z = fb.add(b0, inst(.{ .const_int = 0 }, .i64)); + const c = fb.add(b0, inst(.{ .cmp_eq = .{ .lhs = ref(0), .rhs = ref(z) } }, .bool)); + _ = fb.add(b0, inst(.{ .cond_br = .{ .cond = ref(c), .then_target = BlockId.fromIndex(1), .then_args = &.{}, .else_target = BlockId.fromIndex(2), .else_args = &.{} } }, .void)); + // b1: base case → 0 + const b1 = fb.block(&.{}); + const zero = fb.add(b1, inst(.{ .const_int = 0 }, .i64)); + _ = fb.add(b1, inst(.{ .ret = .{ .operand = ref(zero) } }, .void)); + // b2: recurse → n + sum(n-1) + const b2 = fb.block(&.{}); + const one = fb.add(b2, inst(.{ .const_int = 1 }, .i64)); + const nm1 = fb.add(b2, inst(.{ .sub = .{ .lhs = ref(0), .rhs = ref(one) } }, .i64)); + const rargs = [_]Ref{ref(nm1)}; + const rec = fb.add(b2, inst(.{ .call = .{ .callee = self_id, .args = &rargs } }, .i64)); + const s = fb.add(b2, inst(.{ .add = .{ .lhs = ref(0), .rhs = ref(rec) } }, .i64)); + _ = fb.add(b2, inst(.{ .ret = .{ .operand = ref(s) } }, .void)); + const sum_id = module.addFunction(fb.func); + try std.testing.expectEqual(@as(u32, 0), sum_id.index()); // confirms the self-reference id + + var v = vm.Vm.init(alloc); + v.table = &module.types; + v.module = &module; + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 15), toI64(try v.run(module.getFunction(sum_id), &.{fromI64(5)}))); + try std.testing.expectEqual(@as(i64, 55), toI64(try v.run(module.getFunction(sum_id), &.{fromI64(10)}))); +} + +test "comptime_vm bridge: Value <-> Reg round-trips (scalar, string, struct)" { + const alloc = std.testing.allocator; + var table = types.TypeTable.init(alloc); + defer table.deinit(); + const pfields = [_]types.TypeInfo.StructInfo.Field{ + .{ .name = table.internString("x"), .ty = .i64 }, + .{ .name = table.internString("y"), .ty = .i64 }, + }; + const point = table.intern(.{ .@"struct" = .{ .name = table.internString("Point"), .fields = &pfields } }); + + var v = vm.Vm.init(alloc); + v.table = &table; + defer v.deinit(); + + // scalar i64 + const r_i = try v.valueToReg(&table, .{ .int = 42 }, .i64); + try std.testing.expectEqual(@as(i64, 42), toI64(r_i)); + const back_i = try v.regToValue(alloc, &table, r_i, .i64); + try std.testing.expectEqual(@as(i64, 42), back_i.int); + + // string (materialized into flat memory, read back + deep-copied out) + const r_s = try v.valueToReg(&table, .{ .string = "hi" }, .string); + const back_s = try v.regToValue(alloc, &table, r_s, .string); + defer alloc.free(back_s.string); + try std.testing.expectEqualStrings("hi", back_s.string); + + // struct {x:i64, y:i64} + const fvals = [_]Value{ .{ .int = 3 }, .{ .int = 4 } }; + const r_p = try v.valueToReg(&table, .{ .aggregate = &fvals }, point); + const back_p = try v.regToValue(alloc, &table, r_p, point); + defer alloc.free(back_p.aggregate); + try std.testing.expectEqual(@as(i64, 3), back_p.aggregate[0].int); + try std.testing.expectEqual(@as(i64, 4), back_p.aggregate[1].int); +} + +test "comptime_vm tryEval: pure function → Value; unsupported → null" { + const alloc = std.testing.allocator; + var module = Module.init(alloc); + defer module.deinit(); + + // fn k() -> i64 { return 6 * 7 } → tryEval yields Value.int(42) + var fb = Fb.init(alloc, &.{}, .i64); + const b0 = fb.block(&.{}); + const a = fb.add(b0, inst(.{ .const_int = 6 }, .i64)); + const b = fb.add(b0, inst(.{ .const_int = 7 }, .i64)); + const m = fb.add(b0, inst(.{ .mul = .{ .lhs = ref(a), .rhs = ref(b) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(m) } }, .void)); + const ok_id = module.addFunction(fb.func); + + const v = vm.tryEval(alloc, &module, ok_id) orelse return error.VmShouldHaveHandledIt; + try std.testing.expectEqual(@as(i64, 42), v.int); + + // fn bad() { unbox_any(1) } → tryEval yields null (caller falls back to legacy) + var fb2 = Fb.init(alloc, &.{}, .void); + const c0 = fb2.block(&.{}); + const c = fb2.add(c0, inst(.{ .const_int = 1 }, .i64)); + _ = fb2.add(c0, inst(.{ .unbox_any = .{ .operand = ref(c) } }, .i64)); + _ = fb2.add(c0, inst(.ret_void, .void)); + const bad_id = module.addFunction(fb2.func); + + try std.testing.expect(vm.tryEval(alloc, &module, bad_id) == null); +} + +test "comptime_vm exec: division by zero and unsupported op bail loudly" { + // a / b + { + const params = [_]Function.Param{ param(.i64), param(.i64) }; + var fb = Fb.init(std.testing.allocator, ¶ms, .i64); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const q = fb.add(b0, inst(.{ .div = .{ .lhs = ref(0), .rhs = ref(1) } }, .i64)); + _ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(q) } }, .void)); + + var v = vm.Vm.init(std.testing.allocator); + defer v.deinit(); + try std.testing.expectEqual(@as(i64, 4), toI64(try v.run(&fb.func, &.{ fromI64(12), fromI64(3) }))); + try std.testing.expectError(error.DivisionByZero, v.run(&fb.func, &.{ fromI64(12), fromI64(0) })); + } + // A not-yet-ported op (unbox_any) → Unsupported with the op name in `detail`. + { + var fb = Fb.init(std.testing.allocator, &.{}, .void); + defer fb.deinit(); + const b0 = fb.block(&.{}); + const c = fb.add(b0, inst(.{ .const_int = 1 }, .i64)); + _ = fb.add(b0, inst(.{ .unbox_any = .{ .operand = ref(c) } }, .i64)); + _ = fb.add(b0, inst(.ret_void, .void)); + + var v = vm.Vm.init(std.testing.allocator); + defer v.deinit(); + try std.testing.expectError(error.Unsupported, v.run(&fb.func, &.{})); + try std.testing.expectEqualStrings("unbox_any", v.detail.?); + } +} + +test "comptime_vm: allocBytes never returns null_addr and respects alignment" { + var m = vm.Machine.init(std.testing.allocator); + defer m.deinit(); + + const a = m.allocBytes(1, 1); + try std.testing.expect(a != vm.null_addr); + + // An 8-aligned allocation lands on an 8-multiple address. + const b = m.allocBytes(4, 8); + try std.testing.expectEqual(@as(u64, 0), b % 8); + + // Distinct allocations don't overlap. + const c = m.allocBytes(4, 8); + try std.testing.expect(c >= b + 4); + + // A zero-size allocation is still a valid, non-null, aligned address. + const z = m.allocBytes(0, 4); + try std.testing.expect(z != vm.null_addr); + try std.testing.expectEqual(@as(u64, 0), z % 4); +} + +test "comptime_vm: writeWord/readWord round-trip at each scalar size" { + var m = vm.Machine.init(std.testing.allocator); + defer m.deinit(); + + const sizes = [_]usize{ 1, 2, 4, 8 }; + const vals = [_]u64{ 0xAB, 0xBEEF, 0xDEADBEEF, 0x0123456789ABCDEF }; + for (sizes, vals) |size, val| { + const addr = m.allocBytes(size, size); + m.writeWord(addr, size, val); + try std.testing.expectEqual(val, m.readWord(addr, size)); + } +} + +test "comptime_vm: writeWord truncates to size and readWord zero-extends" { + var m = vm.Machine.init(std.testing.allocator); + defer m.deinit(); + + // Write a full 64-bit word's worth of bits through a 1-byte store: only the + // low byte lands; the read zero-extends it. + const addr = m.allocBytes(1, 1); + m.writeWord(addr, 1, 0xFFFF_FF42); + try std.testing.expectEqual(@as(u64, 0x42), m.readWord(addr, 1)); +} + +test "comptime_vm: bytes() view reflects word writes (little-endian)" { + var m = vm.Machine.init(std.testing.allocator); + defer m.deinit(); + + const addr = m.allocBytes(4, 4); + m.writeWord(addr, 4, 0xDEADBEEF); + const view = m.bytes(addr, 4); + try std.testing.expectEqual(@as(u8, 0xEF), view[0]); + try std.testing.expectEqual(@as(u8, 0xBE), view[1]); + try std.testing.expectEqual(@as(u8, 0xAD), view[2]); + try std.testing.expectEqual(@as(u8, 0xDE), view[3]); +} + +test "comptime_vm: mark/reset reclaims the stack region" { + var m = vm.Machine.init(std.testing.allocator); + defer m.deinit(); + + _ = m.allocBytes(16, 8); + const top = m.mark(); + const reclaimed = m.allocBytes(64, 8); + try std.testing.expect(m.mark() > top); + m.reset(top); + try std.testing.expectEqual(top, m.mark()); + + // After reset the freed region is handed back out again (same address). + const reused = m.allocBytes(64, 8); + try std.testing.expectEqual(reclaimed, reused); +} + +test "comptime_vm: Frame register file round-trips (no stack reclaim)" { + var frame = vm.Frame.init(std.testing.allocator, 4); + defer frame.deinit(); + + // Registers default to zero, then round-trip. + try std.testing.expectEqual(@as(vm.Reg, 0), frame.get(2)); + frame.set(2, 0x1234); + try std.testing.expectEqual(@as(vm.Reg, 0x1234), frame.get(2)); +} diff --git a/src/ir/comptime_vm.zig b/src/ir/comptime_vm.zig new file mode 100644 index 00000000..7771369f --- /dev/null +++ b/src/ir/comptime_vm.zig @@ -0,0 +1,915 @@ +//! Flat-memory comptime machine — Phase 1 of `current/PLAN-COMPILER-VM.md`. +//! +//! The comptime evaluator is being rebuilt around a flat, byte-addressable memory +//! so comptime values are NATIVE BYTES (like runtime), instead of the tagged +//! `Value` union the legacy interpreter (`interp.zig`) uses. This module is the +//! machine substrate: a linear byte memory with a bump/stack allocator, plus a +//! per-call `Frame` holding a register file. +//! +//! Value model (grows over later sub-steps): a register (`Reg`) is a raw 64-bit +//! word that is EITHER an immediate scalar (its bits) OR an `Addr` into flat +//! memory (for aggregates) — interpreted by the IR result type, exactly like a +//! real machine / LLVM. Scalars up to 64 bits (sx's widest is `i64`/`u64`/`f64`) +//! fit a register directly; structs/arrays/slices live in flat memory and a +//! register holds their address. +//! +//! Target-awareness lives in the EXECUTOR, not here: this module only moves raw +//! bytes. Layout (sizes/offsets/pointer width) is supplied by the type table when +//! the executor lays a value out, so cross-compilation stays correct. +//! +//! Sub-step 1 (this file): `Machine` (memory + bump/stack alloc + scalar word +//! read/write + byte views) and `Frame` (register file + stack reclamation). No +//! op execution yet — the executor + op handlers arrive in the next sub-step. The +//! legacy interpreter remains the live evaluator until the VM reaches parity. + +const std = @import("std"); +const inst_mod = @import("inst.zig"); +const types = @import("types.zig"); +const mod_mod = @import("module.zig"); +const interp_mod = @import("interp.zig"); +const Value = interp_mod.Value; +const Inst = inst_mod.Inst; +const Ref = inst_mod.Ref; +const BlockId = inst_mod.BlockId; +const Function = inst_mod.Function; +const Module = mod_mod.Module; +const OpTag = std.meta.Tag(inst_mod.Op); +const TypeId = types.TypeId; + +/// A byte offset into the machine's flat memory. `null_addr` (0) is reserved as a +/// never-allocated sentinel, so a zeroed register reads as null rather than a +/// valid object — mirroring how the legacy `Value` model distinguishes `null_val`. +pub const Addr = u64; +pub const null_addr: Addr = 0; + +/// A raw register word: an immediate scalar's bits, or an `Addr`. The IR result +/// type tells the executor which. +pub const Reg = u64; + +/// The flat-memory machine: one linear byte buffer serving as both the comptime +/// stack and heap, with a bump allocator and stack-mark reclamation. +pub const Machine = struct { + mem: std.ArrayList(u8), + gpa: std.mem.Allocator, + + /// Reserve a small guard prefix so `allocBytes` never returns `null_addr` (0) + /// — a zeroed register must read as null, not as a real object at offset 0. + pub fn init(gpa: std.mem.Allocator) Machine { + var m = Machine{ .mem = .empty, .gpa = gpa }; + m.mem.appendNTimes(gpa, 0, 8) catch @panic("comptime VM: out of memory reserving guard"); + return m; + } + + pub fn deinit(self: *Machine) void { + self.mem.deinit(self.gpa); + } + + /// Bump-allocate `size` bytes aligned to `alignment` (zero-initialised); + /// returns the address. `size == 0` still returns a valid (aligned) address + /// distinct from `null_addr`. Allocations are reclaimed wholesale by + /// `reset(mark())` — there is no per-object free (comptime is short-lived). + pub fn allocBytes(self: *Machine, size: usize, alignment: usize) Addr { + const a = if (alignment == 0) 1 else alignment; + const cur = self.mem.items.len; + const aligned = std.mem.alignForward(usize, cur, a); + const pad = aligned - cur; + self.mem.appendNTimes(self.gpa, 0, pad + size) catch @panic("comptime VM: out of memory"); + return @intCast(aligned); + } + + /// Current stack high-water mark — pair with `reset` to reclaim a region. + pub fn mark(self: *const Machine) usize { + return self.mem.items.len; + } + + /// Reclaim everything allocated after `m` (a prior `mark()`), keeping the + /// backing capacity for reuse. + pub fn reset(self: *Machine, m: usize) void { + std.debug.assert(m <= self.mem.items.len); + self.mem.shrinkRetainingCapacity(m); + } + + /// Read a `size`-byte (1/2/4/8) little-endian scalar at `addr` into a register + /// word (zero-extended). Bounds- and null-checked. + pub fn readWord(self: *const Machine, addr: Addr, size: usize) Reg { + const a: usize = @intCast(addr); + std.debug.assert(addr != null_addr); + std.debug.assert(a + size <= self.mem.items.len); + std.debug.assert(size <= 8); + var buf: [8]u8 = @splat(0); + @memcpy(buf[0..size], self.mem.items[a .. a + size]); + return std.mem.readInt(u64, &buf, .little); + } + + /// Write the low `size` bytes (1/2/4/8) of register word `val` little-endian + /// at `addr`. Bounds- and null-checked. + pub fn writeWord(self: *Machine, addr: Addr, size: usize, val: Reg) void { + const a: usize = @intCast(addr); + std.debug.assert(addr != null_addr); + std.debug.assert(a + size <= self.mem.items.len); + std.debug.assert(size <= 8); + var buf: [8]u8 = undefined; + std.mem.writeInt(u64, &buf, val, .little); + @memcpy(self.mem.items[a .. a + size], buf[0..size]); + } + + /// A mutable byte view of `len` bytes at `addr` (for aggregate copies / slice + /// payloads). Bounds- and null-checked. The slice is invalidated by any + /// subsequent `allocBytes` that grows the backing — re-fetch after allocating. + pub fn bytes(self: *Machine, addr: Addr, len: usize) []u8 { + const a: usize = @intCast(addr); + std.debug.assert(addr != null_addr); + std.debug.assert(a + len <= self.mem.items.len); + return self.mem.items[a .. a + len]; + } +}; + +/// One call frame: a register file indexed by IR `Ref` index. It does NOT reclaim +/// the machine stack on exit — a callee can return an aggregate whose value is an +/// `Addr` into flat memory, and reclaiming the callee's region would dangle it. +/// Comptime evaluation is bounded, so all allocations live until `Vm.deinit`; +/// `Machine.mark`/`reset` remain for explicit scoped use. The register file IS +/// per-call (each `run` gets a fresh one sized to its callee's Ref space). +pub const Frame = struct { + regs: []Reg, + gpa: std.mem.Allocator, + + pub fn init(gpa: std.mem.Allocator, num_regs: usize) Frame { + const regs = gpa.alloc(Reg, num_regs) catch @panic("comptime VM: out of memory (frame regs)"); + @memset(regs, 0); + return .{ .regs = regs, .gpa = gpa }; + } + + pub fn deinit(self: *Frame) void { + self.gpa.free(self.regs); + } + + pub fn get(self: *const Frame, ref_index: usize) Reg { + return self.regs[ref_index]; + } + + pub fn set(self: *Frame, ref_index: usize, word: Reg) void { + self.regs[ref_index] = word; + } +}; + +/// Wiring entry point: try to evaluate comptime function `func_id` entirely on the +/// flat-memory VM and return its result as a legacy `Value`, or `null` if the VM +/// can't handle it (unsupported op, no body, or any bail) — the caller then falls +/// back to the legacy interpreter. The result is deep-copied into `gpa`, so it +/// outlives the VM's flat memory (freed here on return). +/// +/// SAFETY NOTE (host wiring prerequisite): the VM's memory accessors currently +/// `assert` on a null/out-of-bounds address (a debug panic), so this is only safe +/// for functions whose every access is well-formed. Before routing ARBITRARY host +/// comptime functions through here, harden `Machine.readWord`/`writeWord`/`bytes` +/// to return `error.OutOfBounds` instead of asserting — then a malformed run bails +/// (→ null → legacy fallback) rather than crashing the compiler. +pub fn tryEval(gpa: std.mem.Allocator, module: *const Module, func_id: inst_mod.FuncId) ?Value { + const func = module.getFunction(func_id); + if (func.is_extern or func.blocks.items.len == 0) return null; + var vm = Vm.init(gpa); + defer vm.deinit(); + vm.table = &module.types; + vm.module = module; + const reg = vm.run(func, &.{}) catch return null; + return vm.regToValue(gpa, &module.types, reg, func.ret) catch null; +} + +// ── Executor ──────────────────────────────────────────────────────────────── +// +// Walks the SAME SSA IR the legacy interpreter (`interp.zig`) walks, but over +// flat-memory frames: each SSA result is a `Reg` word (immediate scalar bits, or +// an `Addr`). Scalar semantics MIRROR the legacy interp so the two evaluators +// agree byte-for-byte (the parity goal): integer math is 64-bit wrapping/signed +// (`+%`, `@divTrunc`, signed compares — the legacy's `.int` is i64 regardless of +// the declared width), float math is f64. Memory/aggregate/call ops are not ported +// yet — they bail loudly (`error.Unsupported` + `detail`), never silently. + +pub const Error = error{ DivisionByZero, TypeError, Unsupported }; + +fn isFloat(ty: TypeId) bool { + return ty == .f32 or ty == .f64; +} + +pub const Vm = struct { + machine: Machine, + gpa: std.mem.Allocator, + /// The type table — supplies TARGET-aware layout (sizes/alignments/field + /// offsets keyed off `pointer_size`) for memory + aggregate ops. Optional so + /// scalar-only runs need no table; memory ops bail loudly if it is absent. + table: ?*const types.TypeTable = null, + /// The module — resolves a `call`'s callee `FuncId` to its `Function`. Optional + /// so leaf functions (no calls) need none; a `call` bails loudly if it is absent. + module: ?*const Module = null, + /// Current call-recursion depth, guarded against host stack overflow on deep / + /// infinite comptime recursion (mirrors the legacy interp's `call_depth`). + depth: u32 = 0, + /// Reason for the last `error.Unsupported` / `error.TypeError` bail — the op + /// tag name or a one-line explanation. Mirrors the legacy interp's + /// `last_bail_detail` so the host can surface a real message, not a bare error. + detail: ?[]const u8 = null, + + pub const max_depth: u32 = 512; + + pub fn init(gpa: std.mem.Allocator) Vm { + return .{ .machine = Machine.init(gpa), .gpa = gpa }; + } + + pub fn deinit(self: *Vm) void { + self.machine.deinit(); + } + + /// Run `func` with scalar `args` (one `Reg` word each, in param order) and + /// return the scalar result word. `ret_void` / falling off a block with no + /// terminator yields 0. Aggregate args/results await the memory sub-step. + pub fn run(self: *Vm, func: *const Function, args: []const Reg) Error!Reg { + if (self.depth >= max_depth) { + self.detail = "comptime VM: call recursion too deep"; + return error.Unsupported; + } + self.depth += 1; + defer self.depth -= 1; + + // The Ref index space is flat: params first, then every block's + // instructions in block order (each `block.first_ref` is its base). Size + // the register file + a parallel Ref→type map to it. + var total: usize = func.params.len; + for (func.blocks.items) |blk| total += blk.insts.items.len; + + const ref_types = self.gpa.alloc(TypeId, total) catch @panic("comptime VM: out of memory (ref types)"); + defer self.gpa.free(ref_types); + for (func.params, 0..) |p, i| ref_types[i] = p.ty; + for (func.blocks.items) |blk| { + for (blk.insts.items, 0..) |ins, j| ref_types[@as(usize, blk.first_ref) + j] = ins.ty; + } + + var frame = Frame.init(self.gpa, total); + defer frame.deinit(); + for (args, 0..) |a, i| frame.set(i, a); + + var current = BlockId.fromIndex(0); + // Branch args are passed as Refs (not resolved values): the same frame + // persists, and a target block's `block_param`s — its first instructions — + // read the source registers before anything overwrites them (SSA: a block + // only writes its own Ref range). + var block_args: []const Ref = &.{}; + while (true) { + const blk = &func.blocks.items[current.index()]; + var ref: usize = blk.first_ref; + var jumped = false; + for (blk.insts.items) |*ins| { + if (ins.op == .block_param) { + const bp = ins.op.block_param; + if (bp.param_index < block_args.len) + frame.set(ref, frame.get(block_args[bp.param_index].index())); + ref += 1; + continue; + } + switch (try self.exec(ins, &frame, ref_types)) { + .value => |w| { + frame.set(ref, w); + ref += 1; + }, + .jump => |j| { + current = j.target; + block_args = j.args; + jumped = true; + break; + }, + .ret => |w| return w, + .ret_void => return 0, + } + } + if (!jumped) return 0; // fell off the block with no terminator → void + } + } + + const Step = union(enum) { + value: Reg, + jump: struct { target: BlockId, args: []const Ref }, + ret: Reg, + ret_void, + }; + + fn exec(self: *Vm, ins: *const Inst, frame: *Frame, ref_types: []const TypeId) Error!Step { + switch (ins.op) { + // ── Constants ─────────────────────────────────────── + .const_int => |v| return .{ .value = @bitCast(v) }, + .const_bool => |v| return .{ .value = @intFromBool(v) }, + .const_float => |v| return .{ .value = @bitCast(v) }, + .const_null, .const_undef => return .{ .value = null_addr }, + + // ── Arithmetic ────────────────────────────────────── + .add, .sub, .mul, .div, .mod => |b| return .{ + .value = try arith(std.meta.activeTag(ins.op), ins.ty, frame.get(b.lhs.index()), frame.get(b.rhs.index())), + }, + .neg => |u| { + const x = frame.get(u.operand.index()); + if (isFloat(ins.ty)) return .{ .value = @bitCast(-@as(f64, @bitCast(x))) }; + return .{ .value = @bitCast(-%@as(i64, @bitCast(x))) }; + }, + + // ── Comparison (operand type drives signedness/kind) ─ + .cmp_eq, .cmp_ne, .cmp_lt, .cmp_le, .cmp_gt, .cmp_ge => |b| { + const r = try self.cmp(std.meta.activeTag(ins.op), ref_types[b.lhs.index()], frame.get(b.lhs.index()), frame.get(b.rhs.index())); + return .{ .value = @intFromBool(r) }; + }, + + // ── Logical (operands already evaluated) ──────────── + .bool_and => |b| return .{ .value = @intFromBool(frame.get(b.lhs.index()) != 0 and frame.get(b.rhs.index()) != 0) }, + .bool_or => |b| return .{ .value = @intFromBool(frame.get(b.lhs.index()) != 0 or frame.get(b.rhs.index()) != 0) }, + .bool_not => |u| return .{ .value = @intFromBool(frame.get(u.operand.index()) == 0) }, + + // ── Conversions ───────────────────────────────────── + // widen/narrow/bitcast pass the bits through (comptime values don't + // truncate — matches the legacy interp). int↔float DO convert. + .widen, .narrow, .bitcast => |c| return .{ .value = frame.get(c.operand.index()) }, + .int_to_float => |c| return .{ .value = @bitCast(@as(f64, @floatFromInt(@as(i64, @bitCast(frame.get(c.operand.index())))))) }, + .float_to_int => |c| return .{ .value = @bitCast(@as(i64, @intFromFloat(@as(f64, @bitCast(frame.get(c.operand.index())))))) }, + + // ── Memory + structs (flat layout, target-aware) ──── + .alloca => |t| { + const table = try self.requireTable(); + return .{ .value = self.machine.allocBytes(table.typeSizeBytes(t), table.typeAlignBytes(t)) }; + }, + .load => |u| { + const table = try self.requireTable(); + return .{ .value = try self.readField(table, frame.get(u.operand.index()), ins.ty) }; + }, + .store => |s| { + const table = try self.requireTable(); + const vty = if (s.val_ty != .void) s.val_ty else ref_types[s.val.index()]; + try self.writeField(table, frame.get(s.ptr.index()), vty, frame.get(s.val.index())); + return .{ .value = 0 }; // store has a void result but still occupies a Ref slot + }, + .struct_init => |agg| { + const table = try self.requireTable(); + const sty = ins.ty; + const addr = self.machine.allocBytes(table.typeSizeBytes(sty), table.typeAlignBytes(sty)); + const fields = table.get(sty).@"struct".fields; + for (fields, 0..) |f, i| { + if (i >= agg.fields.len) break; + const off = fieldOffset(table, sty, @intCast(i)); + try self.writeField(table, addr + off, f.ty, frame.get(agg.fields[i].index())); + } + return .{ .value = addr }; + }, + .struct_get => |fa| { + const table = try self.requireTable(); + const sty = aggType(table, fa, ref_types); + const fty = table.get(sty).@"struct".fields[fa.field_index].ty; + return .{ .value = try self.readField(table, frame.get(fa.base.index()) + fieldOffset(table, sty, fa.field_index), fty) }; + }, + .struct_gep => |fa| { + const table = try self.requireTable(); + const sty = aggType(table, fa, ref_types); + return .{ .value = frame.get(fa.base.index()) + fieldOffset(table, sty, fa.field_index) }; + }, + + // ── Tuples (positional aggregates) ────────────────── + .tuple_init => |agg| { + const table = try self.requireTable(); + const tty = ins.ty; + const addr = self.machine.allocBytes(table.typeSizeBytes(tty), table.typeAlignBytes(tty)); + const elems = table.get(tty).tuple.fields; + for (elems, 0..) |fty, i| { + if (i >= agg.fields.len) break; + try self.writeField(table, addr + tupleFieldOffset(table, tty, @intCast(i)), fty, frame.get(agg.fields[i].index())); + } + return .{ .value = addr }; + }, + .tuple_get => |fa| { + const table = try self.requireTable(); + const tty = aggType(table, fa, ref_types); + const fty = table.get(tty).tuple.fields[fa.field_index]; + return .{ .value = try self.readField(table, frame.get(fa.base.index()) + tupleFieldOffset(table, tty, fa.field_index), fty) }; + }, + + // ── Arrays (contiguous, elem-size stride) ─────────── + .index_get => |b| { + const table = try self.requireTable(); + const addr = try self.elemAddr(table, ref_types[b.lhs.index()], frame.get(b.lhs.index()), frame.get(b.rhs.index()), table.typeSizeBytes(ins.ty)); + return .{ .value = try self.readField(table, addr, ins.ty) }; + }, + .index_gep => |b| { + const table = try self.requireTable(); + const elem_ty = pointeeOf(table, ins.ty); + return .{ .value = try self.elemAddr(table, ref_types[b.lhs.index()], frame.get(b.lhs.index()), frame.get(b.rhs.index()), table.typeSizeBytes(elem_ty)) }; + }, + .length => |u| { + const table = try self.requireTable(); + const oty = ref_types[u.operand.index()]; + if (oty == .string) return .{ .value = self.sliceLen(frame.get(u.operand.index())) }; + if (!oty.isBuiltin()) { + switch (table.get(oty)) { + .array => |a| return .{ .value = a.length }, + .slice => return .{ .value = self.sliceLen(frame.get(u.operand.index())) }, + else => {}, + } + } + self.detail = "comptime VM: length() on a non-array/slice/string operand"; + return error.Unsupported; + }, + + // ── Slices + strings ({ptr,len} fat pointers) ─────── + .const_string => |sid| { + const table = try self.requireTable(); + const text = table.getString(sid); + const data = self.machine.allocBytes(text.len + 1, 1); // +1: NUL (zero-init) + if (text.len > 0) @memcpy(self.machine.bytes(data, text.len), text); + return .{ .value = self.makeSlice(table, data, text.len) }; + }, + .data_ptr => |u| { + const table = try self.requireTable(); + const oty = ref_types[u.operand.index()]; + if (oty == .string or (!oty.isBuiltin() and table.get(oty) == .slice)) + return .{ .value = self.sliceData(table, frame.get(u.operand.index())) }; + self.detail = "comptime VM: .ptr (data_ptr) on a non-slice/string operand"; + return error.Unsupported; + }, + .array_to_slice => |u| { + const table = try self.requireTable(); + var aty = ref_types[u.operand.index()]; + if (!aty.isBuiltin() and table.get(aty) == .pointer) aty = table.get(aty).pointer.pointee; + if (aty.isBuiltin() or table.get(aty) != .array) { + self.detail = "comptime VM: array_to_slice on a non-array operand"; + return error.Unsupported; + } + return .{ .value = self.makeSlice(table, frame.get(u.operand.index()), table.get(aty).array.length) }; + }, + .subslice => |s| { + const table = try self.requireTable(); + const base = frame.get(s.base.index()); + const lo: u64 = @bitCast(frame.get(s.lo.index())); + const hi: u64 = @bitCast(frame.get(s.hi.index())); + const bty = if (s.base_ty != .void) s.base_ty else ref_types[s.base.index()]; + var elem: TypeId = .u8; + var data: Addr = base; + if (bty == .string) { + data = self.sliceData(table, base); + } else if (!bty.isBuiltin()) { + switch (table.get(bty)) { + .array => |a| elem = a.element, + .slice => |sl| { + elem = sl.element; + data = self.sliceData(table, base); + }, + else => { + self.detail = "comptime VM: subslice on a non-array/slice/string base"; + return error.Unsupported; + }, + } + } else { + self.detail = "comptime VM: subslice on an unsupported base"; + return error.Unsupported; + } + const esz: u64 = @intCast(table.typeSizeBytes(elem)); + return .{ .value = self.makeSlice(table, data +% lo *% esz, hi - lo) }; + }, + .str_eq, .str_ne => |b| { + const table = try self.requireTable(); + const lb = frame.get(b.lhs.index()); + const rb = frame.get(b.rhs.index()); + const ls = self.machine.bytes(self.sliceData(table, lb), @intCast(self.sliceLen(lb))); + const rs = self.machine.bytes(self.sliceData(table, rb), @intCast(self.sliceLen(rb))); + const eq = std.mem.eql(u8, ls, rs); + return .{ .value = @intFromBool(if (std.meta.activeTag(ins.op) == .str_eq) eq else !eq) }; + }, + + // ── Optionals ─────────────────────────────────────── + .optional_wrap => |u| { + const table = try self.requireTable(); + const child = table.get(ins.ty).optional.child; // ins.ty is ?T + const val = frame.get(u.operand.index()); + if (optChildIsPtr(table, child)) return .{ .value = val }; // pointer optional: the pointer + const addr = self.machine.allocBytes(table.typeSizeBytes(ins.ty), table.typeAlignBytes(ins.ty)); + try self.writeField(table, addr, child, val); // payload @ 0 + self.machine.writeWord(addr + table.typeSizeBytes(child), 1, 1); // has_value flag = 1 + return .{ .value = addr }; + }, + .optional_unwrap => |u| { + const table = try self.requireTable(); + const opt_ty = ref_types[u.operand.index()]; + const v = frame.get(u.operand.index()); + if (!self.optHas(table, opt_ty, v)) { + self.detail = "comptime VM: unwrap of a null optional"; + return error.TypeError; + } + const child = table.get(opt_ty).optional.child; + if (optChildIsPtr(table, child)) return .{ .value = v }; + return .{ .value = try self.readField(table, v, child) }; + }, + .optional_has_value => |u| { + const table = try self.requireTable(); + return .{ .value = @intFromBool(self.optHas(table, ref_types[u.operand.index()], frame.get(u.operand.index()))) }; + }, + .optional_coalesce => |b| { + const table = try self.requireTable(); + const opt_ty = ref_types[b.lhs.index()]; + const v = frame.get(b.lhs.index()); + if (self.optHas(table, opt_ty, v)) { + const child = table.get(opt_ty).optional.child; + if (optChildIsPtr(table, child)) return .{ .value = v }; + return .{ .value = try self.readField(table, v, child) }; + } + return .{ .value = frame.get(b.rhs.index()) }; + }, + + // ── Enums (payloadless: the tag is the value) ─────── + .enum_init => |ei| { + if (!ei.payload.isNone()) { + self.detail = "comptime VM: enum_init with payload (tagged union) not yet ported"; + return error.Unsupported; + } + return .{ .value = @as(Reg, ei.tag) }; + }, + .enum_tag => |u| { + const oty = ref_types[u.operand.index()]; + const v = frame.get(u.operand.index()); + if (oty.isBuiltin()) return .{ .value = v }; // already an integer tag + const table = try self.requireTable(); + if (table.get(oty) == .@"enum") return .{ .value = v }; // payloadless: word IS the tag + self.detail = "comptime VM: enum_tag on a tagged union not yet ported"; + return error.Unsupported; + }, + + // ── Calls ─────────────────────────────────────────── + .call => |c| { + const module = self.module orelse { + self.detail = "comptime VM: call needs a module (not provided)"; + return error.Unsupported; + }; + const callee = module.getFunction(c.callee); + if (callee.is_extern or callee.blocks.items.len == 0) { + self.detail = "comptime VM: call to an extern/builtin function not yet ported"; + return error.Unsupported; + } + // Marshal arg Refs → Reg words (aggregates pass as their Addr — the + // callee shares this machine's flat memory, so no copy is needed). + const argbuf = self.gpa.alloc(Reg, c.args.len) catch @panic("comptime VM: out of memory (call args)"); + defer self.gpa.free(argbuf); + for (c.args, 0..) |a, i| argbuf[i] = frame.get(a.index()); + return .{ .value = try self.run(callee, argbuf) }; + }, + + // ── Pointers ──────────────────────────────────────── + // `@x` — pass through: an aggregate value already IS its address, and a + // pointer value is already an address (mirrors the legacy interp). + .addr_of => |u| return .{ .value = frame.get(u.operand.index()) }, + // `p.*` — read the pointee (like `load`); `ins.ty` is the pointee type. + .deref => |u| { + const table = try self.requireTable(); + return .{ .value = try self.readField(table, frame.get(u.operand.index()), ins.ty) }; + }, + + // ── Terminators ───────────────────────────────────── + .br => |b| return .{ .jump = .{ .target = b.target, .args = b.args } }, + .cond_br => |b| { + if (frame.get(b.cond.index()) != 0) return .{ .jump = .{ .target = b.then_target, .args = b.then_args } }; + return .{ .jump = .{ .target = b.else_target, .args = b.else_args } }; + }, + .ret => |u| return .{ .ret = frame.get(u.operand.index()) }, + .ret_void => return .ret_void, + + // Not yet ported (memory, aggregates, calls, …): bail loudly with the + // op name — never a silent default. + else => { + self.detail = @tagName(ins.op); + return error.Unsupported; + }, + } + } + + /// 64-bit integer (wrapping/signed) or f64 arithmetic, keyed on the result + /// type — mirrors the legacy `evalArith`. + fn arith(tag: OpTag, ty: TypeId, l: Reg, r: Reg) Error!Reg { + if (isFloat(ty)) { + const lf: f64 = @bitCast(l); + const rf: f64 = @bitCast(r); + const res: f64 = switch (tag) { + .add => lf + rf, + .sub => lf - rf, + .mul => lf * rf, + .div => if (rf == 0.0) return error.DivisionByZero else lf / rf, + .mod => @mod(lf, rf), + else => unreachable, + }; + return @bitCast(res); + } + const li: i64 = @bitCast(l); + const ri: i64 = @bitCast(r); + const res: i64 = switch (tag) { + .add => li +% ri, + .sub => li -% ri, + .mul => li *% ri, + .div => if (ri == 0) return error.DivisionByZero else @divTrunc(li, ri), + .mod => if (ri == 0) return error.DivisionByZero else @mod(li, ri), + else => unreachable, + }; + return @bitCast(res); + } + + /// Comparison keyed on the operand type: f64 for floats, == / != only for + /// bool, else signed i64 — mirrors the legacy `evalCmp`. + fn cmp(self: *Vm, tag: OpTag, lty: TypeId, l: Reg, r: Reg) Error!bool { + if (isFloat(lty)) { + const lf: f64 = @bitCast(l); + const rf: f64 = @bitCast(r); + return switch (tag) { + .cmp_eq => lf == rf, + .cmp_ne => lf != rf, + .cmp_lt => lf < rf, + .cmp_le => lf <= rf, + .cmp_gt => lf > rf, + .cmp_ge => lf >= rf, + else => unreachable, + }; + } + if (lty == .bool) { + const lb = l != 0; + const rb = r != 0; + return switch (tag) { + .cmp_eq => lb == rb, + .cmp_ne => lb != rb, + else => { + self.detail = "comptime VM: bool comparison supports only == / !="; + return error.TypeError; + }, + }; + } + const li: i64 = @bitCast(l); + const ri: i64 = @bitCast(r); + return switch (tag) { + .cmp_eq => li == ri, + .cmp_ne => li != ri, + .cmp_lt => li < ri, + .cmp_le => li <= ri, + .cmp_gt => li > ri, + .cmp_ge => li >= ri, + else => unreachable, + }; + } + + fn requireTable(self: *Vm) Error!*const types.TypeTable { + return self.table orelse { + self.detail = "comptime VM: memory/aggregate op needs a type table (not provided)"; + return error.Unsupported; + }; + } + + fn failMsg(self: *Vm, msg: []const u8) error{Unsupported} { + self.detail = msg; + return error.Unsupported; + } + + // ── Reg ↔ Value bridge (legacy-interop boundary) ──────────────────────── + // + // The wiring step routes a comptime eval through the VM, falling back to the + // legacy `interp.zig` (tagged `Value` model) on `error.Unsupported`. The + // boundary converts host `Value` args → VM `Reg` words and the VM's result back + // → a `Value`. This IS a (de)serialization, but ONLY at the legacy boundary and + // ONLY for the shapes the VM handled — it is transitional, deleted once the VM + // owns comptime end-to-end. Covers scalars + strings + structs; other aggregate + // shapes bail loudly (added as wiring surfaces them). + + /// Convert a legacy `Value` of type `ty` into a VM `Reg`, materializing + /// aggregates into flat memory (returning their `Addr`). + pub fn valueToReg(self: *Vm, table: *const types.TypeTable, value: Value, ty: TypeId) Error!Reg { + switch (kindOf(table, ty)) { + .word => return switch (value) { + .int => |i| @bitCast(i), + .boolean => |b| @intFromBool(b), + .float => |f| @bitCast(f), + .null_val => null_addr, + .type_tag => |t| t.index(), + else => self.failMsg("value→reg: scalar value kind mismatch"), + }, + .aggregate => { + if (ty == .string) { + const text = switch (value) { + .string => |s| s, + else => return self.failMsg("value→reg: expected a string literal value"), + }; + const data = self.machine.allocBytes(text.len + 1, 1); + if (text.len > 0) @memcpy(self.machine.bytes(data, text.len), text); + return self.makeSlice(table, data, text.len); + } + const info = table.get(ty); + if (info == .@"struct") { + const fvals = switch (value) { + .aggregate => |a| a, + else => return self.failMsg("value→reg: expected a struct aggregate"), + }; + const addr = self.machine.allocBytes(table.typeSizeBytes(ty), table.typeAlignBytes(ty)); + for (info.@"struct".fields, 0..) |f, i| { + if (i >= fvals.len) break; + const fr = try self.valueToReg(table, fvals[i], f.ty); + try self.writeField(table, addr + fieldOffset(table, ty, @intCast(i)), f.ty, fr); + } + return addr; + } + return self.failMsg("value→reg: aggregate shape not bridged yet (slice/array/optional/tuple/enum)"); + }, + .unsupported => return self.failMsg("value→reg: unsupported type"), + } + } + + /// Convert a VM `Reg` (+ flat memory) of type `ty` back into a legacy `Value`. + /// Strings/aggregates are deep-copied into `alloc` (they must outlive flat memory). + pub fn regToValue(self: *Vm, alloc: std.mem.Allocator, table: *const types.TypeTable, reg: Reg, ty: TypeId) Error!Value { + switch (kindOf(table, ty)) { + .word => { + if (isFloat(ty)) return .{ .float = @bitCast(reg) }; + if (ty == .bool) return .{ .boolean = reg != 0 }; + return .{ .int = @bitCast(reg) }; + }, + .aggregate => { + if (ty == .string) { + const src = self.machine.bytes(self.sliceData(table, reg), @intCast(self.sliceLen(reg))); + return .{ .string = alloc.dupe(u8, src) catch return self.failMsg("reg→value: out of memory (string)") }; + } + const info = table.get(ty); + if (info == .@"struct") { + const out = alloc.alloc(Value, info.@"struct".fields.len) catch return self.failMsg("reg→value: out of memory (struct)"); + for (info.@"struct".fields, 0..) |f, i| { + const fr = try self.readField(table, reg + fieldOffset(table, ty, @intCast(i)), f.ty); + out[i] = try self.regToValue(alloc, table, fr, f.ty); + } + return .{ .aggregate = out }; + } + return self.failMsg("reg→value: aggregate shape not bridged yet"); + }, + .unsupported => return self.failMsg("reg→value: unsupported type"), + } + } + + /// How a value of type `ty` is held: a register word (scalar/pointer, ≤8 + /// bytes) or by-address in flat memory (struct). Anything else is not ported + /// yet (slice/string/any/optional/enum/union/array/tuple/vector — sub-step 4+). + const Kind = enum { word, aggregate, unsupported }; + + fn kindOf(table: *const types.TypeTable, ty: TypeId) Kind { + switch (ty) { + .bool, .i8, .u8, .i16, .u16, .i32, .u32, .f32, .i64, .u64, .f64, .usize, .isize, .cstring => return .word, + .string => return .aggregate, // {ptr,len} fat pointer (16B), by-address + else => {}, + } + if (ty.isBuiltin()) return .unsupported; // any (16B, different shape), void, noreturn, unresolved + return switch (table.get(ty)) { + .pointer, .many_pointer, .function => .word, + .@"enum" => .word, // payloadless enum: i64 (or its backing) — a word + .@"struct", .array, .tuple, .slice => .aggregate, + // `?T`: a pointer child is null-as-0 (word); else `{T, i1}` by-address. + .optional => |o| if (optChildIsPtr(table, o.child)) .word else .aggregate, + else => .unsupported, + }; + } + + /// A `?T` whose child is a pointer/many-pointer/function is represented as a + /// bare pointer (null == 0), not a `{T, i1}` aggregate — mirrors `typeSizeBytes`. + fn optChildIsPtr(table: *const types.TypeTable, child: TypeId) bool { + if (child.isBuiltin()) return false; + return switch (table.get(child)) { + .pointer, .many_pointer, .function => true, + else => false, + }; + } + + /// Does an optional value `v` of type `opt_ty` hold a value? A pointer optional + /// is present iff non-null; a `{T,i1}` optional is none when `v` is `null_addr` + /// (the `const_null` form) else its flag byte (at offset `sizeof(child)`) is set. + fn optHas(self: *Vm, table: *const types.TypeTable, opt_ty: TypeId, v: Reg) bool { + const child = table.get(opt_ty).optional.child; + if (optChildIsPtr(table, child)) return v != null_addr; + if (v == null_addr) return false; + return self.machine.readWord(v + table.typeSizeBytes(child), 1) != 0; + } + + /// Read a value of type `ty` from flat address `addr`: a scalar reads its + /// bytes; an aggregate value IS its address (it lives inline at `addr`). + fn readField(self: *Vm, table: *const types.TypeTable, addr: Addr, ty: TypeId) Error!Reg { + return switch (kindOf(table, ty)) { + .word => self.machine.readWord(addr, table.typeSizeBytes(ty)), + .aggregate => addr, + .unsupported => { + self.detail = "comptime VM: value type not yet supported on flat memory (slice/optional/enum/array/etc.)"; + return error.Unsupported; + }, + }; + } + + /// Write register word `val` (of type `ty`) to flat address `addr`: a scalar + /// writes its bytes; an aggregate copies `sizeof(ty)` bytes from `val` (its + /// source address) into `addr`. + fn writeField(self: *Vm, table: *const types.TypeTable, addr: Addr, ty: TypeId, val: Reg) Error!void { + switch (kindOf(table, ty)) { + .word => self.machine.writeWord(addr, table.typeSizeBytes(ty), val), + .aggregate => { + const n = table.typeSizeBytes(ty); + if (n > 0) @memcpy(self.machine.bytes(addr, n), self.machine.bytes(val, n)); + }, + .unsupported => { + self.detail = "comptime VM: value type not yet supported on flat memory (slice/optional/enum/array/etc.)"; + return error.Unsupported; + }, + } + } + + /// The byte offset of struct field `idx`, computed the same way + /// `TypeTable.typeSizeBytes` lays a struct out (each field aligned to its own + /// alignment, in declaration order) — so init/get/gep agree, and the layout + /// matches the table's size computation. + fn fieldOffset(table: *const types.TypeTable, sty: TypeId, idx: u32) Addr { + const fields = table.get(sty).@"struct".fields; + var off: usize = 0; + for (fields, 0..) |f, i| { + off = std.mem.alignForward(usize, off, table.typeAlignBytes(f.ty)); + if (i == idx) return @intCast(off); + off += table.typeSizeBytes(f.ty); + } + return @intCast(off); + } + + /// The struct type a `FieldAccess` operates on: the explicit `base_type` when + /// lowering set it, else the base operand's Ref type — dereferenced when the + /// base is a POINTER (`struct_gep` on an `alloca` result is `*S` → `S`). + fn aggType(table: *const types.TypeTable, fa: inst_mod.FieldAccess, ref_types: []const TypeId) TypeId { + if (fa.base_type) |bt| return bt; + const rt = ref_types[fa.base.index()]; + if (!rt.isBuiltin()) { + const info = table.get(rt); + if (info == .pointer) return info.pointer.pointee; + } + return rt; + } + + /// The byte offset of tuple element `idx` — the positional analogue of + /// `fieldOffset` (each element aligned to its own alignment, in order). + fn tupleFieldOffset(table: *const types.TypeTable, tty: TypeId, idx: u32) Addr { + const fields = table.get(tty).tuple.fields; + var off: usize = 0; + for (fields, 0..) |fty, i| { + off = std.mem.alignForward(usize, off, table.typeAlignBytes(fty)); + if (i == idx) return @intCast(off); + off += table.typeSizeBytes(fty); + } + return @intCast(off); + } + + /// The pointee of a single-element pointer type (the result of `index_gep` is + /// `*element`). Falls back to `ty` if it isn't a `.pointer` (the caller only + /// uses the result for an element-size query). + fn pointeeOf(table: *const types.TypeTable, ty: TypeId) TypeId { + if (!ty.isBuiltin()) { + const info = table.get(ty); + if (info == .pointer) return info.pointer.pointee; + } + return ty; + } + + /// Address of element `idx_word` in `base`: `data + idx * elem_size`, where + /// `data` is `base` itself for a directly-addressable base (`array` / `pointer` + /// / `many_pointer` / `cstring`) or the loaded `.ptr` field for a fat-pointer + /// base (`slice` / `string`). + fn elemAddr(self: *Vm, table: *const types.TypeTable, base_ty: TypeId, base: Reg, idx_word: Reg, elem_size: usize) Error!Addr { + const data: Addr = blk: { + if (base_ty == .string) break :blk self.machine.readWord(base, table.pointer_size); + if (base_ty == .cstring) break :blk base; + if (base_ty.isBuiltin()) { + self.detail = "comptime VM: indexing an unsupported builtin base"; + return error.Unsupported; + } + break :blk switch (table.get(base_ty)) { + .array, .pointer, .many_pointer => base, + .slice => self.machine.readWord(base, table.pointer_size), + else => { + self.detail = "comptime VM: indexing a non-array/pointer/slice base"; + return error.Unsupported; + }, + }; + }; + const idx: u64 = @bitCast(idx_word); // non-negative comptime index + return data +% idx *% @as(u64, @intCast(elem_size)); + } + + /// Build a `{ptr, len}` fat pointer (slice/string value) in flat memory and + /// return its address. `ptr` is `pointer_size` bytes at offset 0; `len` is an + /// i64 at offset 8 (the layout `typeSizeBytes` uses for slice/string: 16B). + fn makeSlice(self: *Vm, table: *const types.TypeTable, data: Addr, len: u64) Addr { + const fp = self.machine.allocBytes(16, 8); + self.machine.writeWord(fp, table.pointer_size, data); + self.machine.writeWord(fp + 8, 8, len); + return fp; + } + + /// Read the `.len` field (i64 @ offset 8) of a fat-pointer value at `base`. + fn sliceLen(self: *Vm, base: Addr) u64 { + return self.machine.readWord(base + 8, 8); + } + + /// Read the `.ptr` field (`pointer_size` @ offset 0) of a fat-pointer at `base`. + fn sliceData(self: *Vm, table: *const types.TypeTable, base: Addr) Addr { + return self.machine.readWord(base, table.pointer_size); + } +}; diff --git a/src/ir/ir.zig b/src/ir/ir.zig index c1241535..e6def641 100644 --- a/src/ir/ir.zig +++ b/src/ir/ir.zig @@ -61,6 +61,7 @@ pub const ErrorFacts = error_analysis.ErrorFacts; pub const compiler_hooks = @import("compiler_hooks.zig"); pub const compiler_lib = @import("compiler_lib.zig"); +pub const comptime_vm = @import("comptime_vm.zig"); pub const emit_llvm = @import("emit_llvm.zig"); pub const LLVMEmitter = emit_llvm.LLVMEmitter; @@ -91,6 +92,7 @@ pub const emit_llvm_tests = @import("emit_llvm.test.zig"); pub const jni_descriptor_tests = @import("jni_descriptor.test.zig"); pub const jni_java_emit_tests = @import("jni_java_emit.test.zig"); pub const compiler_lib_tests = @import("compiler_lib.test.zig"); +pub const comptime_vm_tests = @import("comptime_vm.test.zig"); test { @import("std").testing.refAllDecls(@This());