comptime VM: Phase 3 — find_type + type_field_count reflection readers

First read-only compiler-API reflection readers, bound the same way as the
intern/text_of seed (compiler_lib.bound_fns + Vm.callCompilerFn, native on flat
memory, no marshaling). A type handle is a plain u32 TypeId (like StringId), so
both stay clean scalar host-calls:

  find_type(name: StringId) -> TypeId          (TypeTable.findByName; unresolved/0 if absent)
  type_field_count(t: TypeId) -> i64           (new TypeTable.memberCount; loud-bail, no silent 0)

memberCount is the single source both the legacy handler and the VM read, so the
two paths can't drift. find_type returns a non-optional TypeId using the
unresolved(0) sentinel for not-found rather than ?Type — a Type value is
.any-typed (which the flat-memory VM does not represent) and an optional can't
cross the legacy<->VM eval boundary; unresolved is the project-blessed "no type"
marker.

Example 0628 chains intern -> find_type -> type_field_count (+ a not-found
lookup), folded at #run, VM-HANDLED natively. VM unit test added.

Parity 689/689 (gate OFF and -Dcomptime-flat).
This commit is contained in:
agra
2026-06-18 09:25:26 +03:00
parent 0367d96d9b
commit a9302a8b50
10 changed files with 247 additions and 15 deletions

View File

@@ -47,6 +47,8 @@ pub const BoundFn = struct {
pub const bound_fns = [_]BoundFn{
.{ .sx_name = "intern", .handler = handleIntern },
.{ .sx_name = "text_of", .handler = handleTextOf },
.{ .sx_name = "find_type", .handler = handleFindType },
.{ .sx_name = "type_field_count", .handler = handleTypeFieldCount },
};
/// Look up a compiler function by its sx name. Returns null when the name is not
@@ -82,3 +84,30 @@ fn handleTextOf(interp: *Interpreter, args: []const Value) InterpError!Value {
const id: StringId = @enumFromInt(@as(u32, @intCast(args[0].int)));
return Value{ .string = interp.module.types.getString(id) };
}
/// `find_type(name: StringId) -> TypeId` — look up a named type (struct / enum /
/// union / tagged-union / error-set) by its interned name and return its handle.
/// A name with no matching type yields the dedicated `unresolved` sentinel (a
/// `TypeId` of 0), the codebase-blessed "no type" marker — NOT an `?Type` (a
/// `Type` value is `.any`-typed, which the flat-memory VM does not represent, and
/// an optional can't cross the legacy↔VM eval boundary). The caller checks the
/// handle against 0 / `unresolved`. The VM mirrors this in `comptime_vm.callCompilerFn`.
fn handleFindType(interp: *Interpreter, args: []const Value) InterpError!Value {
if (args.len != 1 or args[0] != .int) return error.TypeError;
if (args[0].int < 0 or args[0].int > std.math.maxInt(u32)) return error.TypeError;
const name: StringId = @enumFromInt(@as(u32, @intCast(args[0].int)));
const tid = interp.module.types.findByName(name) orelse types.TypeId.unresolved;
return Value{ .int = tid.index() };
}
/// `type_field_count(t: TypeId) -> i64` — the member count of an aggregate type
/// (struct/union/tagged-union fields, enum variants, array/vector length), read
/// through `TypeTable.memberCount`. A type with no member count (scalar, pointer,
/// the `unresolved` sentinel, …) is a loud error — never a silent 0.
fn handleTypeFieldCount(interp: *Interpreter, args: []const Value) InterpError!Value {
if (args.len != 1 or args[0] != .int) return error.TypeError;
if (args[0].int < 0 or args[0].int > std.math.maxInt(u32)) return error.TypeError;
const tid: types.TypeId = @enumFromInt(@as(u32, @intCast(args[0].int)));
const count = interp.module.types.memberCount(tid) orelse return error.TypeError;
return Value{ .int = count };
}

View File

@@ -769,6 +769,70 @@ test "comptime_vm exec: compiler-fn intern/text_of round-trip (native, no legacy
try std.testing.expectEqual(@as(i64, 5), toI64(try v.run(module.getFunction(main_id), &.{})));
}
test "comptime_vm exec: compiler-fn find_type + type_field_count (native reflection)" {
const alloc = std.testing.allocator;
var module = Module.init(alloc);
defer module.deinit();
// A struct `Point { x, y, z }` registered in the type table (the thing the
// reflection readers look up by name and count the fields of).
const point_name = module.types.internString("Point");
const pfields = [_]types.TypeInfo.StructInfo.Field{
.{ .name = module.types.internString("x"), .ty = .i64 },
.{ .name = module.types.internString("y"), .ty = .i64 },
.{ .name = module.types.internString("z"), .ty = .i64 },
};
_ = module.types.intern(.{ .@"struct" = .{ .name = point_name, .fields = &pfields } });
// extern find_type(name: u32) -> u32 [compiler] (FuncId 0, no body)
const fp = [_]Function.Param{.{ .name = module.types.internString("name"), .ty = .u32 }};
var ffb = Fb.init(alloc, &fp, .u32);
ffb.func.is_extern = true;
ffb.func.compiler_welded = true;
ffb.func.name = module.types.internString("find_type");
const find_id = module.addFunction(ffb.func);
// extern type_field_count(t: u32) -> i64 [compiler] (FuncId 1, no body)
const cp = [_]Function.Param{.{ .name = module.types.internString("t"), .ty = .u32 }};
var cfb = Fb.init(alloc, &cp, .i64);
cfb.func.is_extern = true;
cfb.func.compiler_welded = true;
cfb.func.name = module.types.internString("type_field_count");
const count_id = module.addFunction(cfb.func);
// main(): return type_field_count(find_type(intern_id_of("Point"))) → 3
// ("Point" is already interned above; pass its StringId directly.)
var fb = Fb.init(alloc, &.{}, .i64);
const b0 = fb.block(&.{});
const nm = fb.add(b0, inst(.{ .const_int = @intFromEnum(point_name) }, .u32));
const nargs = [_]Ref{ref(nm)};
const tid = fb.add(b0, inst(.{ .call = .{ .callee = find_id, .args = &nargs } }, .u32));
const targs = [_]Ref{ref(tid)};
const cnt = fb.add(b0, inst(.{ .call = .{ .callee = count_id, .args = &targs } }, .i64));
_ = fb.add(b0, inst(.{ .ret = .{ .operand = ref(cnt) } }, .void));
const main_id = module.addFunction(fb.func);
var v = vm.Vm.init(alloc);
v.table = &module.types;
v.module = &module;
defer v.deinit();
try std.testing.expectEqual(@as(i64, 3), toI64(try v.run(module.getFunction(main_id), &.{})));
// A name with no matching type → the `unresolved` (0) sentinel.
const missing = module.types.internString("Nope");
var mfb = Fb.init(alloc, &.{}, .u32);
const mb = mfb.block(&.{});
const mnm = mfb.add(mb, inst(.{ .const_int = @intFromEnum(missing) }, .u32));
const margs = [_]Ref{ref(mnm)};
const mres = mfb.add(mb, inst(.{ .call = .{ .callee = find_id, .args = &margs } }, .u32));
_ = mfb.add(mb, inst(.{ .ret = .{ .operand = ref(mres) } }, .void));
const missing_main = module.addFunction(mfb.func);
try std.testing.expectEqual(
@as(i64, @intFromEnum(TypeId.unresolved)),
toI64(try v.run(module.getFunction(missing_main), &.{})),
);
}
test "comptime_vm exec: func_ref + call_indirect dispatch" {
const alloc = std.testing.allocator;
var module = Module.init(alloc);

View File

@@ -1011,6 +1011,30 @@ pub const Vm = struct {
if (text.len > 0) @memcpy(try self.machine.bytes(data, text.len), text);
return try self.makeSlice(table, data, text.len);
}
// ── read-only reflection readers (Phase 3) ──────────────────────────
// Type handle = a u32 `TypeId` (a word), exactly like `StringId` — so
// these mirror intern/text_of's shape: word in, word out, no marshaling.
if (std.mem.eql(u8, name, "find_type")) {
if (args.len != 1) return self.failMsg("comptime find_type: expected one StringId arg");
const raw = frame.get(args[0].index());
if (raw > std.math.maxInt(u32)) return self.failMsg("comptime find_type: StringId out of range");
const sid: types.StringId = @enumFromInt(@as(u32, @intCast(raw)));
// Not found → the dedicated `unresolved` (0) sentinel, never a real
// type id (mirrors `compiler_lib.handleFindType`).
const tid = table.findByName(sid) orelse TypeId.unresolved;
return @as(Reg, tid.index());
}
if (std.mem.eql(u8, name, "type_field_count")) {
if (args.len != 1) return self.failMsg("comptime type_field_count: expected one TypeId arg");
const raw = frame.get(args[0].index());
if (raw > std.math.maxInt(u32)) return self.failMsg("comptime type_field_count: TypeId out of range");
const tid: TypeId = @enumFromInt(@as(u32, @intCast(raw)));
// Same `TypeTable.memberCount` the legacy handler reads → no drift; a
// type with no member count bails loudly (no silent 0).
const count = table.memberCount(tid) orelse
return self.failMsg("comptime type_field_count: type has no field/variant count");
return @as(Reg, @bitCast(count));
}
return null; // not a known compiler function → caller bails to legacy
}

View File

@@ -480,6 +480,26 @@ pub const TypeTable = struct {
return null;
}
/// Member count of an aggregate type: struct/union/tagged-union fields, enum
/// variants, or array/vector length. Returns null for a type that has no
/// member count (a scalar, pointer, the `unresolved` sentinel, …) — so a
/// caller bails loudly rather than reading a silent 0. The comptime
/// compiler-API reflection reader `type_field_count` rides on this (both the
/// legacy `compiler_lib` handler and the flat-memory VM call it, so the two
/// paths can never drift). Out-of-range ids return null, not a panic.
pub fn memberCount(self: *const TypeTable, id: TypeId) ?i64 {
if (id.index() >= self.infos.items.len) return null;
return switch (self.get(id)) {
.@"struct" => |s| @intCast(s.fields.len),
.@"union" => |u| @intCast(u.fields.len),
.tagged_union => |u| @intCast(u.fields.len),
.@"enum" => |e| @intCast(e.variants.len),
.array => |a| @intCast(a.length),
.vector => |v| @intCast(v.length),
else => null,
};
}
/// Source-sensitive variant of `findByName`: asserts at most one named type
/// matches, then returns it (or null). Quarantines the global first-match
/// scan — new resolver code that must not silently pick a first-of-many