diff --git a/current/CHECKPOINT-COMPILER-API.md b/current/CHECKPOINT-COMPILER-API.md index 387b69db..b7638fc6 100644 --- a/current/CHECKPOINT-COMPILER-API.md +++ b/current/CHECKPOINT-COMPILER-API.md @@ -31,11 +31,16 @@ with ONE welded mechanism. Branch: `reify` (off `master`). Update after every st > wired end-to-end: foundation (`6844fb9`), resolver flip + full `.any`-ref migration (`94f60c5`), > and the VM models `.type_value` natively (`554871b`). **697/0 BOTH gates + 494 unit tests.** > `first_user` is now **100** (slots 20–99 reserved builtin headroom so future builtins don't -> renumber user TypeIds / churn snapshots). What remains is the PAYOFF, no longer gated on the WALL: -> port the **WRITE side** (declare_type / register_type / pointer_to) into `Vm.callCompilerFn` + -> give the lowering-time path a **REAL Context** (CAllocator thunk func-refs, not zeroed) → the -> first **HANDLED** lowering-time type-fn end-to-end on the VM. Today those type-fns run partway on -> the VM then bail cleanly at the `define`/`register_type` `call_builtin` → legacy mints (parity). +> renumber user TypeIds / churn snapshots). The PAYOFF is now LANDED (``): the +> **WRITE side** (declare_type / register_type / pointer_to) is VM-native in `Vm.callCompilerFn` +> — the compiler-API type-fns (`0631`/`0635`) run **HANDLED end-to-end on the VM at LOWERING +> time** (parity-correct), the first lowering-time comptime to do so; they run on the zeroed +> lowering-time context (no allocation). **697/0 both gates + EXIT=0.** What's LEFT toward the +> end-state ONE evaluator: (1) re-express the metatype `define`/`make_enum` over the compiler-API +> + delete the bespoke interp arms (the `make_enum` examples still fall back cleanly through +> `call_builtin(define)`); (2) a REAL lowering-time Context (CAllocator thunk func-refs) for +> List-growing type-fns — deferred (no HANDLED type-fn allocates); (3) eventually flip the VM to +> default + delete `interp.zig`. > > Done so far in Phase 3: > - **READ side (7 readers, dual-path):** `find_type`/`type_kind`/`type_field_count`/ @@ -341,6 +346,27 @@ when reached (sentinels or accessor fns; see the design doc Risks). `List` growth; orthogonal, see `current/CHECKPOINT-METATYPE.md`.) ## Log +- **Phase 3 P3.4 step 5 (VM plan) — WRITE side ported to the VM → FIRST HANDLED lowering-time type-fns (2026-06-18).** + Ported `declare_type` / `pointer_to` / `register_type` into `Vm.callCompilerFn`, mirroring the + legacy `compiler_lib` handlers (mint via `@constCast(table)` — the same mutable access the + read-side `intern` uses; the lowering-time mint target IS `&module.types`). `register_type` + reads the `[]Member` slice from FLAT MEMORY: threaded `ref_types` through `invoke` → + `callCompilerFn` so the slice's element type (`Member = {name: string, ty: Type}`) gives the + field offsets + stride; decodes each `{name, ty}` and branches on `kind` (1 struct · 2 enum · + 3 tagged_union · 4 tuple) exactly as legacy (dup-name / payload-on-enum rejections, idempotent + re-fill via `nominalIdentOf`). **Key unblock:** the synthesized comptime type-fn wrapper + (`createComptimeFunction`/`…WithPrelude`) was built with return type `.any` → `regToValue` + bailed at the VM↔legacy boundary; changed to `.type_value` (the legacy path reads via `asTypeId` + regardless, so no legacy change). **Result: the compiler-API write type-fns now run HANDLED + end-to-end on the VM at LOWERING time** — `0631` (register-graph: 2 HANDLED, A↔B cycle via + forward handles + `pointer_to`) and `0635` (multi-edge import: 2 HANDLED), parity-correct. They + run on the ZEROED lowering-time context (fixed `.[…]` member arrays, no allocation). The + metatype `make_enum`/`define` examples (`0632`) still fall back CLEANLY through + `call_builtin(define)` (the separate metatype path — re-expressing it onto the compiler-API is + the other half of P3.4). **697/0 BOTH gates + EXIT=0.** On `reify`. **Next:** (optional, deferred) + a REAL lowering-time Context (CAllocator thunk func-refs) for List-growing type-fns; and + re-express the metatype `define`/`make_enum` over the compiler-API to delete the bespoke interp + arms (the end-state: ONE evaluator). - **Phase 3 P3.4 step 4 (VM plan) — model `.type_value` natively in the comptime VM (2026-06-18).** The VM now HANDLES Type values instead of bailing: `kindOf(.type_value)` → `.word`; a new `const_type` exec arm → the word `TypeId.index()`; `regToValue` maps a `.type_value` word back diff --git a/src/ir/comptime_vm.zig b/src/ir/comptime_vm.zig index a8da5c28..1374f125 100644 --- a/src/ir/comptime_vm.zig +++ b/src/ir/comptime_vm.zig @@ -242,6 +242,20 @@ fn isFloat(ty: TypeId) bool { return ty == .f32 or ty == .f64; } +/// The nominal identity (`name` + stable `nominal_id`) of a `declare_type`'d slot — +/// from the forward `tagged_union` OR an already-completed nominal (so a re-fill +/// preserves identity). Mirrors `compiler_lib.nominalIdent`. Null for a non-nominal +/// handle (not a `declare_type` result). +fn nominalIdentOf(info: types.TypeInfo) ?struct { name: types.StringId, nominal_id: u32 } { + return switch (info) { + .tagged_union => |u| .{ .name = u.name, .nominal_id = u.nominal_id }, + .@"enum" => |e| .{ .name = e.name, .nominal_id = e.nominal_id }, + .@"struct" => |s| .{ .name = s.name, .nominal_id = s.nominal_id }, + .tuple => .{ .name = types.StringId.empty, .nominal_id = 0 }, // structural; name vestigial + else => null, + }; +} + /// A signed integer type narrower-or-equal to 64 bits — its loaded bytes must be /// SIGN-extended into the register (the legacy `.int` model is i64). fn isSignedInt(ty: TypeId) bool { @@ -772,7 +786,7 @@ pub const Vm = struct { // ── Calls ─────────────────────────────────────────── // Direct call: resolve the static callee `FuncId` and dispatch. - .call => |c| return .{ .value = try self.invoke(c.callee, c.args, frame) }, + .call => |c| return .{ .value = try self.invoke(c.callee, c.args, frame, ref_types) }, // Indirect call: the callee is a `func_ref` value (its `FuncId.index()` // as a word) in a register — e.g. an allocator protocol's `alloc_fn`. // A null (0) function pointer can't be dispatched → bail. @@ -782,7 +796,7 @@ pub const Vm = struct { self.detail = "comptime VM: call_indirect through a null function pointer"; return error.Unsupported; }; - return .{ .value = try self.invoke(fid, ci.args, frame) }; + return .{ .value = try self.invoke(fid, ci.args, frame, ref_types) }; }, // ── Globals / function values ─────────────────────── @@ -923,7 +937,7 @@ pub const Vm = struct { /// extern/bodyless callee routes to the native libc memory builtins (else /// bails); a normal callee runs on the VM. Aggregate args pass as their Addr /// over the shared flat memory (no copy). - fn invoke(self: *Vm, fid: inst_mod.FuncId, args: []const Ref, frame: *Frame) Error!Reg { + fn invoke(self: *Vm, fid: inst_mod.FuncId, args: []const Ref, frame: *Frame, ref_types: []const TypeId) Error!Reg { const module = self.module orelse return self.failMsg("comptime VM: call needs a module (not provided)"); if (fid.index() >= module.functions.items.len) return self.failMsg("comptime VM: call to an out-of-range function id"); const callee = module.getFunction(fid); @@ -937,7 +951,7 @@ pub const Vm = struct { // the comptime compiler-API, serviced natively on flat memory (Phase 3 // seed). The `compiler_welded` flag is the safety boundary. if (callee.compiler_welded) { - if (try self.callCompilerFn(name, args, frame)) |r| return r; + if (try self.callCompilerFn(name, args, frame, ref_types)) |r| return r; } // Any other extern bails → the legacy interpreter's dlsym path. self.detail = "comptime VM: call to an extern/builtin function not yet ported"; @@ -1043,7 +1057,7 @@ pub const Vm = struct { return @enumFromInt(try self.argHandle(args, frame, i)); } - fn callCompilerFn(self: *Vm, name: []const u8, args: []const Ref, frame: *Frame) Error!?Reg { + fn callCompilerFn(self: *Vm, name: []const u8, args: []const Ref, frame: *Frame, ref_types: []const TypeId) Error!?Reg { const table = try self.requireTable(); if (std.mem.eql(u8, name, "intern")) { if (args.len != 1) return self.failMsg("comptime intern: expected one string arg"); @@ -1122,9 +1136,113 @@ pub const Vm = struct { return self.failMsg("comptime type_field_value: non-enum or out-of-range idx"); return @as(Reg, @bitCast(v)); } + // ── write side (lowering-time, mints into the type table) ─────────── + // These MINT into the type table via `@constCast(table)` — the same + // mutable access the read-side `intern` uses (the table is genuinely + // mutable; the VM merely holds it `const`). They take/return real `Type` + // values (`.type_value` words = `TypeId.index()`). Mirror the legacy + // `compiler_lib` handlers exactly so the dual paths can't drift. + if (std.mem.eql(u8, name, "declare_type")) { + if (args.len != 1) return self.failMsg("comptime declare_type: expected (name)"); + const s = frame.get(args[0].index()); // string fat-pointer Addr + const text = try self.machine.bytes(try self.sliceData(table, s), @intCast(try self.sliceLen(s))); + const tbl = @constCast(table); + const name_id = tbl.internString(text); + if (tbl.findByName(name_id)) |existing| return @as(Reg, existing.index()); + const tid = tbl.internNominal(.{ .tagged_union = .{ .name = name_id, .fields = &.{}, .tag_type = .i64 } }, 0); + return @as(Reg, tid.index()); + } + if (std.mem.eql(u8, name, "pointer_to")) { + if (args.len != 1) return self.failMsg("comptime pointer_to: expected (Type)"); + const t = try self.argTypeId(args, frame, 0); + return @as(Reg, @constCast(table).intern(.{ .pointer = .{ .pointee = t } }).index()); + } + if (std.mem.eql(u8, name, "register_type")) { + return self.registerTypeVm(args, frame, ref_types); + } return null; // not a known compiler function → caller bails to legacy } + /// VM-native `register_type(handle: Type, kind: i64, members: []Member) -> Type` + /// — fill a `declare_type`'d forward slot, branching on `kind` in the compiler + /// (mirrors `compiler_lib.handleRegisterType`, but reads `[]Member` from flat + /// memory instead of decoding a `Value`). `Member` is `{ name: string, ty: Type }`. + fn registerTypeVm(self: *Vm, args: []const Ref, frame: *Frame, ref_types: []const TypeId) Error!?Reg { + const table = try self.requireTable(); + if (args.len != 3) return self.failMsg("comptime register_type: expected (handle, kind, members)"); + const handle = try self.argTypeId(args, frame, 0); + const kind: i64 = @bitCast(frame.get(args[1].index())); + + // Decode the `[]Member` slice: element layout comes from the slice's IR + // element type (`Member` — `{ name: string @0, ty: Type @1 }`). + const slice_ty = try self.refTy(ref_types, args[2]); + if (slice_ty.isBuiltin() or table.get(slice_ty) != .slice) + return self.failMsg("comptime register_type: members arg is not a slice"); + const member_ty = table.get(slice_ty).slice.element; + if (member_ty.isBuiltin() or table.get(member_ty) != .@"struct" or table.get(member_ty).@"struct".fields.len != 2) + return self.failMsg("comptime register_type: Member element must be a {name, ty} struct"); + const mfields = table.get(member_ty).@"struct".fields; + const name_off = fieldOffset(table, member_ty, 0); + const ty_off = fieldOffset(table, member_ty, 1); + const name_fty = mfields[0].ty; // string + const members_word = frame.get(args[2].index()); + const len = try self.sliceLen(members_word); + const base = try self.sliceData(table, members_word); + const stride: Addr = @intCast(table.typeSizeBytes(member_ty)); + if (len == 0) return self.failMsg("comptime register_type: a type with no members is never valid"); + + const tbl = @constCast(table); + // The slot's nominal identity — accept the forward `tagged_union` from + // `declare_type` AND an already-completed nominal of the same name (so a + // re-fill via two import edges is idempotent). A non-nominal handle (not a + // `declare_type`'d slot) is rejected. + const ident = nominalIdentOf(table.get(handle)) orelse + return self.failMsg("comptime register_type: handle is not a declare_type'd nominal slot"); + + const M = struct { name: types.StringId, ty: TypeId }; + // Read each `Member { name, ty }` out of flat memory. + var members = std.ArrayList(M).empty; + defer members.deinit(self.gpa); + for (0..@intCast(len)) |i| { + const elem = base + @as(Addr, @intCast(i)) * stride; + const name_fp = try self.readField(table, elem + name_off, name_fty); // string fat-pointer Addr + const mname = try self.machine.bytes(try self.sliceData(table, name_fp), @intCast(try self.sliceLen(name_fp))); + const mty: TypeId = @enumFromInt(@as(u32, @intCast(try self.readField(table, elem + ty_off, .type_value)))); + members.append(self.gpa, .{ .name = tbl.internString(mname), .ty = mty }) catch return self.failMsg("comptime register_type: out of memory"); + } + + switch (kind) { + 4 => { // tuple — positional element types (names ignored) + const tys = self.gpa.alloc(TypeId, members.items.len) catch return self.failMsg("comptime register_type: out of memory"); + for (members.items, 0..) |m, i| tys[i] = m.ty; + tbl.replaceKeyedInfo(handle, .{ .tuple = .{ .fields = tys, .names = null } }); + }, + 2 => { // actual (payloadless) enum — members are variant NAMES; payload must be void + const names = self.gpa.alloc(types.StringId, members.items.len) catch return self.failMsg("comptime register_type: out of memory"); + for (members.items, 0..) |m, i| { + if (m.ty != .void) return self.failMsg("comptime register_type: payload variant — use kind 3 (tagged_union)"); + for (names[0..i]) |prev| if (prev == m.name) return self.failMsg("comptime register_type: duplicate enum variant"); + names[i] = m.name; + } + tbl.replaceKeyedInfo(handle, .{ .@"enum" = .{ .name = ident.name, .variants = names, .nominal_id = ident.nominal_id } }); + }, + 1, 3 => { // struct / tagged_union — `{ name, ty }` fields (dup names rejected) + const flds = self.gpa.alloc(types.TypeInfo.StructInfo.Field, members.items.len) catch return self.failMsg("comptime register_type: out of memory"); + for (members.items, 0..) |m, i| { + for (flds[0..i]) |prev| if (prev.name == m.name) return self.failMsg("comptime register_type: duplicate member name"); + flds[i] = .{ .name = m.name, .ty = m.ty }; + } + const full: types.TypeInfo = if (kind == 1) + .{ .@"struct" = .{ .name = ident.name, .fields = flds, .nominal_id = ident.nominal_id } } + else + .{ .tagged_union = .{ .name = ident.name, .fields = flds, .tag_type = .i64, .nominal_id = ident.nominal_id } }; + tbl.replaceKeyedInfo(handle, full); + }, + else => return self.failMsg("comptime register_type: unknown kind code"), + } + return @as(Reg, handle.index()); + } + // ── Reg ↔ Value bridge (legacy-interop boundary) ──────────────────────── // // The wiring step routes a comptime eval through the VM, falling back to the diff --git a/src/ir/lower/comptime.zig b/src/ir/lower/comptime.zig index ce8f56bc..5e6fbd8c 100644 --- a/src/ir/lower/comptime.zig +++ b/src/ir/lower/comptime.zig @@ -449,7 +449,11 @@ pub fn evalComptimeType(self: *Lowering, expr: *const Node) ?TypeId { // lowering because a `*Name` can lower before its `declare` within the same // body. The interp's `declare` returns this same slot; `define` completes it. preregisterForwardTypes(self, expr); - const func_id = self.createComptimeFunction("__ctype", expr, .any); + // The wrapper returns a `Type` value → `.type_value` (the dedicated 8-byte + // handle). The legacy path reads the result via `asTypeId` regardless, but the + // VM path converts `func.ret` — `.type_value` → `.type_tag` (an `.any` return + // would box the result and bail at the VM↔legacy boundary). + const func_id = self.createComptimeFunction("__ctype", expr, .type_value); return self.runComptimeTypeFunc(func_id, expr.span); } @@ -463,7 +467,8 @@ pub fn evalComptimeTypeBody(self: *Lowering, body: *const Node, ret_expr: *const // the prelude, not the return) so forward types register before lowering. preregisterForwardTypes(self, body); const prelude = preludeBeforeReturn(body); - const func_id = self.createComptimeFunctionWithPrelude("__ctype", prelude, ret_expr, .any); + // Return type `.type_value` (a `Type` value) — see `evalComptimeType`. + const func_id = self.createComptimeFunctionWithPrelude("__ctype", prelude, ret_expr, .type_value); return self.runComptimeTypeFunc(func_id, ret_expr.span); }