Files
sx/src/ir/interp.zig
agra f2b3868579 mem: thread val_ty through inst.Store; per-width comptime regression test
The interp's `storeAtRawPtr` used to write 8 bytes from a `.int` /
`.float` Value regardless of the destination's declared width. The
Value tag flattens s8..s64/u*/pointer all to `.int`, so it can't
disambiguate widths on its own — every store risked clobbering up to
7 neighbor bytes if the actual IR type was sub-8.

Fix:

- `inst.Store` gains `val_ty: TypeId` (defaults to `.void` for
  backward compat with the LLVM emitter, which doesn't read it).
- `builder.store` captures `getRefType(val)` at emit time.
- `storeAtRawPtr` now takes `val_ty`, looks up
  `types.typeSizeBytes(val_ty)`, and writes exactly that many bytes:
  `.int` → width bytes of the i64 representation (1..8),
  `.float` → 4 (f32 round-trip via @floatCast) or 8,
  `.boolean` → 1 (zeros higher width bytes when destination is wider),
  `.null_val` → width bytes of zero. Width outside the expected band
  bails with a clear diagnostic.

Regression test: `examples/132-comptime-typed-store-widths.sx`. For
every primitive type (u8/u16/u32/u64, s8/s16/s32/s64, bool, f32, f64),
the test:

1. Allocates a 32-byte libc buffer through `context.allocator`.
2. Fills with sentinel byte 0xAA.
3. Writes ONE typed value at offset 8.
4. Sums every byte back.
5. Compares the runtime checksum (LLVM-emitted store, already
   correct) against a comptime checksum baked via `#run`.

Mismatch = neighbor clobber. The test exits non-zero with a per-width
"FAIL u8: comptime=X runtime=Y" line so future regressions surface
the offending width.

Also wired:

- Interp's `index_get` gains `.int` / `.byte_ptr` base arms — `buf[i]`
  through a raw libc-malloc'd pointer reads one byte at offset i.
  Used by the new test's `sum_bytes` loop; previously bailed at
  `op=index_get`.
- `emit_llvm`'s comptime-init catch block prints a real diagnostic
  instead of swallowing the error and filling the const with zero.
  Stale bail state from a previous init is cleared before each call.

154/154 example tests pass (the new test + the existing 153). Chess
still green on macOS / iOS sim / Android.
2026-05-25 11:41:59 +03:00

1762 lines
83 KiB
Zig

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 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;
}
/// 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,
// 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
// <file>:<offset>" 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,
};
}
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.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] = &.{};
}
}
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"),
};
}
fn callForeign(self: *Interpreter, func: *const inst_mod.Function, args: []const Value) InterpError!Value {
const name = self.module.types.getString(func.name);
const symbol = (host_ffi.lookupSymbol(self.alloc, name) catch return error.CannotEvalComptime) orelse {
return error.CannotEvalComptime;
};
var packed_args: [8]usize = undefined;
if (args.len > packed_args.len) return error.CannotEvalComptime;
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);
}
// 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, &current_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 },
// ── 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 error.TypeError,
} };
},
// ── 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);
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 error.TypeError,
}
},
// ── 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 error.TypeError;
return .{ .value = fields[0] };
},
else => return error.TypeError,
}
},
.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 error.TypeError,
}
},
// ── 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 error.CannotEvalComptime,
// Same story for JNI — no JVM at compile time.
.jni_msg_send => return error.CannotEvalComptime,
// ── 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,
// ── Heap operations ─────────────────────────────────
.heap_alloc => |u| {
const size_val = frame.getRef(u.operand);
const size: usize = @intCast(size_val.asInt() orelse return error.TypeError);
const hp = self.heapAlloc(size);
return .{ .value = .{ .heap_ptr = hp } };
},
.heap_free => |u| {
const ptr = frame.getRef(u.operand);
switch (ptr) {
.heap_ptr => |hp| self.heapFree(hp),
else => {},
}
return .{ .value = .void_val };
},
// ── 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 error.CannotEvalComptime;
return .{ .value = result };
}
return .{ .value = .void_val };
}
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 error.CannotEvalComptime,
}
},
.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 error.CannotEvalComptime,
}
},
.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 error.CannotEvalComptime;
},
// ── 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"),
// Other Value kinds (aggregate, string, int constants
// used as identity-pointers in protocol thunks, etc.)
// pass through — they're already the dereferenced form.
else => return .{ .value = val },
}
},
// ── 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;
},
else => return .{ .value = val },
}
},
// ── Reflection ─────────────────────────────────────
.field_name_get => |fr| {
const idx_val = frame.getRef(fr.index);
const idx: usize = @intCast(switch (idx_val) {
.int => |i| i,
else => return error.CannotEvalComptime,
});
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 error.CannotEvalComptime,
};
if (idx >= fields.len) return error.OutOfBounds;
const name = self.module.types.getString(fields[idx].name);
return .{ .value = .{ .string = name } };
},
.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 error.CannotEvalComptime,
});
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 error.CannotEvalComptime,
};
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 error.CannotEvalComptime,
}
},
// ── 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 error.CannotEvalComptime;
},
.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 error.CannotEvalComptime,
}
},
else => return error.CannotEvalComptime,
}
},
// ── 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 error.CannotEvalComptime,
}
},
// 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, .closure_create, .union_get, .union_gep, .vec_splat, .vec_extract, .vec_insert => {
return error.CannotEvalComptime;
},
}
}
// ── 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 error.TypeError;
}
// ── 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,
};
}
}
return error.TypeError;
}
// ── 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, .type_of, .alloc, .dealloc => {
return error.CannotEvalComptime;
},
}
}
};
// ── 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;
}
}
};