Compile-Time Overview

Why not generics?

Tape does not have generics (<T>). Instead, compile-time code execution provides the same power:

  • No type inference complexity
  • No monomorphization
  • No trait bounds or variance
  • Explicit, debuggable, and visible

@tape blocks

tape
@tape {
    // this code runs at compile time
    gen_queue(i64);
    gen_queue(Event);
}

Code inside @tape { } executes during compilation. It is ordinary tape: every block, @tape const and @tape fn, generators included, runs in the compiler’s VM (the interpreter tape run uses), so it can use the whole language and call the program’s own functions. There is no second, smaller interpreter for compile-time code: what it can do is what the language does, less the few things listed under What can run at compile time.

@tape fn

Compile-time functions are declared with @tape fn. They can be called from @tape {} blocks:

tape
@tape fn gen_queue(T: type) {
    @println("Generating Queue_{T}");
    @emit struct Queue_${T} {
        items: *${T}; count: i64; cap: i64;
    }
    @emit fn queue_${T}_push(q: *Queue_${T}, item: ${T}) {
        // grow + append
    }
    @emit fn queue_${T}_pop(q: *Queue_${T}) -> ?${T} {
        // remove + return
    }
}

@tape { gen_queue(i64); }
@tape { gen_queue(Event); }

@emit

@emit injects declarations into the module being compiled:

FormInjects
@emit struct Name { ... }Struct declaration
@emit tagged Name { ... }, @emit union Name { ... }Tagged or untagged union declaration
@emit type Name = T;Type alias
@emit component Name { ... }Component (view tree and @tape blocks included)
@emit test "name" { ... }A test in the caller’s #test region
@emit @tape fn name(...) { ... }A generator for later @tape blocks
@emit attr name;, @emit @packed struct ...Attribute declaration; attributes on emitted declarations
@emit fn name(...) { ... }Function declaration
@emit const NAME = expr;Constant
@emit field: Type;Field (inside struct @tape {})
@emit prop name: Type;Prop (inside component @tape {})
@emit state, @emit event, @emit slot, @emit fnState flag, event, slot, method (inside component @tape {})
@emit var name: Type;Var (inside component @tape {})
@emit @dispatch fn ...Dispatch (inside component @tape {})

$ splicing

Names containing $ are substituted at emit time:

  • $var — simple variable substitution (greedy identifier chars)
  • ${var} — braced substitution (explicit boundary)
  • ${var.field} — dotted path (access compound value fields)
tape
@tape fn gen_getter(T: type, field_name: string) {
    @emit fn get_${field_name}(obj: *${T}) -> i64 {
        return obj.${field_name};
    }
}

Values are spliced as: types → type name (i64, Point, cslice_u8 for []const u8), strings → string value, integers → decimal text. A type splice that is a whole type (obj: *${T}, -> $T) is the type itself, so $T works for []const u8 or geo.Point too.

type as a value

At compile time, type is a first-class value. Type names resolve to type values:

tape
@tape fn print_fields(T: type) {
    var info = @typeof(T);
    @println("struct {info.name} (size={info.size}, align={info.align}):");
    for (field in info.fields) {
        @println("  {field.name} : offset={field.offset}, size={field.size}");
    }
}

@typeof(T) returns a TypeInfo compound with fields: name, kind, size, align, fields, variants, is_const, len, is_volatile. Each field element has: name, type, offset, size, __attrs, has_default.

comptime match

Match on a type value at compile time:

tape
@tape fn emit_default(T: type) {
    comptime match(T) {
        i64, i32 => { @emit const DEFAULT = 0; }
        f64      => { @emit const DEFAULT = 0.0; }
        _        => { @compile_error("unsupported type"); }
    }
}

Arms match type names. _ is the wildcard. Multiple types per arm separated by commas.

What can run at compile time

Compile-time code is checked and run as t1 code, whatever the module’s profile: in a t0 module’s @tape code string is the owned string, and the view is written []const u8. It is typechecked like any code, a @tape fn before anything calls it (tape check reports a broken generator nobody calls). Blocks, @tape consts and @tape fns, generators included, can use:

  • The whole language: structs, enums, tagged unions and match, ?T, T or E with or return / or { }, defer, pointers to locals, strings, slices and allocation
  • Ordinary functions: the program’s own, and those of pure stdlib modules (str, json, bytes, collections’ emitted code, …), so one function serves both sides:
tape
fn fib(n: i64) -> i64 { if (n < 2) { return n; } return fib(n - 1) + fib(n - 2); }
@tape const F20: i64 = fib(20);   // 6765, computed by the compiler

import json from "json";
@tape fn port(text: []const u8) -> i64 {
    let v = json.parse(text) or { return -1; };
    let p = v.get("port") or { return -1; };
    return p.as_i64() or { return -1; };
}
@tape const PORT: i64 = port(@embed("./config.json"));   // {"port": 8080}
  • A @tape const of a struct, enum or tagged union, as well as numbers, bools, strings and arrays
  • A plain const whose initializer calls a function, computed by the compiler like a @tape const (the same rules for what the call reaches). A const without a call is folded and runs nothing:
tape
fn sq(x: i64) -> i64 { return x * x; }
const AREA: i64 = sq(4);                  // 16
const ROOT: f64 = math.sqrt_f64(2.0);     // 1.4142...
const NEXT = AREA + 1;                    // 17
const MASK = 1 << 4;                      // folded: no code runs

They cannot call a capability module’s functions (io, fs, net, os, …: a compile error naming the module), an extern fn, or read or write the module’s own vars (it has no state before the program runs; its lets and consts are readable). The same holds for everything a call reaches: a program function that reads a var or calls io.println is a compile error when compile-time code calls it, noted at the call. A function that uses what a later @tape block emits is not ready yet: calling it is a compile error that names what it uses and the block that emits it:

tape
import collections from "collections";
fn total() -> i64 { var v: Vec_i64 = vec_i64_new(); v.push(1); return v.len; }
@tape const T: i64 = total();
// error: 'total' cannot run at compile time here: it uses 'Vec_i64', which a
//        later @tape block emits (the block at line 6)
@tape { collections.gen_vec(i64); }

Once that block has run, compile-time code after it may call total.

A generator also has what it needs to write declarations:

  • @emit to generate declarations; a splice (${expr}) is any expression of the generator’s code, computed when the @emit runs, so it may call a helper or an ordinary function (fn_${name_of(T)})
  • @typeof, @sizeof, @alignof for type reflection: a type’s fields, variants and methods (@typeof(T).methods), a component’s props, events, slots and vars, and a function’s parameters and result (@typeof(f)), so a generator can write one declaration per member (reflection); @has_field, @has_method, @inner_type, @field_type for type queries
  • comptime match(T) for type-based dispatch, T == string, @typeof(T).kind == TypeKind.Struct for type dispatch
  • A type an earlier statement of the same code emitted, named where a type goes (@emit struct Vec3 { ... } then @sizeof(Vec3)): names resolve when the code runs
  • Function parameters (less: fn(*const T, *const T) -> bool) that emitted code calls (function parameters); in @tape code such a function is spliced, passed on, or called when its signature names no type parameter (f: fn(i64) -> i64, then f(1))
  • Module parameters (m: module) with @moduleinfo(m) and @module_api(m, names)
  • field.has_attr(a) and field.attr(a) for attribute queries, on a struct’s fields, the type itself (@typeof(T)) and a module’s functions (@moduleinfo(m).functions)
  • @assert, @compile_error, @print / @println, @embed

Fuel limit

Each top-level compile-time execution (a @tape block, a constant’s initializer, a @tape block inside a function body) may run 10 million operations (in the VM, one operation is one instruction); a generator it calls spends from the same budget. Exceeding it is a compile error at the operation that ran out, which names the flag that raises it: --tape-fuel <n> on tape build, run, check and test. This prevents accidental infinite loops from stalling compilation.

A trap in compile-time code (an index out of bounds, ! on null) is a compile error where it trapped, trap at compile time: index out of bounds, with a note at each call that led there.

Last modified: