Skip to main content

Compile-time parameters and control flow

Everything on this page runs before name resolution, not at runtime. A param binds a compile-time-known value; comptime for/comptime if copy and specialize a chunk of source text once per iteration or branch, at compile time, using that value; [<...>] and str!(...) build new identifiers and strings out of it. None of these constructs, nor any trace of them, survives into the module the rest of the compiler sees — by the time name resolution runs, the expanded output looks exactly like a module you could have written by hand.

This is a distinct pipeline stage, expand, that runs between the parser and the resolver: lexer -> parser -> expand -> resolve -> typeck -> lower -> ir. An error here is reported as an expansion diagnostic, in its own phase between syntax and resolution (see Diagnostics).

param: a compile-time-known value with a default

param N: i32 = 4;
param Debug: bool = false;
param Label: string = "v1";

A param has one of four types — i32, i64, bool, or string. None of these is a new runtime value type. string is comptime-only and can never appear in a runtime expression or a global initializer — it only ever exists as text, consumed by str!(...)/[<...>] (below). An i32/i64/bool param reaches runtime two separate ways: spliced in as an ordinary literal token via $name during expansion (below), or — independently of that — its bare name read directly, like an ordinary identifier, in any runtime expression or a global initializer (see Reading a param's bare name). A param's default must fit its declared type's range at the declaration site.

Override a default from the command line without touching the source, with a repeatable --define:

$ reedc build app.reed --define N=6 --define Debug=true

--define's value is parsed according to the named param's type: decimal text for i32/i64, true/false for bool, or the literal text itself for string. A malformed value is an expansion error (--define value 'six' is not a valid integer, --define value 'yes' is not 'true' or 'false') that still names the param's source declaration.

A param is consumed entirely during expansion when it's used through $name/ [<...>]/str!(...) splicing — there is no runtime global backing that mechanism, and nothing to look up in a later pass for it. This also means, unlike every other declaration in this language, a param's position in the file matters for splicing: expand processes a module top-to-bottom in one pass, so a param must appear before any splice that reads it.

Reading a param's bare name

Separately from splicing, an i32/i64/bool param's bare name is also a valid Ident in any ordinary expression, and in a global initializer — not only inside an comptime for/comptime if body:

param Limit: i32 = 100;

func check() -> i32 {
  if (Limit != 100) { return 1; }
  return 0;
}

export func check as "check";

This resolves the same way a read of an imported immutable global does (a later pass over Env.params, not an expansion-time text substitution), so, unlike splicing, the param's position in the file does not matter for this form — a function defined before its param declaration can still read it. A string param's bare name is still rejected in this position (param 'X' has type 'string', which has no runtime value and cannot be used as a value expression), matching the existing restriction on splicing a string param via $name. A param name also can't collide with a global name, since resolving a bare PascalCase identifier must be unambiguous between the two.

const: a compile-time value that is derived, not configured

A const is a param with the two differences that matter: its value is an expression rather than a literal, and --define cannot change it.

param Slots: i32 = 4;
const Mask: i32 = Slots - 1;
const Wide: bool = Slots > 2;

func check() -> i32 {
  if (Mask != 3) { return 1; }
  comptime if (Wide) { return 0; }
  return 2;
}

export func check as "check";

Reach for param when a build should be able to turn the knob, and const when the value follows from one that already can. Above, a build may pick Slots, but Mask is Slots - 1 in every build, and saying so is what stops the two from drifting apart.

A const takes the same four types as a param (i32, i64, bool, string), is checked against that type, and is range-checked at i32 width. It is valid everywhere a param is: an ordinary expression, a global initializer, a switch case value, a comptime if condition, a comptime for bound, and the $name/[<...>]/str!(...) splice forms below.

Its initializer goes through the same evaluator a comptime if condition does, so its operands must be compile-time known — a param, an earlier const, an enum member, a loop variable, or a literal. Naming a global there is an error, not a deferred one:

global Counter: i32 = 5;
const Derived: i32 = Counter + 1;   // error: 'Counter' is not a compile-time-known value

Order matters, unlike a param's bare-name read

Expansion is a single forward pass, so a const must be declared above anything it reads:

const A: i32 = B;   // error: 'B' is not a compile-time-known value here
const B: i32 = 1;

This is the same rule param and macro already carry, and it is a genuine difference from the bare-name read described above: a function may read a const declared below it, because that read is resolved by a later pass. Only another compile-time initializer needs the declaration to come first.

string consts

string is the one type with no runtime representation, so a string const exists only to be compared, concatenated, and stringized:

const Prefix: string = "reed";
const Full: string = Prefix + "c-1";
const Matches: bool = Full == "reedc-1";

comptime if (Matches) {
  @custom("build-id", str!(Full));
}

+ concatenates and ==/!= compare; there is no ordering comparison, since the language does not define one over strings. Using a string const as a runtime value is an error, exactly as it is for a string param.

comptime for / comptime if: compile-time control flow

comptime for and comptime if reuse the ordinary for/if grammar verbatim, with an inline prefix, and work at both module scope (declaration position) and inside a function body (statement position):

comptime for i in 0..N {
    // one copy of this body per value of i, i = 0, 1, ..., N-1
}

comptime if (Debug) {
    // this branch only
} else {
    // or this one
}

The difference from a plain for/if is what's allowed in the range/condition and what happens to the body. A plain for's bounds and a plain if's condition are ordinary runtime expressions, evaluated once, at runtime, in the compiled module. An inline for's bounds and a comptime if's condition must instead be compile-time-known: a param, an enclosing comptime for's own loop variable, an integer/boolean literal, or a combination of those. Reading a local, a global, or a function call result there is an expansion error, not a type error:

// rejected: 'x' is a runtime local, not compile-time-known
func check() -> i32 {
    let x: i32 = 1;
    comptime for i in 0..x {
        return 0;
    }
    return 1;
}
export func check as "check";
expansion error at 4:24: 'x' is not a compile-time-known value here (expected a 'param' or an enclosing 'comptime for' loop variable)

Given a compile-time-known range/condition, the compiler substitutes the concrete value into a fresh copy of the body for every taken iteration or branch, then parses that copy as ordinary declarations or statements and splices the result in place of the original construct — the loop or branch itself never reaches the compiled module; only its already-specialized bodies do.

$name, [<...>], and str!(...): using the value

Three token-level forms read a compile-time-known value inside a comptime for/inline if body (and nowhere else):

  • $name splices an i32/i64 value in as an integer literal token (with a leading - if negative). It's an expansion error on a bool/string value — those have no runtime literal form to splice as.
  • [<...>] pastes a sequence of plain identifier fragments and $name splices (of any of the four types) together into a single new identifier token, e.g. [<Counter $i>] with i = 3 produces the identifier Counter3. The pasted text must form a valid identifier — pasting a fragment sequence that starts with a digit, for example, is an expansion error.
  • str!(name) (also str!($name)) stringizes a compile-time-known value of any of the four types into a string-literal token, using its plain textual form.

Nesting

A comptime for/comptime if may contain another one. Each level binds its own loop variable, and an inner header may read an outer level's variable by its bare name (0..i, not 0..$i$name is body syntax, headers take ordinary compile-time expressions):

param N: i32 = 2;
param M: i32 = 2;

comptime for i in 0..N {
    comptime for j in 0..M {
        global [<Cell $i X $j>]: i32 = 0;
    }
}

func check() -> i32 {
    return Cell0X0 + Cell0X1 + Cell1X0 + Cell1X1;
}
export func check as "check";

That expands to the four globals Cell0X0, Cell0X1, Cell1X0, Cell1X1 — one per combination — and check() returns 0, since each defaults to 0.

Worked example: compile-time select-style dispatch

This program builds N global counters and a select function that dispatches on an index to the matching one, entirely via comptime for — the loop never appears in the compiled output, only its N unrolled copies do:

param N: i32 = 4;

comptime for i in 0..N {
    global [<Counter $i>]: i32 = 0;
}

func select(index: i32) -> i32 {
    comptime for i in 0..N {
        if (index == $i) {
            return [<Counter $i>];
        }
    }
    unreachable;
}

func check() -> i32 {
    return select(2) + select(0) + select(3) + select(1);
}
export func check as "check";

With the default N = 4, this expands to four globals (Counter0..Counter3) and four ifs inside select, each comparing index against a literal and returning the matching global. All four counters default to 0, so check() returns 0. Compiling with reedc build, then validating with wasm-tools, then running with wasmtime run --invoke check confirms this: the module validates and check returns 0, matching the prose above.

That is O(N) comparisons at runtime. Putting the same comptime for inside a switch instead generates case arms, which lower to a single br_table — the arms are still generated at compile time, but the dispatch becomes one indexed jump rather than a chain:

param N: i32 = 4;

comptime for i in 0..N {
    global [<Counter $i>]: i32 = 0;
}

func select(index: i32) -> i32 {
    switch (index) {
        comptime for i in 0..N {
            case $i: { return [<Counter $i>] + $i; }
        }
        default: { unreachable; }
    }
}

func check() -> i32 {
    return select(0) + select(1) + select(2) + select(3) - 6;
}
export func check as "check";

Each counter is 0, so select(k) is k, and 0 + 1 + 2 + 3 - 6 is 0.

--define N=6 instead produces six counters (Counter0..Counter5) and six ifs, with no trace of Counter6 — the same source, specialized to a different compile-time value, with no source edit required.

Limits

Expanding comptime for/comptime if is bounded on two axes, to turn a runaway expansion (a self-referential comptime for bound, or simply too large an N) into a diagnostic instead of an unbounded compile:

  • Nesting depth: a comptime for/comptime if nested inside another, more than 128 levels deep, is an expansion error (compile-time expansion nested past the maximum depth of 128).
  • Total iterations: the sum of comptime for iterations actually executed across the whole module, once past 100,000, is an expansion error ('comptime for' exceeded the maximum of 100000 total iterations across the module) — not a per-loop limit, a whole-module budget.

Both numbers are this compiler's own choice, not a language requirement; another implementation may choose different bounds, but must document whatever it enforces and must report a diagnostic rather than expand indefinitely once its bound is hit.

Editor support

A param is a real symbol to the language server, not just an expansion-time value. Hover, go-to-definition, completion, the document outline, and rename all work on it, in every position it can appear:

  • its declaration (param Width: i32 = 3;),
  • a bare read in an ordinary expression (return Width + 1;),
  • a comptime for range bound (comptime for i in 0..Width),
  • and a $Width splice inside a template body.

Hover shows the effective value, i.e. after any --define override, since that is what the code you are looking at actually compiled with.

Rename is supported for a param, unlike for a macro or a comptime func: every use of a param survives in the source as the literal identifier, including the $Width splice form, so the rename reaches all of them. It is refused when the new name is already taken by a global or a type, rather than silently producing a module with a name collision.

The same applies inside a compile-time construct: a comptime for's loop variable and a comptime func's own parameters are renameable, splices included. Those two are exempt from the snake_case rule renaming enforces elsewhere, because the language does not constrain their case either and T/Name/Count is the conventional spelling. What stays refused is renaming a generator or a macro itself, for a different reason: by then the declarations an instantiation produced are indistinguishable from hand-written ones.

A macro arm's captures behave the same way as a generator's parameters, and for the same reason: the capture survives in the source as a literal $name, so a rename reaches the pattern and every splice together. What is refused is renaming the macro or the generator itself, which is the sentence above -- not the compile-time names either one binds.

Inside a comptime for/comptime if body, everything resolves as it would outside one: the loop variable at its binding and at every $i splice, and the parameters and locals of any function the body declares. A comptime for's loop variable works in all three places one can appear — at module scope, inside a function body, and nested inside a comptime func.

The same holds inside a comptime func template body, which needs its own machinery: a template is never compiled as written, so nothing the compiler produced describes the text you are reading. Its parameters hover as comptime param T: type, at their declaration and at every $T splice, and a generator is a closed scope in the editor exactly as it is in the language — one generator's parameters are never offered inside another's body.

Go-to-definition on a generated declaration lands on whatever produced it: the instantiation for a comptime func, or the loop body for a comptime for. It cannot land on the declaration's own name, because there isn't one — make_int_box comes from [<make_ $prefix>], so that spelling appears nowhere in the source.

A [<...>] paste used as a type name does resolve, though. array [<$Name Storage>] { mut $T } declares a type, and every later &[<$Name Storage>] refers to it: within one template the spelling is fixed, so identical text names the same declaration whatever the arguments turn out to be. Hover and go-to-definition work from any of those positions. A paste that names no declared type — a function name, say — stays silent rather than guessing.

Field completion follows from the same reasoning. Typing self-> inside a generator offers the fields of the pub struct $Name that template declares, because whichever type $Name becomes, self always points at that struct. Field types are shown as written (mut &[<$Name Storage>]), since there is no resolved type until an instantiation runs. Hovering a field name says the same thing, and a . position offers the methods the template declares on its own struct — both work before anything has instantiated the generator, which is exactly when you are writing it.

What this doesn't do (yet)

  • No hygiene for macro definitions. Macros do exist — a named pattern/template pair invoked at a call site, complementary to the structural expansion on this page, and variadic via $(...),* — but a name a template introduces is an ordinary name, so it can collide with one at the call site (see Open Extensions).
  • No macro-expansion preview in the editor. A diagnostic inside an expanded comptime for/comptime if body is attributed to the original invocation's source span, not to the specific token that produced it — there is no LSP feature to step through what a particular iteration expanded to.

comptime for/comptime if used to be skipped inside a block/loop written as a value (a let initializer, a return operand, a call argument) — expansion walked statements only, so the construct survived to type checking and was reported as unexpanded. That gap is closed: expansion now walks every expression, so a comptime for works the same way wherever you write it.

param N: i32 = 3;

func check() -> i32 {
  var total: i32 = 0;
  let doubled: i32 = block done() -> i32 {
    comptime for i in 0..N {
      total = total + $i;
    }
    br done(total * 2);
  };
  return doubled - 6;
}

export func check as "check";

The comptime for sits inside a block used as a let initializer. It unrolls to three total = total + <k>; statements for k = 0, 1, 2, so total is 3, doubled is 6, and check() returns 0.