ADR-0042: Comptime Metaprogramming — Diagnostics, Strings, Reflection, and comptime_unroll For
Status
Implemented
Summary
Complete the comptime metaprogramming story by adding four capabilities identified as future work in ADR-0040: user-controlled compile-time diagnostics (@compileError, @compileLog), a comptime-only string type (comptime_str), compile-time type reflection (@typeInfo), and compile-time loop unrolling (comptime_unroll for). Together, these enable Gruel users to write sophisticated type-safe code generators and generic abstractions — the kind of metaprogramming that Zig's comptime model is known for. The comptime allocator (materializing comptime heap into the binary's static data) is explicitly deferred to a future ADR.
Context
Where comptime stands today
ADR-0025 introduced the comptime keyword, type parameters, and monomorphization. ADR-0033 replaced the expression-only constant folder with a proper AIR-level interpreter (call stack, mutable locals, comptime heap). ADR-0040 closed the remaining operational gaps — mutation, enums, pattern matching, generic calls, method calls, and @dbg. A differential fuzzer validates interpreter-vs-codegen agreement.
The interpreter now supports nearly every pure language construct. What's missing is not evaluation capability but metaprogramming capability — the tools that let comptime code inspect types, generate diagnostics, manipulate text, and unroll loops over compile-time data.
What's missing
| Feature | What it enables | Zig equivalent |
|---|---|---|
@compileError / @compileLog | User-defined compile errors and debug logging | @compileError, @compileLog |
comptime_str | String manipulation at compile time (field names, error messages) | Comptime slices (built-in) |
@typeInfo | Inspecting struct fields, enum variants, function signatures | @typeInfo |
comptime_unroll for | Unrolling loops over comptime-known collections | inline for |
These features have strong dependencies on each other:
@compileError/@compileLog (standalone)
│
▼
comptime_str ←── string literals in comptime context
│
▼
@typeInfo ←── returns comptime_str for field/variant names
│
▼
comptime_unroll for ←── iterating over @typeInfo results
@compileError/@compileLog are standalone. comptime_str is a prerequisite for @typeInfo (field names are strings). @typeInfo motivates comptime_unroll for (iterating over struct fields). Each phase is independently useful, but the full power emerges when combined.
Why not the comptime allocator?
The comptime allocator (materializing comptime heap items into the compiled binary as static data) crosses the interpreter/codegen boundary — it requires LLVM global generation, lifetime semantics for frozen data, and decisions about mutability of materialized values. It deserves its own ADR and does not block the metaprogramming features in this one.
Design principles (carried forward from ADR-0040)
- No comptime pointers. Heap-indexed values (
ConstValue::Struct(u32)) remain the representation for composites. ConstValuestaysCopy. All variable-size data lives on the comptime heap;ConstValueholds only fixed-size tags and indices.- Same syntax where possible. New constructs reuse existing syntax patterns.
comptime_unroll forintroduces a new keyword (comptime_unroll) that clearly communicates the construct belongs to the comptime family and that it unrolls — generating runtime code from compile-time data.
Decision
Phase 1: @compileError and @compileLog
Two new intrinsics that give comptime code control over compiler diagnostics.
@compileError(message)
Emits a compile error with a user-defined message. The message must be a string literal (Phase 1) or a comptime_str value (after Phase 2).
fn Matrix(comptime rows: i32, comptime cols: i32) -> type {
if rows <= 0 || cols <= 0 {
@compileError("Matrix dimensions must be positive");
}
struct { data: [i32; rows * cols] }
}
Semantics:
- Evaluated during comptime interpretation, not during parsing
- Unreachable
@compileErrorcalls are never evaluated (dead code in branches is fine) - The message becomes the primary error text in the diagnostic
- Type:
@compileErrorhas type!(never) — it terminates compilation of the current comptime block
Interpreter implementation:
// In evaluate_comptime_inst, Intrinsic handler:
if name == self.known.compile_error
The evaluate_comptime_string_arg helper resolves the argument. In Phase 1, it only accepts StringConst instructions (string literals). In Phase 2, it also accepts ConstValue::ComptimeStr values.
@compileLog(args...)
Emits a compile-time log message. Unlike @compileError, it does not stop compilation — it prints during the compilation process. Useful for debugging comptime logic.
fn compute(comptime n: i32) -> type {
@compileLog("computing with n =", n);
// ...
}
Semantics:
- Variadic: accepts any number of arguments of any comptime-evaluable type
- Each argument is formatted (integers as decimal, bools as
true/false, types as their name,comptime_stras their content) - Output goes to stderr with a
comptime log:prefix, similar to Zig - The result type is
()(unit) — it's a statement, not an expression - A program that compiles successfully but contains
@compileLogcalls emits a warning ("comptime log present — remove before release"), preventing accidental commit of debug logging
Interpreter implementation:
if name == self.known.compile_log
After sema completes, if comptime_log_output is non-empty, emit a warning for each entry.
RIR changes
No new RIR instructions needed. @compileError and @compileLog are parsed as Intrinsic instructions (same as @dbg, @intCast). The names compileError and compileLog are registered in KnownSymbols.
Error infrastructure
Add ErrorKind::ComptimeUserError(String) to gruel-error for user-defined compile errors.
Phase 2: comptime_str type
A comptime-only string type for manipulating text at compile time. Unlike the runtime String type (which is a synthetic struct backed by {ptr, len, cap} and FFI methods in gruel-runtime), comptime_str is a pure interpreter value — an owned Rust String inside the comptime heap.
Type system
Add Type::ComptimeStr as a new type tag in the type intern pool, alongside Type::ComptimeType. Like type, comptime_str is rejected in runtime positions.
fn greet(comptime name: comptime_str) -> type {
@compileLog("building greeter for", name);
struct {
fn hello(self) -> i32 { 42 }
}
}
ConstValue and heap
// New ConstValue variant
// New ComptimeHeapItem variant
String literal promotion
When a string literal (StringConst) appears in a comptime context, the interpreter promotes it to a ConstValue::ComptimeStr:
StringConst =>
This is the bridge: string literals are runtime values in normal code but become comptime_str values inside comptime blocks.
Methods
comptime_str methods are implemented directly in the interpreter, not through the synthetic struct / runtime function pattern. Each method is dispatched by name in a MethodCall handler for comptime_str receivers:
| Method | Signature | Description |
|---|---|---|
len | fn len(self) -> i32 | Length in bytes |
is_empty | fn is_empty(self) -> bool | Whether length is zero |
contains | fn contains(self, needle: comptime_str) -> bool | Substring search |
starts_with | fn starts_with(self, prefix: comptime_str) -> bool | Prefix check |
ends_with | fn ends_with(self, suffix: comptime_str) -> bool | Suffix check |
eq | fn eq(self, other: comptime_str) -> bool | Equality (== operator) |
ne | fn ne(self, other: comptime_str) -> bool | Inequality (!= operator) |
lt | fn lt(self, other: comptime_str) -> bool | Less than (< operator, lexicographic) |
le | fn le(self, other: comptime_str) -> bool | Less or equal (<= operator, lexicographic) |
gt | fn gt(self, other: comptime_str) -> bool | Greater than (> operator, lexicographic) |
ge | fn ge(self, other: comptime_str) -> bool | Greater or equal (>= operator, lexicographic) |
concat | fn concat(self, other: comptime_str) -> comptime_str | Concatenation |
Deferred methods (can be added incrementally, not gated):
trim,trim_start,trim_end— whitespace trimmingto_upper,to_lower— case conversionsplit— splitting into an array (requires comptime arrays of comptime_str)slice— substring extraction
The initial set is deliberately minimal. The most important use case for comptime_str in Phase 2 is receiving field names from @typeInfo (Phase 3) and passing them to @compileError / @compileLog (Phase 1). Rich string manipulation can be added as methods without further gating.
@dbg extension
Extend the comptime @dbg handler to format ConstValue::ComptimeStr values:
ComptimeStr =>
@compileError / @compileLog extension
After Phase 2, @compileError and @compileLog accept comptime_str arguments in addition to string literals:
fn check(comptime T: type) -> type {
let info = @typeInfo(T);
if info.fields.len == 0 {
@compileError("type has no fields");
}
// ...
}
Phase 3: @typeInfo and @typeName
Compile-time type reflection — inspecting the structure of types during comptime evaluation.
@typeName(T) — simple type name
Returns the name of a type as a comptime_str:
fn debug_type(comptime T: type) -> comptime_str {
@typeName(T) // "i32", "MyStruct", "Option", etc.
}
Implementation: A new TypeIntrinsic handler in the comptime interpreter. Resolves the type, formats its name, allocates a ComptimeStr on the heap.
@typeInfo(T) — full type metadata
Returns a comptime struct describing the type's structure. The shape of the returned struct depends on the type's kind.
fn inspect(comptime T: type) {
let info = @typeInfo(T);
@compileLog("type", @typeName(T), "has", info.fields.len, "fields");
}
TypeKind builtin enum
A new builtin enum for discriminating type kinds, injected via the existing BuiltinEnumDef infrastructure (same pattern as Arch and Os):
pub static TYPE_KIND_ENUM: BuiltinEnumDef = BuiltinEnumDef ;
This enables match on type kind rather than string comparisons.
Returned structures:
For struct types:
// @typeInfo(SomeStruct) returns:
struct {
kind: TypeKind, // TypeKind::Struct
name: comptime_str, // "SomeStruct"
fields: [FieldInfo; N], // fixed-size array
}
// Where FieldInfo is:
struct {
name: comptime_str, // "x", "y", etc.
field_type: type, // i32, bool, etc.
}
For enum types:
// @typeInfo(SomeEnum) returns:
struct {
kind: TypeKind, // TypeKind::Enum
name: comptime_str, // "SomeEnum"
variants: [VariantInfo; N], // fixed-size array
}
// Where VariantInfo is:
struct {
name: comptime_str, // "Red", "Some", etc.
fields: [FieldInfo; M], // empty for unit variants
}
For primitive types:
// @typeInfo(i32) returns:
struct {
kind: TypeKind, // TypeKind::Int
name: comptime_str, // "i32"
bits: i32, // 32
is_signed: bool, // true
}
Users can then write:
fn describe(comptime T: type) {
let info = @typeInfo(T);
match info.kind {
TypeKind::Struct => @compileLog("struct with", info.fields.len, "fields"),
TypeKind::Enum => @compileLog("enum with", info.variants.len, "variants"),
TypeKind::Int => @compileLog("integer:", info.bits, "bits"),
_ => @compileLog("other type"),
}
}
Implementation strategy:
@typeInfo is a TypeIntrinsic that, during comptime evaluation, constructs anonymous struct types and values on the fly:
- Resolve the type argument to a concrete
Type - Match on its
TypeKind(Struct, Enum, Int, Bool, etc.) - Create anonymous struct types for
FieldInfo,VariantInfo, and the top-level info struct using the existing anonymous struct infrastructure from ADR-0025 Phase 4 - Set the
kindfield to the appropriateTypeKindvariant (aConstValue::EnumVariant) - Allocate instances of these structs on the comptime heap with the appropriate field values
- Return the
ConstValue::Struct(heap_idx)pointing to the info struct
The anonymous struct types created by @typeInfo are cached per-type to avoid creating duplicate type definitions. A HashMap<Type, StructId> in Sema maps inspected types to their info struct types.
The key subtlety: FieldInfo structs contain field_type: type fields, which store ConstValue::Type(ty) values. This reuses the existing comptime type value infrastructure. Field name strings use ConstValue::ComptimeStr, which is why Phase 2 is a prerequisite.
Phase 4: comptime_unroll for
Compile-time loop unrolling over comptime-known collections.
Syntax
comptime_unroll_for = "comptime_unroll" "for" ["mut"] identifier "in" expression "{" block "}" ;
The syntax mirrors regular for loops (no parentheses around the binding/iterable), prefixed with the comptime_unroll keyword.
fn sum_fields(comptime T: type, val: T) -> i32 {
let info = @typeInfo(T);
let mut total: i32 = 0;
comptime_unroll for field in info.fields {
total = total + @field(val, field.name);
}
total
}
Semantics
- The collection expression must be comptime-known (a comptime array or
@rangecall with comptime-known arguments) - The loop is unrolled at compile time: one copy of the body per element
- The loop variable is comptime within each unrolled iteration
- The unrolled body is analyzed in a runtime context (it can reference runtime variables)
Distinction from for inside comptime blocks: Regular for loops work inside comptime blocks just like while and loop — the entire loop runs at compile time and produces a single comptime value. comptime_unroll for is fundamentally different: it produces runtime code (one copy of the body per iteration), while a for inside comptime { } produces a single comptime value. comptime_unroll for bridges comptime and runtime — it uses comptime data to generate runtime code.
// Pure comptime — the for loop runs entirely at compile time, producing one value
let x = comptime {
let mut sum = 0;
for i in @range(10) { sum = sum + i; }
sum // 45
};
// comptime_unroll — uses comptime data to generate N copies of runtime code
fn sum_fields(comptime T: type, val: T) -> i32 {
let mut total: i32 = 0;
comptime_unroll for field in @typeInfo(T).fields {
total = total + @field(val, field.name); // val is runtime
}
total
}
@field intrinsic
comptime_unroll for over @typeInfo fields requires accessing struct fields by comptime-known name. The @field intrinsic provides this:
@field(value, field_name) // Access a field by comptime_str name
Semantics:
valueis a runtime value of struct typefield_nameis acomptime_strnaming the field- At compile time,
@fieldresolves to aFieldGetinstruction with the concrete field index - Type-safe: the resolved field's type is used for type checking
This is equivalent to Zig's @field.
Implementation
comptime_unroll for is implemented as a desugaring pass in Sema, not as a new RIR instruction:
- Parser: Parse
comptime_unroll foras a new AST nodeComptimeUnrollFor { pattern, collection, body } - AstGen: Lower to a new RIR instruction
ComptimeUnrollFor { binding, collection, body_start, body_len } - Sema: When analyzing
ComptimeUnrollFor: a. Evaluate the collection expression in comptime context →ConstValue::Array(heap_idx)b. Read the array from the comptime heap c. For each element, substitute the loop variable with the element's value and analyze the body d. Emit the analyzed body instructions for each iteration sequentially into the AIR
This means the loop is fully unrolled during sema — by the time the CFG is built, there is no loop, just N copies of the body with different constant substitutions.
Interaction with @typeInfo and @field
The canonical use case combines all three phases:
fn serialize(comptime T: type, val: T) -> i32 {
let info = @typeInfo(T);
let mut hash: i32 = 0;
comptime_unroll for field in info.fields {
let field_val = @field(val, field.name);
hash = hash * 31 + field_val; // assuming i32 fields for simplicity
}
hash
}
struct Point { x: i32, y: i32 }
fn main() -> i32 {
let p = Point { x: 10, y: 20 };
serialize(Point, p)
// Unrolls to: hash = 0; hash = hash * 31 + p.x; hash = hash * 31 + p.y;
}
New keywords and symbols
| Token | Type | Phase |
|---|---|---|
comptime_str | Type keyword | 2 |
TypeKind | Builtin enum | 3 |
comptime_unroll | Keyword | 4 |
@compileError | Intrinsic | 1 |
@compileLog | Intrinsic | 1 |
@typeName | TypeIntrinsic | 3 |
@typeInfo | TypeIntrinsic | 3 |
@field | Intrinsic | 4 |
Implementation Phases
Phase 1:
@compileErrorand@compileLog— AddcompileErrorandcompileLogtoKnownSymbols. Handle@compileErrorin the comptimeIntrinsichandler: evaluate the string literal argument and returnErr(CompileError::new(ErrorKind::ComptimeUserError(msg), span)). Handle@compileLogby formatting all arguments and appending to a newcomptime_log_output: Vec<(String, Span)>buffer onSema; emit a warning after sema for each entry. AddErrorKind::ComptimeUserError(String)togruel-error. Addformat_const_valuehelper method. Gate behindcomptime_metapreview feature. Add spec rules for both intrinsics. Add spec tests.Phase 2:
comptime_strtype — AddType::ComptimeStrtag to the type intern pool. AddConstValue::ComptimeStr(u32)variant andComptimeHeapItem::String(String)variant. HandleStringConstinevaluate_comptime_instby promoting toComptimeStr. Addcomptime_strmethod dispatch in theMethodCallhandler (len, is_empty, contains, starts_with, ends_with, eq, ne, lt, le, gt, ge). Add==,!=,<,<=,>,>=operator support forComptimeStrpairs (lexicographic byte ordering for comparisons). Extend@dbg,@compileError,@compileLogto acceptcomptime_strvalues. Add legality check: rejectcomptime_strin runtime positions (same pattern astype). Gate behindcomptime_meta. Add spec rules and tests.Phase 3:
@typeInfoand@typeName— AddTypeKindbuiltin enum togruel-builtins(variants: Struct, Enum, Int, Bool, Unit, Never, Array) and add it toBUILTIN_ENUMS. Add@typeNameas aTypeIntrinsichandler in comptime evaluation: resolve type, format name, returnComptimeStr. Add@typeInfoas aTypeIntrinsichandler: resolve type, create anonymous struct types for the info structs (cache inHashMap<Type, StructId>), setkindfield to the appropriateTypeKindvariant, allocate instances on comptime heap with field values (usingConstValue::ComptimeStrfor names,ConstValue::Typefor type values,ConstValue::Arrayfor field/variant lists), returnConstValue::Struct. CreateFieldInfoandVariantInfoanonymous struct types. Handle struct, enum, and primitive type kinds. Gate behindcomptime_meta. Add spec rules and tests.Phase 4:
comptime_unroll forand@field— Addcomptime_unrollkeyword to the lexer. AddComptimeUnrollForAST node in parser (comptime_unrollfollowed byfor, then the standard for-loop binding/iterable syntax). Lower toComptimeUnrollForRIR instruction in AstGen. In Sema, evaluate the collection expression in comptime context, then for each element: bind the loop variable as a comptime local and analyze the body, emitting the resulting AIR instructions. Support both comptime arrays and@rangewith comptime-known arguments as the iterable. Add@field(value, field_name)intrinsic: resolvefield_nameas acomptime_strto a concrete field index, emit aFieldGetinstruction. Gate behindcomptime_meta. Add spec rules and tests. Extend the differential fuzzer to generatecomptime_unroll forprograms.
Consequences
Positive
- Full metaprogramming story: Users can write generic serializers, validators, debug formatters, and type-safe builders using comptime
- Better error messages: Library authors can use
@compileErrorto produce domain-specific compile errors instead of cryptic type mismatches - Debugging comptime:
@compileLoggives developers visibility into comptime execution without resorting to@dbghacks - Incremental value: Each phase is useful standalone —
@compileErroralone is worth the effort;comptime_str+@typeInfounlocks reflection;comptime_unroll forenables code generation - Follows established patterns: String-on-heap reuses the
ConstValue/ComptimeHeapItempattern; intrinsics reuse the@dbghandler pattern; anonymous structs reuse ADR-0025 Phase 4 infrastructure
Negative
- Interpreter complexity: ~4 new intrinsic handlers, a new type (
comptime_str), method dispatch for comptime strings, anonymous struct generation for@typeInfo, and loop unrolling logic forcomptime_unroll for - Compile time impact:
@typeInfoconstructs anonymous struct types on every invocation (mitigated by caching);comptime_unroll forduplicates body analysis N times comptime_stris notString: Two string types creates cognitive overhead. The distinction (comptime_stris compile-time only,Stringis runtime) is principled but users must learn it- New builtin enum:
TypeKindadds another compiler-injected enum (joiningArchandOs), but follows the establishedBuiltinEnumDefpattern @fieldrequires comptime name: You cannot access a field by a runtime-computed name. This is by design (field access must be resolved at compile time for type safety) but may surprise users
Neutral
- No new runtime functions: All changes are interpreter-only. The LLVM codegen is unaffected
- New spec section:
comptime_str,@typeInfo, andcomptime_unroll forextend spec section 4.14 - Keyword budget: One new keyword (
comptime_unroll). Thecomptime_prefix signals this is a compile-time construct (consistent withcomptime_str), andunrollcommunicates what it does — generating runtime code by unrolling over comptime data, which is distinct from a regularforloop running inside acomptimeblock - Comptime allocator deferred: Explicitly not included; will be a separate ADR when needed
Open Questions
(resolved):comptime_strequality semanticscomptime_strsupports the full set of comparison operators (==,!=,<,<=,>,>=) with lexicographic byte ordering. This is trivial to implement (RustString'sOrdimpl) and avoids an artificial limitation that would need to be relaxed later.@typeInfodepth: Should@typeInfofor a struct recursively include type info for its fields' types, or only include theTypevalue? Recursive expansion risks infinite loops for recursive types. Tentative answer: shallow — includefield_type: typevalues; the user calls@typeInfoagain on individual field types as needed.(resolved):comptime_unroll forover rangescomptime_unroll for i in @range(N)is the natural syntax, consistent with how regular for-loops already use@range. Both comptime arrays and@rangewith comptime-known arguments are supported as iterables.@fieldas lvalue: Should@field(val, name)work on the left side of assignment (@field(val, name) = 42)? This would require@fieldto produce a place expression, not a value. Tentative answer: read-only initially; add lvalue support later if needed.@compileLogin production: Should@compileLogbe a hard error (not just warning) in release builds, or always a warning? Zig makes it a hard error. Tentative answer: warning only, to match Gruel's less-opinionated stance; users can add CI lints.comptime_strinterning: Should identicalcomptime_strvalues share the same heap slot (interning), or does each occurrence allocate independently? Interning saves memory but adds lookup overhead. Tentative answer: no interning initially; the comptime heap is short-lived (cleared per block) so the waste is bounded.
Future Work
- Comptime allocator: Materializing comptime heap items (structs, arrays) into the compiled binary as static data, enabling comptime-computed lookup tables and configuration. Separate ADR.
comptime_strtoStringbridge: Ato_staticmethod that materializes acomptime_strinto a runtimeStringbacked by.rodata(using thecap = 0convention from string literals). Requires the comptime allocator or a special codegen path.@embedFile: Reading files at compile time, returningcomptime_str. Requires I/O policy decisions.@hasField/@hasMethod: Quick boolean intrinsics for checking type structure without full@typeInfo.comptime_unroll while: Compile-time unrolling of while loops with comptime conditions. Same unrolling machinery ascomptime_unroll for.- Comptime arrays of
comptime_str: Currently thesplitmethod is deferred because it would return[comptime_str; N]where N is comptime-determined. This requires comptime array construction withcomptime_strelements, which works naturally once both types are supported.
References
- ADR-0025: Compile-Time Execution — original comptime design
- ADR-0033: LLVM Backend and Comptime Interpreter — interpreter implementation
- ADR-0040: Comptime Interpreter Expansion — immediate predecessor; defined these as future work
- Zig
@compileError— inspiration for Phase 1 - Zig
@compileLog— inspiration for Phase 1 - Zig
@typeInfo— inspiration for Phase 3 - Zig
inline for— inspiration for Phase 4 (Gruel usescomptime_unroll forinstead) - Zig
@field— inspiration for Phase 4