hanki

16. Meta (compile-time evaluation)

meta is the only metaprogramming surface. It is 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

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

Top-level value bindings are always compile-time-evaluated. The binding form is the marker, the meta keyword is not allowed on the right-hand side of a top-level binding, and 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 marking a sub-expression and not the whole right-hand side.

Function bodies read top-level constants freely. A pure def, an empty-effect-row action, an impl method and a trait default body may all compute from one (Dur(nanos=n * _NS), n * _SCALE), and the resolved value is folded in when the module lowers for the runtime. A folded primitive is free at run time; a folded aggregate is not. A string, an int or a bool interns into the constant pool, and a reference loads it with a refcount bump, and for those the fold is the whole story. A list, struct, sum variant or tuple interns as one pool entry too, and a reference is a single LoadConst in place of a push per field. The pool is per-module while the value it names is a heap object in a per-actor heap, and interning alone therefore does not hand a reference the value. Both tiers materialise it once and cache it, and only the first reference builds anything while the rest read a slot and take a handle: the bytecode tier once per actor, the AOT tier once per thread. Naming a folded aggregate inside the function that runs per input therefore costs a slot read and a branch in place of a rebuild, and hoisting it out of the loop and passing it in is no longer the difference it was when the AOT rebuild cost about 860 ns for a small peg grammar. A constant sits at its module-qualified name like every other top-level item (§14), and a reader in another file spells it limits.MAX_TODOS and gets the same folded value, from another constant's initializer included, subject to the ordering restriction below. That one restriction runs the other way: a top-level binding's initializer may call functions, MAX_TODOS: i32 = fib(20) above, and may not call one whose own body reads a top-level constant. The constants are still being resolved when that call would run, and it is a compile error naming the function and the unresolved constant.

Meta evaluation runs through the bytecode interpreter under a per-meta step budget, currently 10_000_000 bytecode steps. Infinite recursion or a runaway loop in a meta def surfaces as a compile error in place of hanging hanki build. Callers that need a different ceiling, embedders bounding 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 and --allow default. Either faults the run in place of letting untrusted code spin forever or exhaust memory. See §23.

The compile-time interpreter has no per-actor heap. It stores aggregate values, structs, sum variants, lists and arrays inline alongside primitives, and comptime code can construct and read them. Aggregates are immutable values with no in-place field write; Array.set produces a copied comptime value, matching its observable runtime semantics. It 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 numeric Array operations, the hash_u64 primitives, which are pure, total and byte-identical to the runtime's, and a value hashed at compile time lands where the same value hashed at run time does, and the BytesBuilder and BytesReader byte-buffer ops, which is what makes an @encapsulated builder-based serialization fold. Comptime code, and where-predicate folding in particular, can call them. The set is the table below, and a test checks it against the compiler's dispatch each way: 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) being a one-character read, and 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, and 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 is not folded and falls back to the runtime check. Folding is half of reaching a top-level binding: the folded value must also be embeddable as a runtime constant, and the embeddable kinds are the primitives, string, bytes, and struct, list, array, variant and tuple aggregates of those. bytes is what makes a comptime-computed binary table reachable, DFA transitions, a CRC table, or an asset assembled through a BytesBuilder, where List<int> would be an RRB trie descended per lookup. A closure, a live comptime resource, and an in-band ±inf or undefined have no constant form, and a meta const resolving to one is a compile error naming the binding.

The table below lists every comptime-foldable intrinsic. Anything absent from it is rejected at compile time with intrinsic <name> cannot be evaluated at compile time. The exact-tier row is the pair int.pow leaves and returns through, which lets a top-level 10.pow(k) fold; the rest of that family is out until something needs it. The @encapsulated JSON parsers fold through this set, including the strict pair, which makes a comptime JSON constant expressible, and codec.to_bytes and codec.from_bytes fold too, which lets a binary constant be built and read back at compile time.

FamilyIntrinsics
stringstring.length, string.starts_with?, string.contains?, string.find, string.split, string.trim_start, string.trim_end, string.join, string.slice, string.to_ascii_uppercase, string.to_ascii_lowercase, string.to_bytes
hash_u64string.hash_u64, bytes.hash_u64, i32.hash_u64
bytesbytes.compare, bytes.length, bytes.get, bytes.get_or, bytes.to_string
intint._parse, u8.to_int, f64._parse, i32.to_u32, u32.to_i32, u32.count_ones, u32.to_int
exact tierint.to_rational, rational.to_int
bitwisei8.bit_and, i8.bit_or, i8.bit_xor, i8.bit_not, i8.bit_shl, i8.bit_shr, i16.bit_and, i16.bit_or, i16.bit_xor, i16.bit_not, i16.bit_shl, i16.bit_shr, i32.bit_and, i32.bit_or, i32.bit_xor, i32.bit_not, i32.bit_shl, i32.bit_shr, i64.bit_and, i64.bit_or, i64.bit_xor, i64.bit_not, i64.bit_shl, i64.bit_shr, u8.bit_and, u8.bit_or, u8.bit_xor, u8.bit_not, u8.bit_shl, u8.bit_shr, u16.bit_and, u16.bit_or, u16.bit_xor, u16.bit_not, u16.bit_shl, u16.bit_shr, u32.bit_and, u32.bit_or, u32.bit_xor, u32.bit_not, u32.bit_shl, u32.bit_shr, u64.bit_and, u64.bit_or, u64.bit_xor, u64.bit_not, u64.bit_shl, u64.bit_shr
fixed-width narrowingi16.to_i8, i16.try_to_i8, i32.to_i8, i32.try_to_i8, i32.to_i16, i32.try_to_i16, i64.to_i8, i64.try_to_i8, i64.to_i16, i64.try_to_i16, i64.to_i32, i64.try_to_i32, u16.to_u8, u16.try_to_u8, u32.to_u8, u32.try_to_u8, u32.to_u16, u32.try_to_u16, u64.to_u8, u64.try_to_u8, u64.to_u16, u64.try_to_u16, u64.to_u32, u64.try_to_u32
fixed-width wideningi8.to_i16, i8.to_i32, i8.to_i64, i16.to_i32, i16.to_i64, i32.to_i64, u8.to_u16, u8.to_u32, u8.to_u64, u16.to_u32, u16.to_u64, u32.to_u64
Listlist.List.empty, list.List.append, list.List.length, list.List.get, list.List.get_or, list.List.concat, list.List.slice, list.List.update
Arrayarray.Array._filled, array.Array._from_list, array.Array.length, array.Array._get, array.Array._get_or, array.Array._set, array.Array._copy_resize
BytesBuilderBytesBuilder.new!, BytesBuilder.push!, BytesBuilder.extend!, BytesBuilder.reserve!, BytesBuilder.length!, BytesBuilder.fill!, BytesBuilder.set!, BytesBuilder.finish!
BytesReaderBytesReader.new!, BytesReader.take!, BytesReader.remaining!, BytesReader.position!, BytesReader.peek!, BytesReader.take_u8!, BytesReader._take_uint_be!, BytesReader._take_uint_le!, BytesReader.checkpoint!, BytesReader.consume_checkpoint!, BytesReader.skip!
StringBuilderStringBuilder.new!, StringBuilder.push!, StringBuilder.finish!

The builder's spare_capacity! is absent. The room a reserve! wins is this compiler's allocator rounding it up, and folding the observation would bake a figure that differs between compiler builds into a constant. The reserve! itself folds, a hint changing nothing a comptime value can see, which is what leaves an @encapsulated encoder foldable once someone adds one to it.

@derive(...) is the standard mechanism for generated impls. Its targets are a struct and a type; on an actor it is refused (H0405), an actor having no shape a generator can walk.

@derive(Eq, Display)
struct Point
  x: i32
  y: i32
end
# main.hk
open deserializer
open io
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

Four further derives are in the standard library, outside this core structural set, and are @derive-only and never auto-synthesised: their use sites, a test parameter, a query Row, a parsed element, a capture tree, do not 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 and bool, and Option<_> of those for a nullable column, short-circuiting on the first missing or 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 through that type's own FromXml, which composes nested records, with Option<T> an optional child and List<T> the same-named children in document order; attributes are not auto-mapped, and require_attribute reaches them by hand, with no tag syntax. @derive(FromCaptures) (the peg module, §17) generates a from_captures(c) -> Result<Self, CaptureShapeError> reading each field from the capture tagged with its name, through tagged, with Option<T> and List<T> selecting the optional and repeated forms. That is the mapping FromXml applies to child elements, a grammar's tag(name, p) and an XML child being one idea in two syntaxes. It is what makes a grammar written as text win on size: on tools/bench/url_peg.hk the hand-written bridge cost 168 tokens against the grammar's own 136, putting peg 19 percent over the hand-written parser it was measured against, and derived, the whole fixture is 189 tokens against that parser's 255. A sum is refused and never guessed at: a capture tree records which tag matched, and never which alternative of an ordered choice was taken. For all four, a sum, a generic struct, or a field whose type has no mapping is a compile error naming the offending form at the @derive site.

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 and Decode at .encode! and Type.decode!, or at a T: Encode or T: Decode bound; Display at #{…} interpolation and .to_string(); Eq at any == or !=, a bare concrete == synthesising Eq too, like the ==-through-a-generic case (§10), with no structural-compare fallback; and Hash at a .hash read, which also pulls in its Eq supertrait. Synthesis is demand-driven: a type is required to satisfy a trait only where it is used at one, and a struct that merely contains a non-derivable value is fine until that use. A field that cannot take the trait, a resource, an actor reference, a function value, or a type with no impl of that trait, is a compile error naming the field, and no broken impl. f64 has Encode and Display and no Eq or Hash, and a struct with an f64 field can derive the first two and not the last two. A stdlib container field is asked the same question about the container itself: synthesis recurses into List<T>'s, Option<T>'s and Map<K, V>'s element types, and the container's own impl of that trait is hand-written and has to exist. A List<i32> field therefore derives Display and blocks Hash. () is an ordinary type here. It implements Eq, Hash and Display, and a () field derives those three and blocks only the codec pair, where f64 blocks the two it lacks. Equality on a singleton is no judgement call, there being one value and it equalling itself, and it is what makes Result<(), E> and Option<()> comparable, the form every try_each!-style action returns. Display is not settled by that argument, what () should print being a free choice. It renders as the two characters (), chosen because the derive has to print something for a unit field and that is the only spelling which round-trips to the source, and Slot(tag="x", nothing=()) renders as written. 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 and not through Wrap.decode!() directly.

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