ADR-0025: Compile-Time Execution (comptime)
Status
Implemented
Summary
Introduce a unified compile-time execution model inspired by Zig, where comptime marks expressions and parameters that must be evaluated at compile time, and type becomes a first-class comptime-only value. This provides the foundation for generics while also enabling powerful compile-time computation as a feature in its own right.
Context
Gruel currently has basic constant expression evaluation (ADR-0003) that handles arithmetic on literals for compile-time bounds checking. However, this is limited:
- No user-visible constant declarations: Users can't define named compile-time constants
- No compile-time functions: Can't run arbitrary code at compile time
- No generics: No way to parameterize functions or types over other types
- No type-level computation: Can't compute types based on other types
Other languages have approached this differently:
- Rust:
const fnwith gradually expanding capabilities, separate generics syntax with<T> - C++:
constexpr/constevalwith template metaprogramming for generics - Zig: Unified
comptimemodel where types are first-class values
Zig's approach is elegant because it uses the same syntax and semantics for both compile-time computation and generics. A generic function is simply a function with comptime parameters. This reduces conceptual overhead and makes the language more orthogonal.
Why Zig's Model?
- Unified mental model: One concept (
comptime) instead of multiple (const fn, generics, macros) - Types as values:
typeis a comptime-only type, sofn foo(comptime T: type)is just a function that takes a type - Same syntax: Comptime code looks like runtime code, just with
comptimeannotations - Powerful metaprogramming: Compile-time loops, conditionals, and function calls enable sophisticated code generation
Decision
Core Concepts
1. The comptime Keyword
comptime is a guarantee that an expression will be evaluated at compile time. If evaluation fails (e.g., due to runtime dependencies), it's a compile error.
// Comptime block - must evaluate at compile time
const SIZE: i32 = comptime { 1024 * 1024 };
// Comptime parameter - caller must provide a comptime-known value
fn repeat(comptime n: i32, value: i32) -> i32 {
// n is known at compile time, so this loop can be unrolled
let mut sum = 0;
let mut i = 0;
while i < n {
sum = sum + value;
i = i + 1;
}
sum
}
2. The type Type
type is a comptime-only type whose values are types themselves:
fn max(comptime T: type, a: T, b: T) -> T {
if a > b { a } else { b }
}
fn main() -> i32 {
max(i32, 10, 20) // T = i32, returns 20
}
The value i32 has type type. Since type is comptime-only, it can never exist at runtime.
3. Comptime-Only Values
Some values can only exist at compile time:
| Type | Description |
|---|---|
type | Type values (e.g., i32, bool, MyStruct) |
comptime_int | Arbitrary-precision integers (future) |
comptime_float | Arbitrary-precision floats (future) |
Attempting to store these in a runtime variable is a compile error:
fn main() -> i32 {
let t: type = i32; // ERROR: type 'type' cannot exist at runtime
0
}
4. Monomorphization
When a function has comptime type parameters, each unique combination of comptime arguments creates a specialized version:
fn max(comptime T: type, a: T, b: T) -> T {
if a > b { a } else { b }
}
fn main() -> i32 {
let x = max(i32, 1, 2); // Generates max__i32
let y = max(i64, 3, 4); // Generates max__i64
x
}
After monomorphization, AIR contains no generic functions - only concrete specialized versions.
Syntax
Comptime Blocks
comptime { <expr> }
The expression inside must be evaluable at compile time. The result replaces the comptime block.
Comptime Parameters
fn name(comptime param: Type, ...) -> ReturnType { ... }
Parameters marked comptime must be provided with comptime-known values at every call site.
Const Items
const NAME: Type = <expr>;
The expression must be comptime-evaluable. If it's not obviously constant, use comptime { }:
const TABLE_SIZE: i32 = comptime { compute_size() };
Type Parameters
fn generic(comptime T: type, value: T) -> T { ... }
This is just a comptime parameter whose type is type.
Semantics
Evaluation Order
Comptime evaluation happens during semantic analysis (Sema), after parsing and before code generation:
Source → Lexer → Parser → RIR → Sema (comptime eval here) → AIR → Codegen
When Sema encounters a comptime context:
- It evaluates the expression using the comptime interpreter
- If successful, the result replaces the original expression
- If unsuccessful (runtime dependency), emit a compile error
Comptime Context Propagation
Inside a comptime context, everything is comptime:
comptime {
let x = 1 + 2; // Comptime evaluation
let y = x * 3; // Also comptime
foo(x, y) // foo must be callable at comptime
}
What Can Run at Comptime
Initially (Phase 1-2):
- Arithmetic operations
- Comparisons
- Logical operations
- Variable bindings
- Control flow (if/else, while, loops)
- Function calls (to "pure" functions)
Future extensions:
- Struct/array construction
- Pattern matching
- More complex control flow
What Cannot Run at Comptime
- I/O operations
- System calls
- Accessing runtime memory
- Calling functions with side effects
- Operations that would panic (result in compile error instead)
Error Handling
Comptime errors are compile errors:
const X: i32 = comptime { 1 / 0 }; // Compile error: division by zero
Type System Integration
ConstValue Extension
The existing ConstValue enum will be extended:
Type as a Type
A new Type::ComptimeType variant represents the type type:
Comptime Context Tracking
Sema tracks whether it's in a comptime context:
Operations check this to know if they must be comptime-evaluable.
Monomorphization Strategy
When Sema encounters a call to a generic function:
- Evaluate comptime args: All comptime arguments are evaluated to ConstValue
- Generate key: Create a unique key from (function_name, comptime_args)
- Check cache: If this specialization exists, use it
- Specialize: Otherwise, create a new specialized function:
- Clone the RIR function
- Substitute comptime parameters with their concrete values
- Analyze the specialized body
- Store in specialization cache
- Emit call: The call becomes a call to the specialized function
Anonymous Struct Types (Future)
Comptime functions can return types, enabling patterns like:
fn Pair(comptime T: type, comptime U: type) -> type {
struct {
first: T,
second: U,
}
}
fn main() -> i32 {
let p: Pair(i32, bool) = Pair(i32, bool) { first: 42, second: true };
p.first
}
This requires:
- Anonymous struct type syntax
- Comptime struct construction
- Type equality based on structural equivalence
This is deferred to Phase 4.
Implementation Phases
Epic: gruel-33lf (closed)
Phase 1: Comptime Expressions (gruel-3xoq) - Complete
Goal: comptime { expr } syntax with basic expression evaluation.
- Add
comptimekeyword to lexer - Add
ComptimeBlockAST/RIR node - Add comptime context tracking in Sema
- Extend
try_evaluate_const()to handle comptime blocks - Gate behind preview flag
comptime - Add spec tests for comptime expressions
Deliverable: Users can write const X: i32 = comptime { 1 + 2 * 3 };
Phase 2: Comptime Parameters (Value) (gruel-ya9i) - Complete
Goal: Functions can take comptime value parameters.
- Add
comptimeparameter modifier to parser - Track comptime parameters in function signatures
- Validate comptime args are comptime-known at call sites
- Implement function specialization for comptime value params
- Add spec tests
Deliverable: Users can write fn repeat(comptime n: i32, x: i32) -> i32
Phase 3: Type Parameters (gruel-fbwv) - Complete
Goal: The type type and comptime type parameters.
- Add
Type::ComptimeTypevariant - Add
ConstValue::Type(TypeId)variant - Parse
typeas a type name - Implement type parameter substitution in specialization
- Add spec tests for generic functions
Deliverable: Users can write fn max(comptime T: type, a: T, b: T) -> T
Phase 4: Comptime Type Construction (gruel-ak9z) - Complete
Goal: Comptime functions can construct and return types.
- Anonymous struct type syntax
- Comptime struct construction
- Structural type equality
- Add spec tests
Deliverable: Users can write fn Pair(comptime T: type) -> type { struct { ... } }
Consequences
Positive
- Unified model: One concept for const evaluation, metaprogramming, and generics
- Same syntax: Comptime code uses normal Gruel syntax, no special template language
- Incremental adoption: Each phase adds value; Phase 1 is useful standalone
- Type safety: Types are first-class values but still statically checked
- Zero runtime cost: All comptime computation happens at compile time
- Foundation for stdlib: Enables generic
Vec<T>,HashMap<K,V>, etc.
Negative
- Compile time increase: More work at compile time, especially with heavy monomorphization
- Code bloat: Each specialization generates new code (mitigated by deduplication later)
- Complexity: Sema becomes more complex with comptime interpreter
- Error messages: Comptime errors can be confusing (which instantiation failed?)
- Learning curve:
comptimeis a new concept for users from Rust/C++
Neutral
- Different from Rust: Users expecting
<T>syntax will need to learn the new model - Supersedes ADR-0003: The comptime infrastructure subsumes constant evaluation
Open Questions
Comptime variable declarations: Should
comptime let x = ...exist, or only const items?- Tentative answer: Only const items initially.
comptime { let x = ... }works inside blocks.
- Tentative answer: Only const items initially.
Comptime function annotation: Should functions be explicitly marked
comptime fn?- Tentative answer: No. Any function callable at comptime can be, if all inputs are comptime-known.
Recursion limits: How do we prevent infinite compile-time recursion?
- Tentative answer: Configurable recursion/iteration limits with reasonable defaults.
Comptime strings: Should string literals be comptime values?
- Tentative answer: Defer to future work. Focus on numeric types and
typefirst.
- Tentative answer: Defer to future work. Focus on numeric types and
Trait/interface bounds: How do we express "T must support +"?
- Tentative answer: Out of scope for this ADR. Will need a separate traits/interfaces design.
Future Work
These are explicitly out of scope for this ADR:
- Traits/Interfaces: Constraining type parameters (e.g.,
T: Comparable) - Comptime allocations: Allocating memory at compile time (like Zig's comptime allocator)
- Comptime I/O: Reading files at compile time (
@embedFileequivalent) - Comptime reflection: Introspecting types at compile time (
@typeInfoequivalent) - Comptime strings: String manipulation at compile time
- Associated types: Types defined in trait implementations
- Higher-kinded types: Types parameterized over type constructors
References
- ADR-0003: Constant Expression Evaluation - Foundation this builds on
- Zig Language Reference: comptime - Primary inspiration
- Zig's compile-time reflection - Future direction
- Rust const generics - Alternative approach
- C++ constexpr - Another alternative
Appendix: Example Code
Phase 1: Comptime Expressions
// Compile-time arithmetic
const BUFFER_SIZE: i32 = comptime { 4 * 1024 };
const FLAGS: i32 = comptime { 1 | 2 | 4 };
fn main() -> i32 {
// Can use comptime inline too
let x = comptime { 10 * 10 };
x + BUFFER_SIZE
}
Phase 2: Comptime Value Parameters
// Compile-time loop unrolling
fn sum_n(comptime n: i32) -> i32 {
let mut total = 0;
let mut i = 0;
while i < n {
total = total + i;
i = i + 1;
}
total
}
fn main() -> i32 {
sum_n(5) // Unrolled at compile time: 0+1+2+3+4 = 10
}
Phase 3: Generic Functions
fn swap(comptime T: type, a: T, b: T) -> (T, T) {
(b, a)
}
fn identity(comptime T: type, x: T) -> T {
x
}
fn main() -> i32 {
let (y, x) = swap(i32, 1, 2);
identity(i32, x + y)
}
Phase 4: Type Construction (Future)
fn Array(comptime T: type, comptime N: i32) -> type {
struct {
data: [T; N],
len: i32,
}
}
fn main() -> i32 {
let arr: Array(i32, 10) = Array(i32, 10) {
data: [0; 10],
len: 10,
};
arr.len
}