hanki

16. Meta (compile-time evaluation)

meta is the only metaprogramming surface. Conceptually it's Zig's comptime: compile-time evaluation of regular functions and types-as-values.

meta def fib(n: i32) -> i32
  if n < 2 then n
  else fib(n - 1) + fib(n - 2)
  end
end

MAX_TODOS: i32 = fib(20)        # 6765, evaluated at compile time

Top-level value bindings are always compile-time-evaluated - the binding shape itself is the marker, so the meta keyword is not allowed on the RHS of a top-level binding; the parser rejects MAX_TODOS: i32 = meta fib(20) as redundant. The keyword is still required on meta function declarations (meta def fib) and inside expressions (x: i32 = (meta sq(3)) + 1 is fine - the meta there marks a sub-expression, not the whole RHS).

Constants in function bodies. Function bodies read top-level constants freely - a pure def, an empty-effect-row action, an impl method, or a trait default body may all compute from one (Dur(nanos=n * _NS), n * _SCALE); the resolved value is folded in when the module lowers for the runtime. The one ordering restriction runs the other way: a top-level binding's initializer may call functions (MAX_TODOS: i32 = fib(20) above), but not one whose own body reads a top-level constant - the constants are still being resolved when that call would run, so it is a compile error naming the function and the unresolved constant.

Compile-time fuel. Meta evaluation runs through the bytecode interpreter under a per-meta step budget (currently 10_000_000 bytecode steps). Infinite recursion or runaway loops in a meta def surface as a compile error rather than hanging hanki build. Callers that need a different ceiling - embedders sandboxing untrusted .config.hk files, or the hot-reload path - set the budget explicitly when calling the VM. The same fuel idea bounds a whole run at the bytecode tier: hanki run --max-steps N (and hanki test --max-steps) caps total bytecode steps across every actor, and hanki run --max-bytes N caps total heap-object allocation in bytes (both with a --deny/--allow default), faulting the run rather than letting untrusted code spin forever or exhaust memory - see §23.

Pure values and intrinsics in meta. The compile-time interpreter has no per-actor heap, but it holds aggregate values - structs, sum variants, and lists - inline alongside primitives, so comptime code can construct and read them; aggregates are immutable values (no in-place field write), so this matches the runtime exactly. It still rejects effectful and actor operations and the intrinsics that do I/O or mint a native resource. It evaluates a vetted set of pure intrinsics - the string methods, the persistent List operations, the hash_u64 primitives (pure, total, and byte-identical to the runtime's, so a value hashed at compile time lands where the same value hashed at run time does), and the BytesBuilder / BytesReader byte-buffer ops (so an @encapsulated builder-based serialization folds too) - so comptime code, and where-predicate folding in particular, can call them. The set is exactly the table below, which a test holds to the compiler's dispatch in both directions, so an intrinsic listed here is foldable and one absent from here is not. string.slice in that set is what makes a per-character walk expressible at compile time: s.slice(i, i + 1) is a one-character read, so a comptime scanner over a string literal needs no widening of the set. This is what lets Email.new("")'s value.length > 0 fold to a compile error (§9), an actor's static-default invariant fold at its declaration (§15), and a .config.hk file evaluate to a struct or list value (§21). A meta constant is itself comptime-known, so a predicate that reads one (value.length <= LIMIT) folds against its value like any literal. A predicate or construction that needs an impure or effectful intrinsic simply isn't folded - it falls back to the runtime check.

The comptime-foldable intrinsics. Anything not in this table is rejected at compile time with intrinsic <name> cannot be evaluated at compile time.

FamilyIntrinsics
stringstring.length, string.concat, string.starts_with, string.contains, string.find, string.join, string.slice, string.to_ascii_uppercase, string.to_ascii_lowercase
hash_u64string.hash_u64, bytes.hash_u64, i32.hash_u64
Listlist.List.empty, list.List.append, list.List.length, list.List.get, list.List.concat, list.List.slice, list.List.update
BytesBuilderBytesBuilder.new!, BytesBuilder.push!, BytesBuilder.extend!, BytesBuilder.finish!
BytesReaderBytesReader.new!, BytesReader.take!, BytesReader.remaining!, BytesReader.position!, BytesReader.peek!

@derive(...) is the standard mechanism for generated impls:

@derive(Eq, Display)
struct Point
  x: i32
  y: i32
end
# main.hk
open bytes_builder
open bytes_reader
open decode
open deserializer
open encode
open io
open list
open option
open result
open serializer

@derive(Encode, Decode)
struct Point
  x: u8
  y: u8
end

def main!(args: List<string>) -> () [io]
  ser = BinarySerializer(out=BytesBuilder.new!())
  Point(x=1u8, y=2u8).encode!(ser)
  # decode reads the value back from a cursor over the written bytes
  de = BinaryDeserializer(src=BytesReader.new!(ser.out.finish!()))
  round_trips = match Point.decode!(de, 0)
    Ok(p)  -> p.x == 1u8 and p.y == 2u8
    Err(_) -> false
  end
  print!("#{round_trips}")   # true
end

Module-provided derives. Three further derives live in the standard library rather than this core structural set, and are @derive-only - not auto-synthesised, because their use sites (a test parameter, a query Row, a parsed element) don't pin the target type the way .encode! or == do. @derive(Arbitrary) (§20, the arbitrary module) generates a property-test generator; @derive(FromRow) (the sqlite module, §17) generates a from_row(r) -> Result<Self, MapError> that reads each struct field from the column of the same name - int / f64 / string / bytes / bool, and Option<_> of those for a nullable column, short-circuiting on the first missing / wrong-typed column; @derive(FromXml) (the xml module, §17) generates a from_xml(element) -> Result<Self, XmlShapeError> reading each struct field from the CHILD ELEMENT of the same declared name via that type's own FromXml (so nested records compose), with Option<T> an optional child, List<T> the same-named children in document order, and attributes deliberately not auto-mapped (reach them by hand via require_attribute - no tag syntax). For all three, a sum, a generic struct, or a field whose type has no mapping is a compile error naming the offending shape at the @derive site.

Auto-derivation. The derivable traits need no @derive at all. A non-generic struct or sum reached where it needs an impl with none in scope has one synthesised on demand - the same impl @derive(...) would generate, recursing through its field types. The trigger is the use: Encode / Decode at .encode! / Type.decode! (or a T: Encode / T: Decode bound); Display at #{…} interpolation and .to_string(); Eq at any ==/!= - a bare concrete == synthesises Eq too, exactly like the ==-through-a-generic case (§10); there is no structural-compare fallback; Hash at a .hash read (which also pulls in its Eq supertrait). Synthesis is demand-driven: a type is only required to satisfy a trait where it actually is used at one, so a struct that merely holds a non-derivable value is fine until that use. A field that cannot carry the trait - a resource, an actor reference, a function value, or a type with no impl of that trait (f64 has Encode and Display but no Eq/Hash, so a struct with an f64 field can derive the first two but not the last two) - is a compile error naming the field, not a broken impl. Generic user types derive too: one generic impl is synthesised (impl<A: Encode> Encode<Wrap<A>>) and each instantiation resolves through its type-param dictionaries (§10). A generic type's Decode is reached through a T: Decode bound rather than Wrap.decode!() directly.

There are no AST macros. If @derive cannot do something, you write the impl by hand.