const std = @import("std"); const Allocator = std.mem.Allocator; const types = @import("types.zig"); const inst_mod = @import("inst.zig"); const mod_mod = @import("module.zig"); const errors = @import("../errors.zig"); const TypeId = types.TypeId; const TypeTable = types.TypeTable; const StringId = types.StringId; const Ref = inst_mod.Ref; const BlockId = inst_mod.BlockId; const FuncId = inst_mod.FuncId; const Inst = inst_mod.Inst; const Op = inst_mod.Op; const Function = inst_mod.Function; const Block = inst_mod.Block; const Module = mod_mod.Module; const Builder = mod_mod.Builder; // ── Value ─────────────────────────────────────────────────────────────── pub const Value = union(enum) { int: i64, float: f64, boolean: bool, string: []const u8, null_val, void_val, undef, aggregate: []const Value, slot_ptr: u32, // index into the frame's local slots func_ref: FuncId, closure: ClosureVal, type_tag: TypeId, heap_ptr: HeapPtr, // pointer into heap-allocated memory /// Byte-granular raw pointer. Produced by `index_gep` on a string / /// `[*]u8` aggregate whose data field is itself a raw integer pointer /// (e.g. from libc_malloc). Store/load through this variant operate /// on a single byte — matching the heap_ptr semantics for the same /// op shape. byte_ptr: usize, pub const ClosureVal = struct { func: FuncId, env: ?[]const Value, }; /// A pointer to heap-allocated memory, with an optional byte offset. pub const HeapPtr = struct { id: u32, // index into Interpreter.heap offset: u32 = 0, }; pub fn asInt(self: Value) ?i64 { return switch (self) { .int => |v| v, else => null, }; } pub fn asFloat(self: Value) ?f64 { return switch (self) { .float => |v| v, .int => |v| @floatFromInt(v), // implicit int→float for convenience else => null, }; } pub fn asBool(self: Value) ?bool { return switch (self) { .boolean => |v| v, else => null, }; } pub fn isNull(self: Value) bool { return self == .null_val; } /// Extract the TypeId from a first-class Type value. Returns null /// for anything else — including `.int(N)` where N happens to be /// a valid TypeId enum value. The kinds are distinct: a Type IS /// NOT an int. Use this helper instead of `asInt` when reading a /// TypeId out of a Value to keep the kind-distinction honest. pub fn asTypeId(self: Value) ?TypeId { return switch (self) { .type_tag => |id| id, else => null, }; } /// Get the string content, whether from a literal or a heap-backed string aggregate. pub fn asString(self: Value, interp: *const Interpreter) ?[]const u8 { return switch (self) { .string => |s| s, .aggregate => |fields| { // String fat pointer: { heap_ptr/string/raw_int_ptr, int(len) } if (fields.len == 2) { const len: usize = @intCast(fields[1].asInt() orelse return null); switch (fields[0]) { .heap_ptr => |hp| { const mem = interp.heapSlice(hp) orelse return null; return if (len <= mem.len) mem[0..len] else null; }, .string => |s| return if (len <= s.len) s[0..len] else s, // Raw host pointer (e.g. from CAllocator.alloc → // libc_malloc). Read `len` bytes back from real // memory. .int => |addr| { const p: [*]const u8 = @ptrFromInt(@as(usize, @bitCast(addr))); return p[0..len]; }, else => return null, } } return null; }, else => null, }; } }; // ── Error ─────────────────────────────────────────────────────────────── pub const InterpError = error{ CannotEvalComptime, TypeError, OutOfBounds, DivisionByZero, StackOverflow, Unreachable, }; const compiler_hooks = @import("compiler_hooks.zig"); pub const BuildConfig = compiler_hooks.BuildConfig; const host_ffi = @import("host_ffi.zig"); // ── Interpreter ───────────────────────────────────────────────────────── pub const Interpreter = struct { module: *const Module, alloc: Allocator, output: std.ArrayList(u8), call_depth: u32 = 0, max_call_depth: u32 = 256, /// Active sx call-frame chain (oldest→newest), maintained across `call` for /// `trace.print_interpreter_frames()` (ERR E4.1). Only sx-bodied frames are /// tracked — foreign calls return before the frame is pushed. call_chain: std.ArrayList(FuncId) = .empty, /// File → source text (the diagnostics' import_sources). Set by the host /// where available so `.trace_resolve` can turn a `(func_id, span.start)` /// frame into `file:line:col` at comptime (ERR E3.0 slice 3b). Null → the /// resolver degrades to line/col 1:1. source_map: ?*const std.StringHashMap([:0]const u8) = null, // Heap: dynamically allocated memory blocks heap: std.ArrayList([]u8), // Global values: evaluated comptime globals, indexed by GlobalId global_values: std.AutoHashMap(u32, Value), // Mutable build configuration — set by LLVMEmitter, written by #run blocks build_config: ?*BuildConfig = null, // Compiler hook registry for #compiler methods hooks: compiler_hooks.Registry, // First op tag that bailed with InterpError, captured the first // time the interpreter unwinds so callers can surface "op=foo at // :" alongside the bare error name. Static so it // survives Interpreter teardown (lifetime: program global). pub var last_bail_op: ?[]const u8 = null; pub var last_bail_file: ?[]const u8 = null; pub var last_bail_offset: u32 = 0; pub var last_bail_builtin: ?[]const u8 = null; /// Free-text explanation of WHY the bail happened — set at sites /// that currently can't handle a specific Value/op combination /// (raw-pointer loads, struct_gep through `*void`, etc.). The host /// diagnostic renderer surfaces this so users see "load through /// raw pointer not supported" instead of a bare `CannotEvalComptime`. pub var last_bail_detail: ?[]const u8 = null; /// Set `last_bail_detail` to a static message and return the error. /// Use at sites where a specific raw-pointer Value tag isn't handled /// so users get a clear explanation instead of guessing. fn bailDetail(comptime msg: []const u8) InterpError { if (last_bail_detail == null) last_bail_detail = msg; return error.CannotEvalComptime; } /// Like `bailDetail` but returns a `TypeError` — for foreign-arg /// marshalling sites that previously erased the reason. fn typeErrorDetail(comptime msg: []const u8) InterpError { if (last_bail_detail == null) last_bail_detail = msg; return error.TypeError; } pub fn init(module: *const Module, alloc: Allocator) Interpreter { var hooks = compiler_hooks.Registry.init(alloc); hooks.registerDefaults(); return .{ .module = module, .alloc = alloc, .output = std.ArrayList(u8).empty, .heap = std.ArrayList([]u8).empty, .global_values = std.AutoHashMap(u32, Value).init(alloc), .hooks = hooks, }; } /// Provide the file→source map so `.trace_resolve` can compute file:line:col /// for comptime trace frames. Optional — absent in unit tests. pub fn setSourceMap(self: *Interpreter, sm: *const std.StringHashMap([:0]const u8)) void { self.source_map = sm; } pub fn deinit(self: *Interpreter) void { // Free all heap allocations for (self.heap.items) |block| { self.alloc.free(block); } self.heap.deinit(self.alloc); self.output.deinit(self.alloc); self.call_chain.deinit(self.alloc); self.global_values.deinit(); self.hooks.deinit(); } /// Write `val` to the raw host address `addr` using exactly the /// number of bytes declared by `val_ty`. Used when the /// protocol-dispatch chain bottoms out at a foreign-libc-malloc /// pointer and sx code stores through it. Comptime safety is the /// caller's responsibility — wild writes will fault. fn storeAtRawPtr(self: *Interpreter, addr: i64, val: Value, val_ty: @import("types.zig").TypeId) InterpError!void { const dst: [*]u8 = @ptrFromInt(@as(usize, @bitCast(addr))); const width = self.module.types.typeSizeBytes(val_ty); switch (val) { .int => |v| { // Width is whatever the declared IR type says (s8..s64, // u8..u64, usize, pointer-as-int, bool-after-extension). // The Value tag itself is .int regardless. if (width == 0 or width > 8) return bailDetail("comptime store of int through raw pointer: unexpected declared width (expected 1..8 bytes)"); const bytes = std.mem.toBytes(v); @memcpy(dst[0..width], bytes[0..width]); }, .float => |v| { switch (width) { 8 => { const bytes = std.mem.toBytes(v); @memcpy(dst[0..8], &bytes); }, 4 => { const f32_v: f32 = @floatCast(v); const bytes = std.mem.toBytes(f32_v); @memcpy(dst[0..4], &bytes); }, else => return bailDetail("comptime store of float through raw pointer: unexpected declared width (expected 4 or 8 bytes)"), } }, .boolean => |v| { if (width == 0) return bailDetail("comptime store of bool through raw pointer: zero-width destination"); @memset(dst[0..width], 0); dst[0] = if (v) 1 else 0; }, .null_val => { if (width == 0 or width > 8) return bailDetail("comptime store of null through raw pointer: unexpected declared width"); @memset(dst[0..width], 0); }, .aggregate => return bailDetail("comptime store of aggregate through raw pointer not supported (struct field layout not threaded into Store IR op)"), .heap_ptr => return bailDetail("comptime store of interp-heap pointer through raw pointer not supported"), .byte_ptr => return bailDetail("comptime store of byte pointer through raw pointer not supported"), .slot_ptr => return bailDetail("comptime store of slot pointer through raw pointer not supported (frame-local slot indices aren't meaningful as memory contents)"), .func_ref => return bailDetail("comptime store of func_ref through raw pointer not supported"), .closure => return bailDetail("comptime store of closure value through raw pointer not supported"), .string, .type_tag, .void_val, .undef => return bailDetail("comptime store: unsupported Value kind at raw destination"), } } // ── Implicit Context ────────────────────────────────────────── /// Build the default Context aggregate for top-level interp calls. /// Mirrors the static `__sx_default_context` LLVM global: a Context /// whose `allocator` field is the stateless CAllocator inline-protocol /// value (alloc/dealloc thunks bottom out at libc malloc/free). fn defaultContextValue(self: *Interpreter) Value { const tbl_ptr: *const @import("types.zig").TypeTable = &self.module.types; const tbl = @constCast(tbl_ptr); const alloc_thunk_name = tbl.internString("__thunk_CAllocator_Allocator_alloc"); const dealloc_thunk_name = tbl.internString("__thunk_CAllocator_Allocator_dealloc"); var alloc_fn: Value = .null_val; var dealloc_fn: Value = .null_val; for (self.module.functions.items, 0..) |func, i| { if (func.name == alloc_thunk_name) alloc_fn = .{ .func_ref = FuncId.fromIndex(@intCast(i)) }; if (func.name == dealloc_thunk_name) dealloc_fn = .{ .func_ref = FuncId.fromIndex(@intCast(i)) }; } const allocator_fields = self.alloc.alloc(Value, 3) catch unreachable; allocator_fields[0] = .null_val; // CAllocator receiver — stateless allocator_fields[1] = alloc_fn; allocator_fields[2] = dealloc_fn; const allocator_val: Value = .{ .aggregate = allocator_fields }; const ctx_fields = self.alloc.alloc(Value, 2) catch unreachable; ctx_fields[0] = allocator_val; ctx_fields[1] = .null_val; return .{ .aggregate = ctx_fields }; } // ── Heap operations ──────────────────────────────────────────── fn heapAlloc(self: *Interpreter, size: usize) Value.HeapPtr { const mem = self.alloc.alloc(u8, size) catch unreachable; @memset(mem, 0); const id: u32 = @intCast(self.heap.items.len); self.heap.append(self.alloc, mem) catch unreachable; return .{ .id = id }; } fn heapFree(self: *Interpreter, hp: Value.HeapPtr) void { if (hp.id < self.heap.items.len) { self.alloc.free(self.heap.items[hp.id]); self.heap.items[hp.id] = &.{}; } } pub fn heapSlice(self: *const Interpreter, hp: Value.HeapPtr) ?[]u8 { if (hp.id >= self.heap.items.len) return null; const mem = self.heap.items[hp.id]; if (hp.offset >= mem.len) return null; return mem[hp.offset..]; } fn heapMemcpy(self: *Interpreter, dst: Value.HeapPtr, src_bytes: []const u8, len: usize) void { const dst_mem = self.heapSlice(dst) orelse return; const copy_len = @min(len, @min(dst_mem.len, src_bytes.len)); @memcpy(dst_mem[0..copy_len], src_bytes[0..copy_len]); } fn heapMemset(self: *Interpreter, dst: Value.HeapPtr, val: u8, len: usize) void { const dst_mem = self.heapSlice(dst) orelse return; const set_len = @min(len, dst_mem.len); @memset(dst_mem[0..set_len], val); } fn heapStoreByte(self: *Interpreter, dst: Value.HeapPtr, val: u8) void { const mem = self.heapSlice(dst) orelse return; if (mem.len > 0) mem[0] = val; } /// Look up a global value, lazy-evaluating its comptime_func if needed. fn getGlobal(self: *Interpreter, gid: inst_mod.GlobalId) InterpError!Value { const idx = gid.index(); // Check cache first if (self.global_values.get(idx)) |v| return v; // Not cached — evaluate from global definition const global = &self.module.globals.items[idx]; if (global.comptime_func) |func_id| { const result = try self.call(func_id, &.{}); self.global_values.put(idx, result) catch {}; return result; } // Static init value if (global.init_val) |iv| { const val: Value = self.constToValue(iv); self.global_values.put(idx, val) catch {}; return val; } return .undef; } /// Marshal a single sx Value into a `usize` slot for a cdecl host call. /// Strings are made null-terminated; pointer-like values pass their /// underlying address. The returned `usize` is only valid for the /// duration of this call — caller-allocated buffers are tracked in /// `tmp` so they get freed once the call returns. fn marshalForeignArg(self: *Interpreter, v: Value, tmp: *std.ArrayList([]u8)) !usize { return switch (v) { .int => |i| @bitCast(i), .boolean => |b| @intFromBool(b), .null_val => 0, .byte_ptr => |addr| addr, .heap_ptr => |hp| blk: { // `heapSlice` returns the slice already advanced by `hp.offset`, // so its `.ptr` IS the offset address. Adding `hp.offset` again // double-counts and lands the foreign call past the buffer end. _ = self.heapSlice(hp) orelse return error.TypeError; break :blk @intFromPtr(self.heap.items[hp.id].ptr) + hp.offset; }, .string => |s| blk: { const buf = try self.alloc.alloc(u8, s.len + 1); @memcpy(buf[0..s.len], s); buf[s.len] = 0; tmp.append(self.alloc, buf) catch return error.TypeError; break :blk @intFromPtr(buf.ptr); }, .aggregate => |fields| blk: { // Fat string pointer: { ptr, len }. Pass the raw bytes // null-terminated so libc string APIs work. if (fields.len == 2) { const len: usize = @intCast(fields[1].asInt() orelse return error.TypeError); switch (fields[0]) { .heap_ptr => |hp| { const mem = self.heapSlice(hp) orelse return error.TypeError; const start = hp.offset; const slice = mem[start .. start + len]; const buf = try self.alloc.alloc(u8, len + 1); @memcpy(buf[0..len], slice); buf[len] = 0; tmp.append(self.alloc, buf) catch return error.TypeError; break :blk @intFromPtr(buf.ptr); }, .string => |s| { const slice = if (len <= s.len) s[0..len] else s; const buf = try self.alloc.alloc(u8, slice.len + 1); @memcpy(buf[0..slice.len], slice); buf[slice.len] = 0; tmp.append(self.alloc, buf) catch return error.TypeError; break :blk @intFromPtr(buf.ptr); }, // Raw host pointer (from libc_malloc-backed // cstring). Read bytes from real memory and copy // into a null-terminated buffer the foreign call // can consume. .int => |addr| { const src: [*]const u8 = @ptrFromInt(@as(usize, @bitCast(addr))); const buf = try self.alloc.alloc(u8, len + 1); @memcpy(buf[0..len], src[0..len]); buf[len] = 0; tmp.append(self.alloc, buf) catch return error.TypeError; break :blk @intFromPtr(buf.ptr); }, else => return typeErrorDetail("comptime foreign call: unsupported aggregate data-field kind (expected heap_ptr/string/int)"), } } return typeErrorDetail("comptime foreign call: aggregate arg must be a {ptr, len} fat-pointer pair"); }, else => return typeErrorDetail("comptime foreign call: unsupported arg Value kind"), }; } /// Append the current sx call-frame chain to the interp output, most-recent /// last (ERR E4.1). The topmost frame is `print_interpreter_frames` itself /// (the dump site), so it's skipped. Frame source locations await IR-offset /// resolution (the comptime analog of DWARF), so only function names print. fn printInterpFrames(self: *Interpreter) void { const n = self.call_chain.items.len; if (n <= 1) return; self.output.appendSlice(self.alloc, "comptime call frames (most recent call last):\n") catch {}; var i: usize = 0; while (i < n - 1) : (i += 1) { const fid = self.call_chain.items[i]; const fname = self.module.types.getString(self.module.getFunction(fid).name); const line = std.fmt.allocPrint(self.alloc, " at {s}\n", .{fname}) catch continue; defer self.alloc.free(line); self.output.appendSlice(self.alloc, line) catch {}; } } fn callForeign(self: *Interpreter, func: *const inst_mod.Function, args: []const Value) InterpError!Value { const name = self.module.types.getString(func.name); // A foreign call may not return (e.g. `process.exit` → `_exit`), which // would discard the interpreter's buffered `print` output (otherwise // flushed only after `#run` completes). Flush it first so comptime // diagnostics emitted just before a terminating call survive. if (self.output.items.len > 0) { _ = std.c.write(1, self.output.items.ptr, self.output.items.len); self.output.clearRetainingCapacity(); } const symbol = (host_ffi.lookupSymbol(self.alloc, name) catch return bailDetail("comptime foreign call: dlsym error looking up symbol")) orelse { if (last_bail_detail == null) last_bail_detail = "comptime foreign call: symbol not found via dlsym (target-specific binding called at compile time?)"; return error.CannotEvalComptime; }; var packed_args: [8]usize = undefined; if (args.len > packed_args.len) return bailDetail("comptime foreign call: more than 8 args (host_ffi trampolines max out at 8)"); var tmp = std.ArrayList([]u8).empty; defer { for (tmp.items) |buf| self.alloc.free(buf); tmp.deinit(self.alloc); } for (args, 0..) |a, i| { packed_args[i] = self.marshalForeignArg(a, &tmp) catch return error.TypeError; } const argv = packed_args[0..args.len]; // Variadic foreign functions (declared `args: ..T`) must be // dispatched through C-variadic trampolines so the trailing // args land in the right place per the target's variadic // ABI. The fixed-arity trampolines would put them in arg // registers, and the callee would read garbage from the // stack. const fixed = func.params.len; const variadic = func.is_variadic and args.len > fixed; const ret = func.ret; if (ret == .void) { if (variadic) { host_ffi.callVoidRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime; } else { host_ffi.callVoidRet(symbol, argv) catch return error.CannotEvalComptime; } return .void_val; } if (ret == .s8 or ret == .s16 or ret == .s32 or ret == .s64 or ret == .u8 or ret == .u16 or ret == .u32 or ret == .u64 or ret == .usize or ret == .isize) { const r = if (variadic) host_ffi.callIntRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime else host_ffi.callIntRet(symbol, argv) catch return error.CannotEvalComptime; return Value{ .int = r }; } if (ret == .bool) { const r = if (variadic) host_ffi.callIntRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime else host_ffi.callIntRet(symbol, argv) catch return error.CannotEvalComptime; return Value{ .boolean = r != 0 }; } const r = if (variadic) host_ffi.callPtrRetVar(symbol, fixed, argv) catch return error.CannotEvalComptime else host_ffi.callPtrRet(symbol, argv) catch return error.CannotEvalComptime; return Value{ .int = @bitCast(@as(u64, r)) }; } pub fn call(self: *Interpreter, func_id: FuncId, args: []const Value) InterpError!Value { if (self.call_depth >= self.max_call_depth) return error.StackOverflow; self.call_depth += 1; defer self.call_depth -= 1; const func = self.module.getFunction(func_id); if (func.is_extern or func.blocks.items.len == 0) { // Dispatch to host libc via dlsym. Lets `#run` (and the // post-link bundler) call ordinary foreign symbols like // `puts`, `getenv`, `posix_spawn`, etc. return self.callForeign(func, args); } // Track the sx call chain for `trace.print_interpreter_frames()`. self.call_chain.append(self.alloc, func_id) catch {}; defer _ = self.call_chain.pop(); // Compute total refs: params + all instructions across all blocks var total_refs: u32 = @intCast(func.params.len); for (func.blocks.items) |blk| { total_refs += @intCast(blk.insts.items.len); } var frame = Frame.initSized(self.alloc, total_refs); defer frame.deinit(); // Implicit-context bootstrap: when an entry point with implicit // ctx is called without an explicit ctx arg, materialise the // default context in a fresh slot and bind slot_ptr(0) to ref 0. // This is the interp-side equivalent of FFI-inbound wrappers // installing `&__sx_default_context` at function entry. var skip_first: u32 = 0; if (func.has_implicit_ctx and args.len + 1 == func.params.len) { const ctx_val = self.defaultContextValue(); const slot = frame.allocSlot(self.alloc); frame.storeSlot(slot, ctx_val); frame.setRef(0, .{ .slot_ptr = slot }); skip_first = 1; } // Bind parameters as initial refs (indices skip_first..N-1) for (args, 0..) |arg, i| { frame.setRef(@intCast(i + skip_first), arg); } // Start at the entry block (index 0) var current_block: BlockId = BlockId.fromIndex(0); var block_args: []const Value = &.{}; while (true) { const block_idx = current_block.index(); const block = &func.blocks.items[block_idx]; var ref_counter: u32 = block.first_ref; // Bind block params (block_param instructions handle this, but we // also need to pre-set the values for them) for (block_args) |_| { // block_param instructions will read from frame refs when executed // The block_param instruction itself produces the value } for (block.insts.items) |*instruction| { // Special handling for block_param: bind the arg value if (instruction.op == .block_param) { const bp = instruction.op.block_param; if (bp.param_index < block_args.len) { frame.setRef(ref_counter, block_args[bp.param_index]); } ref_counter += 1; continue; } const result = self.execInst(instruction, &frame, ¤t_block, &block_args) catch |err| { if (last_bail_op == null) { last_bail_op = @tagName(instruction.op); last_bail_file = func.source_file; last_bail_offset = instruction.span.start; } return err; }; switch (result) { .value => |val| { frame.setRef(ref_counter, val); ref_counter += 1; }, .branch => { ref_counter += 1; // terminator consumes a ref slot break; }, .ret_val => |val| return val, .ret_nothing => return .void_val, } } else { // Fell through the block with no terminator — treat as implicit return void return .void_val; } } } const ExecResult = union(enum) { value: Value, branch, ret_val: Value, ret_nothing, }; fn execInst(self: *Interpreter, instruction: *const Inst, frame: *Frame, current_block: *BlockId, block_args: *[]const Value) InterpError!ExecResult { const op = instruction.op; switch (op) { // ── Constants ─────────────────────────────────────── .const_int => |v| return .{ .value = .{ .int = v } }, .const_float => |v| return .{ .value = .{ .float = v } }, .const_bool => |v| return .{ .value = .{ .boolean = v } }, .const_string => |sid| return .{ .value = .{ .string = self.module.types.getString(sid) } }, .const_null => return .{ .value = .null_val }, .const_undef => return .{ .value = .undef }, .is_comptime => return .{ .value = .{ .boolean = true } }, .interp_print_frames => { self.printInterpFrames(); return .{ .value = .void_val }; }, .trace_frame => { // Comptime frame: pack (func_id, span.start) so the slice-3b // resolver can recover file:line:col via the IR/source tables. // The interp never produces a `Frame*` — only the compiled // backend does — so this stays a packed id, never a pointer. const fid: u64 = if (self.call_chain.items.len > 0) self.call_chain.items[self.call_chain.items.len - 1].index() else 0; const packed_frame: u64 = (fid << 32) | @as(u64, instruction.span.start); return .{ .value = .{ .int = @bitCast(packed_frame) } }; }, .trace_resolve => |u| { // Unpack the comptime frame `(func_id << 32 | span.start)` and // resolve it to a `Frame { file, line, col, func }` aggregate. const raw: u64 = @bitCast(frame.getRef(u.operand).asInt() orelse 0); const fid: u32 = @intCast(raw >> 32); const offset: u32 = @truncate(raw); const func = self.module.getFunction(FuncId.fromIndex(fid)); const func_name = self.module.types.getString(func.name); const file_full = func.source_file orelse ""; const file = std.fs.path.basename(file_full); var line: i64 = 1; var col: i64 = 1; var line_text: []const u8 = ""; if (self.source_map) |sm| { if (sm.get(file_full)) |src| { const loc = errors.SourceLoc.compute(src, offset); line = @intCast(loc.line); col = @intCast(loc.col); line_text = errors.lineAt(src, offset); } } const fields = self.alloc.alloc(Value, 5) catch return .{ .value = .undef }; fields[0] = .{ .string = file }; fields[1] = .{ .int = line }; fields[2] = .{ .int = col }; fields[3] = .{ .string = func_name }; fields[4] = .{ .string = line_text }; return .{ .value = .{ .aggregate = fields } }; }, .const_type => |tid| return .{ .value = .{ .type_tag = tid } }, // ── Arithmetic ────────────────────────────────────── .add => |b| return .{ .value = try self.evalArith(frame, b, .add) }, .sub => |b| return .{ .value = try self.evalArith(frame, b, .sub) }, .mul => |b| return .{ .value = try self.evalArith(frame, b, .mul) }, .div => |b| return .{ .value = try self.evalArith(frame, b, .div) }, .mod => |b| return .{ .value = try self.evalArith(frame, b, .mod) }, .neg => |u| { const val = frame.getRef(u.operand); return .{ .value = switch (val) { .int => |v| .{ .int = -v }, .float => |v| .{ .float = -v }, else => return typeErrorDetail("comptime unary `-`: operand is neither int nor float"), } }; }, // ── Comparison ────────────────────────────────────── .cmp_eq => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .eq) } }, .cmp_ne => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .ne) } }, .cmp_lt => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .lt) } }, .cmp_le => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .le) } }, .cmp_gt => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .gt) } }, .cmp_ge => |b| return .{ .value = .{ .boolean = try self.evalCmp(frame, b, .ge) } }, .str_eq => |b| { const lhs = frame.getRef(b.lhs); const rhs = frame.getRef(b.rhs); const ls = if (lhs == .string) lhs.string else ""; const rs = if (rhs == .string) rhs.string else ""; return .{ .value = .{ .boolean = std.mem.eql(u8, ls, rs) } }; }, .str_ne => |b| { const lhs = frame.getRef(b.lhs); const rhs = frame.getRef(b.rhs); const ls = if (lhs == .string) lhs.string else ""; const rs = if (rhs == .string) rhs.string else ""; return .{ .value = .{ .boolean = !std.mem.eql(u8, ls, rs) } }; }, // ── Logical ───────────────────────────────────────── .bool_and => |b| { const lhs = frame.getRef(b.lhs).asBool() orelse return error.TypeError; if (!lhs) return .{ .value = .{ .boolean = false } }; const rhs = frame.getRef(b.rhs).asBool() orelse return error.TypeError; return .{ .value = .{ .boolean = rhs } }; }, .bool_or => |b| { const lhs = frame.getRef(b.lhs).asBool() orelse return error.TypeError; if (lhs) return .{ .value = .{ .boolean = true } }; const rhs = frame.getRef(b.rhs).asBool() orelse return error.TypeError; return .{ .value = .{ .boolean = rhs } }; }, .bool_not => |u| { const val = frame.getRef(u.operand).asBool() orelse return error.TypeError; return .{ .value = .{ .boolean = !val } }; }, // ── Conversions ───────────────────────────────────── .widen, .narrow => |c| { const val = frame.getRef(c.operand); return .{ .value = val }; // comptime values don't truncate }, .bitcast => |c| { const val = frame.getRef(c.operand); // Loud-fail on `.type_tag → ` casts. A Type // value can flow through bitcast only to .any (Any-boxing) // or to itself; any other destination means the lowering // emitted a coercion that silently pretends the TypeId is // some other shape (e.g. an int, or a string). The most // likely site that would trip this: the `case type:` arm // of `any_to_string` in stdlib doing `xx val to string` — // which expects the value field to already be a string, // a leftover from the pre-`type_tag` era when Type values // were string-shaped. if (val == .type_tag) { const allowed = c.to == .any or c.to == c.from; if (!allowed) { return bailDetail("comptime bitcast: Type value cast to a non-Type runtime kind — most likely a stale `xx val to string` from the pre-type_tag era; use `type_name(val)` instead"); } } return .{ .value = val }; }, .int_to_float => |c| { const val = frame.getRef(c.operand); const i = val.asInt() orelse return error.TypeError; return .{ .value = .{ .float = @floatFromInt(i) } }; }, .float_to_int => |c| { const val = frame.getRef(c.operand); const f = val.asFloat() orelse return error.TypeError; return .{ .value = .{ .int = @intFromFloat(f) } }; }, // ── Memory (stack simulation) ─────────────────────── .alloca => { const slot = frame.allocSlot(self.alloc); return .{ .value = .{ .slot_ptr = slot } }; }, .load => |u| { const ptr = frame.getRef(u.operand); switch (ptr) { .slot_ptr => |slot| { const slot_val = frame.loadSlot(slot); // Check if this is a field pointer (from struct_gep) if (self.resolveFieldLoad(frame, slot_val)) |field_val| { return .{ .value = field_val }; } return .{ .value = slot_val }; }, // The implicit __sx_ctx arrives as an aggregate after // materializeCtxArg dereferences the caller's slot_ptr. // `load(ref_0)` then naturally yields the Context value. .aggregate => return .{ .value = ptr }, // Comptime load through a raw host pointer needs the // target IR type to know byte width — currently not // threaded into the .load op. Add it when a comptime // path hits this. .int => return bailDetail("comptime load through raw host pointer not supported (IR type width not threaded)"), .byte_ptr => return bailDetail("comptime load through raw byte pointer not supported"), .heap_ptr => return bailDetail("comptime load through interp heap pointer not supported"), else => return bailDetail("comptime load: unsupported pointer kind"), } }, .store => |s| { const ptr = frame.getRef(s.ptr); const val = frame.getRef(s.val); switch (ptr) { .slot_ptr => |slot| { const slot_val = frame.loadSlot(slot); // Check if this is a field pointer (from struct_gep) if (self.resolveFieldStore(frame, slot_val, val)) { // Field store handled } else { frame.storeSlot(slot, val); } }, .heap_ptr => |hp| { // Store a byte into heap memory (from index_gep on string) const byte: u8 = @intCast(@as(u64, @bitCast(val.asInt() orelse return error.TypeError)) & 0xFF); self.heapStoreByte(hp, byte); }, // Raw host pointer (from foreign call, e.g. libc_malloc). // `val_ty` carries the declared destination width so we // write exactly that many bytes — no neighbor clobber. .int => |p| { try storeAtRawPtr(self, p, val, s.val_ty); }, // Byte-granular pointer (from index_gep on a string). // Always a 1-byte store — matches the heap_ptr arm. .byte_ptr => |addr| { const byte: u8 = @intCast(@as(u64, @bitCast(val.asInt() orelse return error.TypeError)) & 0xFF); const dst: [*]u8 = @ptrFromInt(addr); dst[0] = byte; }, else => return bailDetail("comptime store: unsupported pointer kind"), } return .{ .value = .void_val }; }, // ── Struct ops ────────────────────────────────────── .struct_init => |agg| { const fields = self.alloc.alloc(Value, agg.fields.len) catch return error.CannotEvalComptime; for (agg.fields, 0..) |ref, i| { fields[i] = frame.getRef(ref); } return .{ .value = .{ .aggregate = fields } }; }, .struct_get => |fa| { var base = frame.getRef(fa.base); // Auto-deref slot_ptr → load the value if (base == .slot_ptr) { const loaded = frame.loadSlot(base.slot_ptr); if (self.resolveFieldLoad(frame, loaded)) |resolved| { base = resolved; } else { base = loaded; } } switch (base) { .aggregate => |fields| { if (fa.field_index >= fields.len) return error.OutOfBounds; return .{ .value = fields[fa.field_index] }; }, .string => |s| { // String as fat pointer: field 0 = ptr (string), field 1 = len if (fa.field_index == 0) return .{ .value = .{ .string = s } }; if (fa.field_index == 1) return .{ .value = .{ .int = @intCast(s.len) } }; return error.OutOfBounds; }, .int => |v| { // Scalar boxed as "struct" — field 0 is the value itself if (fa.field_index == 0) return .{ .value = .{ .int = v } }; return error.OutOfBounds; }, else => return typeErrorDetail("comptime struct_get: base has no fields (not an aggregate/string/int)"), } }, // ── Enum ops ──────────────────────────────────────── .enum_init => |ei| { if (ei.payload.isNone()) { return .{ .value = .{ .int = @intCast(ei.tag) } }; } else { const payload = frame.getRef(ei.payload); const fields = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime; fields[0] = .{ .int = @intCast(ei.tag) }; fields[1] = payload; return .{ .value = .{ .aggregate = fields } }; } }, .enum_tag => |u| { const val = frame.getRef(u.operand); switch (val) { .int => return .{ .value = val }, .aggregate => |fields| { if (fields.len == 0) return typeErrorDetail("comptime enum_tag: aggregate operand has zero fields"); return .{ .value = fields[0] }; }, else => return typeErrorDetail("comptime enum_tag: operand is neither an int (untagged enum) nor an aggregate (tagged union)"), } }, .enum_payload => |fa| { const base = frame.getRef(fa.base); switch (base) { .aggregate => |fields| { if (fa.field_index + 1 >= fields.len) return error.OutOfBounds; return .{ .value = fields[fa.field_index + 1] }; }, else => return typeErrorDetail("comptime enum_payload: base is not a tagged-union aggregate"), } }, // ── Optional ops ──────────────────────────────────── .optional_wrap => |u| { const val = frame.getRef(u.operand); return .{ .value = val }; // wrapped value is just the value }, .optional_unwrap => |u| { const val = frame.getRef(u.operand); if (val.isNull()) return error.TypeError; // unwrapping null return .{ .value = val }; }, .optional_has_value => |u| { const val = frame.getRef(u.operand); return .{ .value = .{ .boolean = !val.isNull() } }; }, .optional_coalesce => |b| { const lhs = frame.getRef(b.lhs); if (!lhs.isNull()) return .{ .value = lhs }; return .{ .value = frame.getRef(b.rhs) }; }, // ── Calls ─────────────────────────────────────────── .call => |c| { const args = self.alloc.alloc(Value, c.args.len) catch return error.CannotEvalComptime; defer self.alloc.free(args); for (c.args, 0..) |ref, i| { // Inline any slot_ptr field-refs in the caller's frame before // the value crosses the call boundary. slot_ptr indices are // frame-local; if a slice/aggregate carrying one is passed to // the callee, the callee would later resolve the index against // its own slot table and read garbage. args[i] = self.materializeForCall(frame, frame.getRef(ref)); } // The implicit __sx_ctx is logically a `*Context` but the // interp can't dereference cross-frame slot_ptrs. Materialise // args[0] to the loaded Context aggregate so the callee can // treat its slot 0 as the value directly. const callee_func = self.module.getFunction(c.callee); if (callee_func.has_implicit_ctx and args.len >= 1) { args[0] = self.materializeCtxArg(frame, args[0]); } const result = try self.call(c.callee, args); return .{ .value = result }; }, // The Obj-C runtime isn't available at comptime; any // `#objc_call` reached during `#run` execution can't // resolve. Fail fast so callers see a useful diagnostic. .objc_msg_send => return bailDetail("#objc_call not available at comptime (no Obj-C runtime)"), // Same story for JNI — no JVM at compile time. .jni_msg_send => return bailDetail("#jni_call not available at comptime (no JVM)"), // ── Block params ──────────────────────────────────── .block_param => { // Block params are pushed at the start of block execution. // This instruction is a no-op; the value was already pushed // during block arg binding. return .{ .value = .void_val }; }, // ── Terminators ───────────────────────────────────── .br => |b| { const args = self.alloc.alloc(Value, b.args.len) catch return error.CannotEvalComptime; for (b.args, 0..) |ref, i| { args[i] = frame.getRef(ref); } current_block.* = b.target; block_args.* = args; return .branch; }, .cond_br => |cb| { const cond = frame.getRef(cb.cond).asBool() orelse return error.TypeError; if (cond) { const args = self.alloc.alloc(Value, cb.then_args.len) catch return error.CannotEvalComptime; for (cb.then_args, 0..) |ref, i| { args[i] = frame.getRef(ref); } current_block.* = cb.then_target; block_args.* = args; } else { const args = self.alloc.alloc(Value, cb.else_args.len) catch return error.CannotEvalComptime; for (cb.else_args, 0..) |ref, i| { args[i] = frame.getRef(ref); } current_block.* = cb.else_target; block_args.* = args; } return .branch; }, .switch_br => |sb| { const operand = frame.getRef(sb.operand).asInt() orelse return error.TypeError; for (sb.cases) |case| { if (operand == case.value) { const args = self.alloc.alloc(Value, case.args.len) catch return error.CannotEvalComptime; for (case.args, 0..) |ref, i| { args[i] = frame.getRef(ref); } current_block.* = case.target; block_args.* = args; return .branch; } } // Default const args = self.alloc.alloc(Value, sb.default_args.len) catch return error.CannotEvalComptime; for (sb.default_args, 0..) |ref, i| { args[i] = frame.getRef(ref); } current_block.* = sb.default; block_args.* = args; return .branch; }, .ret => |u| { return .{ .ret_val = frame.getRef(u.operand) }; }, .ret_void => return .ret_nothing, .@"unreachable" => return error.Unreachable, // ── Builtin calls ────────────────────────────────── .call_builtin => |bi| { return self.execBuiltin(bi, frame, instruction.ty); }, // ── Compiler hook calls (#compiler methods) ──────── .compiler_call => |cc| { const name = self.module.types.getString(@enumFromInt(cc.name)); if (self.hooks.get(name)) |hook| { // Resolve args from Ref to Value var resolved_args = std.ArrayList(Value).empty; defer resolved_args.deinit(self.alloc); for (cc.args) |arg| { resolved_args.append(self.alloc, frame.getRef(arg)) catch return error.CannotEvalComptime; } if (self.build_config) |bc| { const result = hook(self, resolved_args.items, bc, self.alloc) catch return bailDetail("#compiler hook returned an error (see hook impl)"); return .{ .value = result }; } return .{ .value = .void_val }; } if (last_bail_detail == null) { // Capture which hook name failed so the host diag // surfaces "compiler_call: unknown hook 'X'" instead // of a bare CannotEvalComptime. last_bail_detail = "#compiler hook not registered (likely a target-specific BuildOptions setter)"; } return error.CannotEvalComptime; }, // ── Struct GEP (field pointer) ───────────────────── .struct_gep => |fa| { const base = frame.getRef(fa.base); switch (base) { .slot_ptr => |slot| { // Create a field-pointer: we encode as a slot_ptr with field info // When loading, we extract the field; when storing, we modify the field const field_slot = frame.allocSlot(self.alloc); // Store a field reference: { parent_slot, field_index } const field_ref = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime; field_ref[0] = .{ .int = @intCast(slot) }; field_ref[1] = .{ .int = @intCast(fa.field_index) }; frame.storeSlot(field_slot, .{ .aggregate = field_ref }); return .{ .value = .{ .slot_ptr = field_slot } }; }, // struct_gep through a raw host pointer requires the // struct's field-offset table — feasible via // `fa.base_type` but not currently wired. Add when a // comptime path hits this. .int => return bailDetail("comptime struct_gep through raw host pointer not supported"), .byte_ptr => return bailDetail("comptime struct_gep through raw byte pointer not supported"), .heap_ptr => return bailDetail("comptime struct_gep through interp heap pointer not supported"), else => return bailDetail("comptime struct_gep: unsupported pointer kind"), } }, // ── String/slice operations ──────────────────────── .index_get => |idx| { const base = frame.getRef(idx.lhs); const index_val = frame.getRef(idx.rhs); const i: usize = @intCast(index_val.asInt() orelse return error.TypeError); // Try as string value if (base.asString(self)) |s| { if (i >= s.len) return error.OutOfBounds; return .{ .value = .{ .int = s[i] } }; } // Try as aggregate array or slice switch (base) { .aggregate => |fields| { // Check for slice-like: {data_ptr, len} where data_ptr is slot_ptr if (fields.len == 2 and fields[1] == .int) { const data = fields[0]; if (data == .slot_ptr) { // The data field is a ptr — resolve through slots to get the array const arr = self.resolveSlotChain(frame, data); switch (arr) { .aggregate => |arr_fields| { if (i < arr_fields.len) return .{ .value = arr_fields[i] }; return error.OutOfBounds; }, else => {}, } } else if (data == .aggregate) { // Inline array data const arr_fields = data.aggregate; if (i < arr_fields.len) return .{ .value = arr_fields[i] }; return error.OutOfBounds; } } // Plain aggregate indexing if (i >= fields.len) return error.OutOfBounds; return .{ .value = fields[i] }; }, // Raw host pointer base — `buf[i]` reads one byte at // offset i. Matches the byte-addressed `index_gep` // semantics for the same shape. Used by comptime sx // code that walks libc-malloc'd buffers. .int => |p| { const src: [*]const u8 = @ptrFromInt(@as(usize, @bitCast(p))); return .{ .value = .{ .int = src[i] } }; }, .byte_ptr => |addr| { const src: [*]const u8 = @ptrFromInt(addr); return .{ .value = .{ .int = src[i] } }; }, else => return bailDetail("comptime index_get: unsupported base kind"), } }, .length => |u| { const val = frame.getRef(u.operand); if (val.asString(self)) |s| { return .{ .value = .{ .int = @intCast(s.len) } }; } switch (val) { .aggregate => |fields| { // For fat pointers {ptr, len}, len is field[1] if (fields.len == 2) { return .{ .value = fields[1] }; } return .{ .value = .{ .int = @intCast(fields.len) } }; }, else => return bailDetail("comptime .len: operand is neither a string nor an aggregate"), } }, .data_ptr => |u| { const val = frame.getRef(u.operand); switch (val) { .aggregate => |fields| { if (fields.len >= 1) return .{ .value = fields[0] }; return error.OutOfBounds; }, .string => return .{ .value = val }, else => return bailDetail("comptime .ptr: operand has no data field (not a string or slice aggregate)"), } }, .subslice => |sub| { const base = frame.getRef(sub.base); const lo_val = frame.getRef(sub.lo); const hi_val = frame.getRef(sub.hi); const lo: usize = @intCast(lo_val.asInt() orelse return error.TypeError); const hi: usize = @intCast(hi_val.asInt() orelse return error.TypeError); if (base.asString(self)) |s| { if (hi > s.len) return error.OutOfBounds; return .{ .value = .{ .string = s[lo..hi] } }; } return bailDetail("comptime subslice: base is not a string-backed value (slice over non-string aggregates not yet supported)"); }, // ── Addr/deref ───────────────────────────────────── .addr_of => |u| { const val = frame.getRef(u.operand); return .{ .value = val }; // pass through pointer-like values }, .deref => |u| { const val = frame.getRef(u.operand); switch (val) { .slot_ptr => |slot| return .{ .value = frame.loadSlot(slot) }, // Real raw-memory deref needs val's IR type for byte // width — not yet threaded. Erroring is safer than // returning the pointer-as-int unchanged, which // silently looks like a successful deref. .int => return bailDetail("comptime deref through raw host pointer not supported (IR type width not threaded)"), .byte_ptr => return bailDetail("comptime deref through raw byte pointer not supported"), .heap_ptr => return bailDetail("comptime deref through interp heap pointer not supported"), // Pre-dereferenced values that flow through deref as a // no-op: an aggregate/string already IS the loaded // value (lowering sometimes emits `deref(struct_val)` // where the struct was previously materialized in // place rather than via a slot). .aggregate, .string => return .{ .value = val }, // Null deref is UB at runtime; surface it at comptime // instead of silently producing a null again. .null_val => return bailDetail("comptime deref of null"), // Scalars / handles / undef aren't pointer-shaped — // dereffing them is a frontend bug. Bail rather than // returning the bare value (which looked like a // successful deref to callers). .boolean, .float, .func_ref, .closure, .type_tag, .void_val, .undef => return bailDetail("comptime deref: operand is not a pointer"), } }, // ── Bitwise operations ───────────────────────────── .bit_and => |b| { const lhs = frame.getRef(b.lhs).asInt() orelse return error.TypeError; const rhs = frame.getRef(b.rhs).asInt() orelse return error.TypeError; return .{ .value = .{ .int = lhs & rhs } }; }, .bit_or => |b| { const lhs = frame.getRef(b.lhs).asInt() orelse return error.TypeError; const rhs = frame.getRef(b.rhs).asInt() orelse return error.TypeError; return .{ .value = .{ .int = lhs | rhs } }; }, .bit_xor => |b| { const lhs = frame.getRef(b.lhs).asInt() orelse return error.TypeError; const rhs = frame.getRef(b.rhs).asInt() orelse return error.TypeError; return .{ .value = .{ .int = lhs ^ rhs } }; }, .bit_not => |u| { const val = frame.getRef(u.operand).asInt() orelse return error.TypeError; return .{ .value = .{ .int = ~val } }; }, .shl => |b| { const lhs = frame.getRef(b.lhs).asInt() orelse return error.TypeError; const rhs = frame.getRef(b.rhs).asInt() orelse return error.TypeError; const shift: u6 = @intCast(@min(rhs, 63)); return .{ .value = .{ .int = lhs << shift } }; }, .shr => |b| { const lhs = frame.getRef(b.lhs).asInt() orelse return error.TypeError; const rhs = frame.getRef(b.rhs).asInt() orelse return error.TypeError; const shift: u6 = @intCast(@min(rhs, 63)); return .{ .value = .{ .int = lhs >> shift } }; }, // ── Tuple ops (same as struct) ───────────────────── .tuple_init => |agg| { const fields = self.alloc.alloc(Value, agg.fields.len) catch return error.CannotEvalComptime; for (agg.fields, 0..) |ref, i| { fields[i] = frame.getRef(ref); } return .{ .value = .{ .aggregate = fields } }; }, .tuple_get => |fa| { const base = frame.getRef(fa.base); switch (base) { .aggregate => |fields| { if (fa.field_index >= fields.len) return error.OutOfBounds; return .{ .value = fields[fa.field_index] }; }, else => return error.TypeError, } }, // ── Box/unbox (Any type) ─────────────────────────── .box_any => |ba| { const val = frame.getRef(ba.operand); // Box as aggregate: { type_tag, value } — matches LLVM layout const fields = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime; fields[0] = .{ .int = @intFromEnum(ba.source_type) }; fields[1] = val; return .{ .value = .{ .aggregate = fields } }; }, .unbox_any => |ua| { const val = frame.getRef(ua.operand); switch (val) { .aggregate => |fields| { // Value is at field 1 in { tag, value } layout if (fields.len >= 2) return .{ .value = fields[1] }; if (fields.len >= 1) return .{ .value = fields[0] }; return error.OutOfBounds; }, // Any-typed comptime values flow through box_any first, // which always wraps as an aggregate. If we reach here // with a scalar / undef / null, the IR shape upstream // diverged from the box_any contract — bail loudly so // the offending box_any site shows in the diagnostic. .int, .float, .boolean, .string, .null_val, .undef => return bailDetail("comptime unbox_any: operand is a bare scalar (expected { tag, value } aggregate from box_any)"), .void_val => return bailDetail("comptime unbox_any: operand is void_val"), .slot_ptr, .heap_ptr, .byte_ptr, .func_ref, .closure, .type_tag => return bailDetail("comptime unbox_any: operand is a pointer/handle (expected { tag, value } aggregate)"), } }, // ── Reflection ───────────────────────────────────── .field_name_get => |fr| { const idx_val = frame.getRef(fr.index); const idx: usize = @intCast(switch (idx_val) { .int => |i| i, else => return bailDetail("comptime field_name(T, i): index operand is not an int"), }); const info = self.module.types.get(fr.struct_type); const fields = switch (info) { .@"struct" => |s| s.fields, .@"union" => |u| u.fields, .tagged_union => |u| u.fields, else => return bailDetail("comptime field_name(T, i): T is not a struct/union/tagged_union"), }; if (idx >= fields.len) return error.OutOfBounds; const name = self.module.types.getString(fields[idx].name); return .{ .value = .{ .string = name } }; }, .error_tag_name_get => |u| { const tag_val = frame.getRef(u.operand); const id: u32 = @intCast(switch (tag_val) { .int => |i| i, else => return bailDetail("comptime error_tag_name(e): operand is not an integer tag id"), }); return .{ .value = .{ .string = self.module.types.tags.getName(id) } }; }, .field_value_get => |fr| { const base_val = frame.getRef(fr.base); const idx_val = frame.getRef(fr.index); const idx: usize = @intCast(switch (idx_val) { .int => |i| i, else => return bailDetail("comptime field_value(s, i): index operand is not an int"), }); switch (base_val) { .aggregate => |agg| { if (idx >= agg.len) return error.OutOfBounds; // Box as Any: { value, type_tag } const info = self.module.types.get(fr.struct_type); const fields = switch (info) { .@"struct" => |s| s.fields, .@"union" => |u| u.fields, .tagged_union => |u| u.fields, else => return bailDetail("comptime field_value(s, i): s's type is not a struct/union/tagged_union"), }; const field_ty_tag: i64 = if (idx < fields.len) @intFromEnum(fields[idx].ty) else 0; const boxed = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime; boxed[0] = agg[idx]; boxed[1] = .{ .int = field_ty_tag }; return .{ .value = .{ .aggregate = boxed } }; }, else => return bailDetail("comptime field_value(s, i): s is not an aggregate Value (struct values must be materialized as aggregates at comptime)"), } }, // ── Global access ────────────────────────────────── .global_get => |gid| { const val = try self.getGlobal(gid); return .{ .value = val }; }, .global_addr => |gid| { // The implicit-context default global is the only global // whose address sees runtime use. Return the Context // aggregate directly so `load(args[0])` yields it via the // aggregate-passthrough branch of the `.load` handler. if (gid.index() < self.module.globals.items.len) { const global = &self.module.globals.items[gid.index()]; const name = self.module.types.getString(global.name); if (std.mem.eql(u8, name, "__sx_default_context")) { return .{ .value = self.defaultContextValue() }; } } return bailDetail("comptime global_addr: only `&__sx_default_context` is currently materialised at comptime"); }, .func_ref => |fid| { return .{ .value = .{ .func_ref = fid } }; }, .global_set => |gs| { const val = frame.getRef(gs.value); self.global_values.put(gs.global.index(), val) catch {}; return .{ .value = .void_val }; }, // ── Index GEP (array element pointer) ───────────── .index_gep => |b| { const base = frame.getRef(b.lhs); const idx = frame.getRef(b.rhs); switch (base) { .slot_ptr => |slot| { // Create an indexed element pointer: { parent_slot, index, is_index_gep=1 } const field_slot = frame.allocSlot(self.alloc); const ref = self.alloc.alloc(Value, 3) catch return error.CannotEvalComptime; ref[0] = .{ .int = @intCast(slot) }; ref[1] = idx; ref[2] = .{ .int = 1 }; // marker: this is index_gep, not struct_gep frame.storeSlot(field_slot, .{ .aggregate = ref }); return .{ .value = .{ .slot_ptr = field_slot } }; }, .aggregate => |fields| { // String/slice aggregate {data_ptr, len} — compute data_ptr + index if (fields.len >= 2) { const data_ptr = fields[0]; const offset = idx.asInt() orelse return error.TypeError; switch (data_ptr) { .heap_ptr => |hp| { return .{ .value = .{ .heap_ptr = .{ .id = hp.id, .offset = hp.offset + @as(u32, @intCast(offset)), } } }; }, // Raw host pointer (from foreign call return, // e.g. libc_malloc). Byte-addressed offset // matches the heap_ptr branch above — both // are u8-granular for sx's string/slice ops. // Producing `.byte_ptr` makes store-through // this address write a single byte. .int => |p| { return .{ .value = .{ .byte_ptr = @intCast(p + offset) } }; }, else => {}, } } return bailDetail("comptime index_gep: unsupported aggregate-base shape (expected {data_ptr, len} with heap_ptr or int data field)"); }, .string => |s| { // String literal — copy to heap and return heap_ptr at offset const offset: usize = @intCast(@as(u64, @bitCast(idx.asInt() orelse return error.TypeError))); const hp = self.heapAlloc(s.len); self.heapMemcpy(hp, s, s.len); return .{ .value = .{ .heap_ptr = .{ .id = hp.id, .offset = @intCast(offset), } } }; }, // Raw host pointer base — byte-addressed offset. // Element size > 1 would silently mis-index; document // the assumption. Callers stride past byte granularity // must wrap the pointer in an aggregate so the // {data_ptr, len} branch fires (which is also // byte-addressed today — fix here when needed). .int => |p| { const offset = idx.asInt() orelse return error.TypeError; return .{ .value = .{ .int = p + offset } }; }, else => return bailDetail("comptime index_gep: unsupported base kind"), } }, // ── Array to slice ──────────────────────────────── .array_to_slice => |u| { const val = frame.getRef(u.operand); switch (val) { .aggregate => |fields| { // Convert array aggregate to slice: { aggregate_ref, len } const slice = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime; slice[0] = val; // the array data slice[1] = .{ .int = @intCast(fields.len) }; return .{ .value = .{ .aggregate = slice } }; }, .slot_ptr => |slot| { const arr = frame.loadSlot(slot); switch (arr) { .aggregate => |fields| { const slice = self.alloc.alloc(Value, 2) catch return error.CannotEvalComptime; slice[0] = arr; slice[1] = .{ .int = @intCast(fields.len) }; return .{ .value = .{ .aggregate = slice } }; }, else => return bailDetail("comptime array_to_slice: slot-backed value is not an aggregate"), } }, else => return bailDetail("comptime array_to_slice: operand is neither an aggregate nor a slot pointer"), } }, // ── Call indirect (function pointer) ────────────── .call_indirect => |ci| { const callee = frame.getRef(ci.callee); switch (callee) { .func_ref => |fid| { const args = self.alloc.alloc(Value, ci.args.len) catch return error.CannotEvalComptime; defer self.alloc.free(args); for (ci.args, 0..) |ref, i| { args[i] = self.materializeForCall(frame, frame.getRef(ref)); } const target = self.module.getFunction(fid); if (target.has_implicit_ctx and args.len >= 1) { args[0] = self.materializeCtxArg(frame, args[0]); } const result = try self.call(fid, args); return .{ .value = result }; }, else => return bailDetail("comptime call_indirect: callee is not a func_ref Value (raw fn-pointers from foreign calls aren't dispatchable in interp)"), } }, // Type-as-value sentinel emitted for the type arg of // `cast(T) val`. Result is never read (the cast lowering // consumes the type from the AST, not the IR Ref), so an // undef value is sufficient — matches the LLVM emitter. .placeholder => return .{ .value = .undef }, // ── Not yet evaluable at comptime ────────────────── .call_closure => return bailDetail("comptime call_closure not yet implemented (closure trampoline ABI threading required)"), .closure_create => return bailDetail("comptime closure_create not yet implemented"), .union_get => return bailDetail("comptime union_get not yet implemented"), .union_gep => return bailDetail("comptime union_gep not yet implemented"), .vec_splat => return bailDetail("comptime vec_splat not yet implemented"), .vec_extract => return bailDetail("comptime vec_extract not yet implemented"), .vec_insert => return bailDetail("comptime vec_insert not yet implemented"), } } // ── Arithmetic helpers ────────────────────────────────────────── const ArithOp = enum { add, sub, mul, div, mod }; fn evalArith(self: *Interpreter, frame: *Frame, b: inst_mod.BinOp, comptime aop: ArithOp) InterpError!Value { _ = self; const lhs = frame.getRef(b.lhs); const rhs = frame.getRef(b.rhs); // Both int if (lhs.asInt()) |li| { if (rhs.asInt()) |ri| { return .{ .int = switch (aop) { .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), } }; } } // Both float (or int promoted to float) if (lhs.asFloat()) |lf| { if (rhs.asFloat()) |rf| { return .{ .float = switch (aop) { .add => lf + rf, .sub => lf - rf, .mul => lf * rf, .div => if (rf == 0.0) return error.DivisionByZero else lf / rf, .mod => @mod(lf, rf), } }; } } return typeErrorDetail("comptime arithmetic: operand pair is neither both-int nor both-float-coercible"); } // ── Comparison helpers ────────────────────────────────────────── const CmpOp = enum { eq, ne, lt, le, gt, ge }; fn evalCmp(self: *Interpreter, frame: *Frame, b: inst_mod.BinOp, comptime cop: CmpOp) InterpError!bool { _ = self; const lhs = frame.getRef(b.lhs); const rhs = frame.getRef(b.rhs); // Both int if (lhs.asInt()) |li| { if (rhs.asInt()) |ri| { return switch (cop) { .eq => li == ri, .ne => li != ri, .lt => li < ri, .le => li <= ri, .gt => li > ri, .ge => li >= ri, }; } } // Both float if (lhs.asFloat()) |lf| { if (rhs.asFloat()) |rf| { return switch (cop) { .eq => lf == rf, .ne => lf != rf, .lt => lf < rf, .le => lf <= rf, .gt => lf > rf, .ge => lf >= rf, }; } } // Bool equality if (lhs.asBool()) |lb| { if (rhs.asBool()) |rb| { return switch (cop) { .eq => lb == rb, .ne => lb != rb, else => return error.TypeError, }; } } // Type-as-value equality. Compares TypeIds structurally. // `.type_tag` vs `.int(N)` deliberately does NOT compare — // a Type is not an int even if the underlying enum value // matches; falls through to the typeErrorDetail below. if (lhs.asTypeId()) |la| { if (rhs.asTypeId()) |ra| { return switch (cop) { .eq => la == ra, .ne => la != ra, else => return error.TypeError, }; } } return typeErrorDetail("comptime comparison: operand pair has no shared comparable shape (int/float/bool/string/type)"); } // ── Slot chain resolution ──────────────────────────────────── /// Walk an aggregate Value and rewrite any embedded `slot_ptr` that points /// to a field-ref slot in `frame` (the marker shape `{parent_slot, idx, ..}` /// emitted by `struct_gep` / `index_gep`) into the resolved parent value. /// Slot indices are frame-local; a slice passed across a call would otherwise /// read its data_ptr out of the callee's slot table. /// Resolve the implicit __sx_ctx arg to its loaded Context value so /// callees can treat their own slot 0 as the aggregate directly /// (no cross-frame slot_ptr indirection). fn materializeCtxArg(self: *Interpreter, frame: *Frame, val: Value) Value { _ = self; return switch (val) { .slot_ptr => |slot| frame.loadSlot(slot), else => val, }; } fn materializeForCall(self: *Interpreter, frame: *Frame, val: Value) Value { switch (val) { .aggregate => |fields| { const new_fields = self.alloc.alloc(Value, fields.len) catch return val; for (fields, 0..) |f, i| { new_fields[i] = self.materializeForCall(frame, f); } return .{ .aggregate = new_fields }; }, .slot_ptr => |slot| { const stored = frame.loadSlot(slot); if (stored == .aggregate) { const ref_fields = stored.aggregate; if (ref_fields.len >= 2) { const parent_slot_val = ref_fields[0].asInt() orelse return val; if (ref_fields[1].asInt() == null) return val; const parent_slot: u32 = @intCast(parent_slot_val); const parent = frame.loadSlot(parent_slot); return self.materializeForCall(frame, parent); } } return val; }, else => return val, } } /// Follow a slot_ptr through field-pointer / index-gep chains /// to get the underlying value. Handles nested dereferences. fn resolveSlotChain(self: *Interpreter, frame: *Frame, val: Value) Value { _ = self; var current = val; var depth: u32 = 0; while (depth < 16) : (depth += 1) { switch (current) { .slot_ptr => |slot| { const stored = frame.loadSlot(slot); switch (stored) { .aggregate => |ref_fields| { if (ref_fields.len >= 2) { // Field-pointer or index-gep reference: {parent_slot, index, [marker]} const parent_slot_val = ref_fields[0].asInt() orelse return stored; const parent_slot: u32 = @intCast(parent_slot_val); const parent = frame.loadSlot(parent_slot); return parent; // Return the parent array/struct } return stored; }, .slot_ptr => { current = stored; continue; }, else => return stored, } }, else => return current, } } return current; } // ── Constant → Value conversion ───────────────────────────── fn constToValue(self: *Interpreter, cv: inst_mod.ConstantValue) Value { return switch (cv) { .int => |v| .{ .int = v }, .float => |v| .{ .float = v }, .boolean => |v| .{ .boolean = v }, .string => |sid| .{ .string = self.module.types.getString(sid) }, .null_val => .null_val, .undef, .zeroinit => .undef, .aggregate => |items| { const fields = self.alloc.alloc(Value, items.len) catch return .undef; for (items, 0..) |item, i| { fields[i] = self.constToValue(item); } return .{ .aggregate = fields }; }, .vtable => |func_ids| { // Vtable is a struct of function refs — represent as aggregate of func_ref values const fields = self.alloc.alloc(Value, func_ids.len) catch return .undef; for (func_ids, 0..) |fid, i| { fields[i] = .{ .func_ref = fid }; } return .{ .aggregate = fields }; }, .func_ref => |fid| .{ .func_ref = fid }, }; } // ── Field pointer helpers (for struct_gep load/store) ───────── /// Check if a slot value is a field pointer { parent_slot, field_index [, is_index_gep] }. /// If so, load the parent aggregate and return the field value. fn resolveFieldLoad(self: *Interpreter, frame: *Frame, slot_val: Value) ?Value { _ = self; switch (slot_val) { .aggregate => |fields| { if (fields.len >= 2) { const parent_slot_val = fields[0].asInt() orelse return null; const field_idx_val = fields[1].asInt() orelse return null; // A real field-pointer's parent_slot is a small frame // index; a struct aggregate whose first field happens // to be a wide integer (e.g. a stored pointer-as-int // or a u64) would otherwise mis-trigger this branch. if (parent_slot_val < 0 or parent_slot_val > std.math.maxInt(u32)) return null; if (field_idx_val < 0 or field_idx_val > std.math.maxInt(u32)) return null; const parent_slot: u32 = @intCast(parent_slot_val); const field_idx: usize = @intCast(field_idx_val); const parent = frame.loadSlot(parent_slot); switch (parent) { .aggregate => |parent_fields| { if (field_idx < parent_fields.len) return parent_fields[field_idx]; }, .string => |s| { // String fat pointer: field 0 = ptr (as string), field 1 = len if (field_idx == 0) return .{ .string = s }; if (field_idx == 1) return .{ .int = @intCast(s.len) }; }, else => {}, } } }, else => {}, } return null; } /// Check if a slot value is a field pointer. If so, modify the field /// in the parent aggregate. Returns true if handled. fn resolveFieldStore(self: *Interpreter, frame: *Frame, slot_val: Value, new_val: Value) bool { switch (slot_val) { .aggregate => |fields| { if (fields.len >= 2) { const parent_slot_val = fields[0].asInt() orelse return false; const field_idx_val = fields[1].asInt() orelse return false; // Same field-pointer-vs-real-struct disambiguation as // resolveFieldLoad — a wide integer in fields[0] is a // stored pointer, not a frame index. if (parent_slot_val < 0 or parent_slot_val > std.math.maxInt(u32)) return false; if (field_idx_val < 0 or field_idx_val > std.math.maxInt(u32)) return false; const parent_slot: u32 = @intCast(parent_slot_val); const field_idx: usize = @intCast(field_idx_val); const parent = frame.loadSlot(parent_slot); switch (parent) { .aggregate => |parent_fields| { const new_len = @max(field_idx + 1, parent_fields.len); const new_fields = self.alloc.alloc(Value, new_len) catch return false; @memcpy(new_fields[0..parent_fields.len], parent_fields); for (new_fields[parent_fields.len..]) |*f| f.* = .undef; new_fields[field_idx] = new_val; frame.storeSlot(parent_slot, .{ .aggregate = new_fields }); return true; }, .undef => { // Initialize a new aggregate from undef const num_fields: usize = @max(field_idx + 1, 2); // at least 2 for strings const new_fields = self.alloc.alloc(Value, num_fields) catch return false; for (new_fields) |*f| f.* = .undef; new_fields[field_idx] = new_val; frame.storeSlot(parent_slot, .{ .aggregate = new_fields }); return true; }, else => {}, } } }, else => {}, } return false; } // ── Builtin call dispatch ────────────────────────────────────── fn execBuiltin(self: *Interpreter, bi: inst_mod.BuiltinCall, frame: *Frame, _: TypeId) InterpError!ExecResult { const result = self.execBuiltinInner(bi, frame) catch |err| { if (last_bail_builtin == null) last_bail_builtin = @tagName(bi.builtin); return err; }; return result; } fn execBuiltinInner(self: *Interpreter, bi: inst_mod.BuiltinCall, frame: *Frame) InterpError!ExecResult { switch (bi.builtin) { .out => { const str_val = frame.getRef(bi.args[0]); if (str_val.asString(self)) |s| { self.output.appendSlice(self.alloc, s) catch {}; } return .{ .value = .void_val }; }, .size_of => { // Return a default size (8 bytes for most types) return .{ .value = .{ .int = 8 } }; }, .align_of => { return .{ .value = .{ .int = 8 } }; }, .sqrt => { const val = frame.getRef(bi.args[0]); const f = val.asFloat() orelse return error.TypeError; return .{ .value = .{ .float = @sqrt(f) } }; }, .sin => { const val = frame.getRef(bi.args[0]); const f = val.asFloat() orelse return error.TypeError; return .{ .value = .{ .float = @sin(f) } }; }, .cos => { const val = frame.getRef(bi.args[0]); const f = val.asFloat() orelse return error.TypeError; return .{ .value = .{ .float = @cos(f) } }; }, .floor => { const val = frame.getRef(bi.args[0]); const f = val.asFloat() orelse return error.TypeError; return .{ .value = .{ .float = @floor(f) } }; }, .cast => return bailDetail("comptime #builtin cast: handled at lowering, not the interp (you reached this if a #builtin cast leaked into IR)"), .type_of => return bailDetail("comptime #builtin type_of: handled at lowering, not the interp"), .alloc => return bailDetail("comptime #builtin alloc unused (use context.allocator.alloc)"), .dealloc => return bailDetail("comptime #builtin dealloc unused (use context.allocator.dealloc)"), // ── Comptime reflection (Type-as-Value path) ───────── // These are only reached when lower.zig emitted a real // builtin_call — i.e. the type argument was NOT statically // resolvable (e.g. inside a builder body where `args[i]` is // a `.type_tag(TypeId)` Value bound at interp time). Static // calls fold to `const_string` / `const_bool` at lower time // and never hit this dispatch. .type_name => { if (bi.args.len < 1) return bailDetail("comptime type_name: missing argument"); const arg = frame.getRef(bi.args[0]); // Accept either a bare `.type_tag` Value (the // comptime-native form) or an Any-boxed Type // (`.aggregate { tag: int, value: .type_tag }`) // — the latter shape is what `box_any` produces // when const_type values flow through a `.any`-typed // slice or struct field. const tid = blk: { if (arg.asTypeId()) |t| break :blk t; if (arg == .aggregate) { const fields = arg.aggregate; if (fields.len >= 2) { if (fields[1].asTypeId()) |t| break :blk t; } } return bailDetail("comptime type_name: argument is not a Type value (expected `.type_tag` or Any-boxed Type)"); }; const name = self.module.types.typeName(tid); // Copy the slice into the interp's allocator so it // outlives any TypeTable churn during the rest of the // interp execution. The TypeTable's strings are stable // for now but copying is the safe pattern. const owned = self.alloc.dupe(u8, name) catch return error.CannotEvalComptime; return .{ .value = .{ .string = owned } }; }, .type_eq => { if (bi.args.len < 2) return bailDetail("comptime type_eq: needs two Type arguments"); const a = frame.getRef(bi.args[0]).asTypeId() orelse return bailDetail("comptime type_eq: first argument is not a Type value"); const b = frame.getRef(bi.args[1]).asTypeId() orelse return bailDetail("comptime type_eq: second argument is not a Type value"); return .{ .value = .{ .boolean = a == b } }; }, .has_impl => { // has_impl at interp time needs access to the host's // protocol-registration maps (protocol_thunk_map + // param_impl_map). These live on `Lowering`, not on // the Interpreter. Plumbing a queryable snapshot is // its own slice — until then, bail loudly so the user // gets a clear "not yet wired" message instead of a // silent false. Static-arg has_impl still works via // `tryConstBoolCondition` in lower.zig. return bailDetail("comptime has_impl: interp-time evaluation not yet wired (use static type args for now — they fold at lower time)"); }, } } }; // ── Frame ─────────────────────────────────────────────────────────────── // Holds SSA values (by Ref index) and local mutable slots (for alloca). const Frame = struct { refs: []Value, ref_alloc: Allocator, slots: std.ArrayList(Value), /// Create a frame pre-allocated with `num_refs` slots (all undef). fn initSized(alloc: Allocator, num_refs: u32) Frame { const refs = alloc.alloc(Value, num_refs) catch unreachable; @memset(refs, .undef); return .{ .refs = refs, .ref_alloc = alloc, .slots = std.ArrayList(Value).empty, }; } fn deinit(self: *Frame) void { self.ref_alloc.free(self.refs); } fn setRef(self: *Frame, idx: u32, val: Value) void { if (idx < self.refs.len) { self.refs[idx] = val; } } fn getRef(self: *const Frame, ref: Ref) Value { if (ref.isNone()) return .void_val; const idx = ref.index(); if (idx >= self.refs.len) return .undef; return self.refs[idx]; } fn allocSlot(self: *Frame, alloc: Allocator) u32 { const idx: u32 = @intCast(self.slots.items.len); self.slots.append(alloc, .undef) catch unreachable; return idx; } fn loadSlot(self: *const Frame, slot: u32) Value { if (slot >= self.slots.items.len) return .undef; return self.slots.items[slot]; } fn storeSlot(self: *Frame, slot: u32, val: Value) void { if (slot < self.slots.items.len) { self.slots.items[slot] = val; } } };