SultanC
  1. Home
  2. Documentation
  3. Reference

Compile-Time Evaluation

Documentation pages

Compile-Time Evaluation

This page specifies compile-time expressions and blocks (comptime), compile-time parameters, reflection and validation functions, declaration annotations and decorators, and the always-on assertions of std.debug.assert. It is for programmers who move work from run time to compile time and for contributors working on the evaluator.

How evaluation works

The frontend owns syntax and typing. MSIR lowering turns every compile-time obligation into ordinary verified MSIR, and the driver discharges it before any backend or the interpreter's run phase sees the program (compiler/driver/compile_time.sn). The verified MSIR interpreter is the only executor: it is not duplicated for compile-time execution. It runs with an instruction budget, a call-depth budget and the effect policy of compile-time execution, which allows no I/O and no host state. A failure is a compile-time diagnostic and never a run-time fallback; there is no runtime fallback for an explicitly compile-time expression. Target-specific values respect the selected target's registered model.

Within that policy a primitive may change a value the evaluation owns (append, swap, the vector length store of pop and truncate) and release the interpreter's own storage (drop glue), so compile-time code can build and shrink vectors and text. Primitives that observe machine memory or the host stay prohibited (SULTANC-COMPTIME-PROHIBITED-EFFECT).

The lexer's canonical token registry is tools/generate/registry/token_registry.def, and the generated C and SultanC token surfaces must match. The C Stage0 seed need not use new syntax while compiling frontend support for it; once the compiler's own source uses a construct, the seed must be advanced or the source must remain Stage0-compatible.

Expressions and blocks

  • comptime E and the braced expression form comptime { e1; e2; } (expressions only, the last one supplying the value) are evaluated through the dependency slice of the value. Calls to ordinary functions in the slice are executed by the interpreter.
  • A statement comptime { statements } is lowered as an independent void MSIR function, executed once and removed.
  • A value block assigns the result of statements to a place. It is written as a let initializer or after :=. The block's final expression, or a final call statement, is the value. The block and its value form an independent MSIR function returning the place's type, evaluated once, and the place receives the constant. Like a statement block, it cannot use the enclosing function's locals or return from it, and its value must be a compile-time representable value: integers, booleans, characters, text, and vectors of these.
def main(): int {
    let squares: int = comptime {
        let total: mutable int = 0;
        let i: mutable int = 1;
        while (i <= 4) { total := total + i * i; i := i + 1; }
        total
    };
    return squares;
}

Nested explicit compile-time subexpressions, such as comptime (comptime 20) + (comptime { 22; }), are evaluated through the same constant evaluator, with no separate execution context. English and Arabic examples are in tests/{en,ar}/language/comptime/nested_constant_expression.sn.

Module-level let declarations are constants evaluated at compile time with the run-time semantics. An operation that would trap at run time makes the constant a SULTANC-COMPTIME-EVALUATION-FAILED error (see Type system).

Compile-time parameters

A function with a comptime parameter is a template.

  • If every parameter is comptime, each call is evaluated at compile time.
  • Otherwise each call is specialized. Its compile-time arguments must be compile-time known (constants, compile-time results, or a function name used as a value), and the call is redirected to a copy of the function in which those parameters are that value and only the runtime parameters remain. Copies are shared per (function, compile-time arguments), so each distinct set of compile-time arguments is instantiated once. Inside the copy, comptime expressions may use the compile-time parameters. At most 4096 copies are created, so a recursion on a compile-time parameter without a compile-time base case is a diagnostic (comptime.specialization-limit-exceeded).
def scale(comptime factor: int, x: int): int { return x * comptime (factor + 0); }

def main(): int {
    let n: int = 7;
    return scale(6, n);   // specialized for 6
}

A compile-time parameter is a constant of the copy everywhere a comptime expression can use it: alone (comptime factor), in arithmetic, as a call argument (comptime double(factor)), and in a block after run-time control flow. The copy defines the parameter in its entry block, so the evaluation of a comptime expression seeds the constants of the whole function, not only of its own block: every constant instruction, and every block parameter that all incoming edges of reachable blocks pass the same constant (ابذر_ثوابت_خارج_شريحة_وقت_الترجمة, compiler/driver/compile_time.sn).

Branches the compile-time arguments decide

In a specialized copy, a run-time if (or &&, ||, or a while condition) whose condition the compile-time arguments decide is folded before the copy's compile-time work, so only the taken branch is analyzed (اطو_فروع_تخصيص_وقت_الترجمة). A branch condition, and every value a reachable block passes along its edges, is evaluated where only constants, integer arithmetic, comparisons and casts lead to it, and a local that lowering keeps in a place (a parameter read after an if) is forwarded from its one store. Nothing that needs a call or run-time data is folded. As a result:

  • comptime expressions and calls of compile-time templates on a folded-away path are never evaluated;
  • a compile_error left on a reachable path of the copy fails the compilation with its message (SULTANC-COMPTIME-FAILED), whether the compile-time arguments take that path or run-time data decides it. An analyzed compile_error is always rejected, and in specialized code it is never a run-time trap.
import std.meta.reflect;

def checked(comptime n: int, x: int): int {
    if (n > 10) { compile_error("n must be at most 10"); };
    return n + x;
}

def main(): int {
    let x: int = 7;
    return checked(4, x);    // compiles: the branch is folded away
    // checked(42, x) would fail: SULTANC-COMPTIME-FAILED: n must be at most 10
}

Tests: tests/{ar,en}/language/comptime/parameter_in_comptime_expression.sn, folded_compile_error.sn, reject_folded_compile_error.sn and reject_runtime_branch_compile_error.sn.

Reflection and validation

std.meta.reflect provides facts of the type a reference refers to. The driver folds them into constants for the selected target before any evaluation or code generation.

Function Result
size_of(&v) target byte size (layout owner backend/layout)
align_of(&v) target alignment
field_count(&v) record fields or tuple elements, else 0
constructor_count(&v) enum constructors, else 0
compile_error(msg) fails compile-time evaluation
import std.meta.reflect;

type Pair = { left: i32, right: i32 };
type Shape = | Circle | Square | Triangle;

def main(): int {
    let p: Pair = { left: 1, right: 2 };
    let s: Shape = Shape.Square();
    let size: u64 = size_of(&p);
    let fields: u64 = field_count(&p);
    let ctors: u64 = constructor_count(&s);
    return (size + fields + ctors) as int;
}

A type whose physical layout is intentionally not fixed (text, vectors, slices, function values) has no size or alignment, and asking for one is a compile-time diagnostic. A compile_error reached during compile-time evaluation, or left on a reachable path of a specialized copy, reports SULTANC-COMPTIME-FAILED with its message. In an ordinary function, a run-time path that reaches it traps. The compiler primitives behind these functions (بدائي_حجم_النوع and the others) are registered like every other compiler primitive.

Checks the compiler inserts

The format check of std.fmt is a compile-time obligation that lowering adds before a call of a std format function whose format string is a literal. It is a call of std.fmt's own checker with the literal and the argument kinds, evaluated by the interpreter like any compile-time call and then removed (Formatting, "Compile-time check"). All such checks of one compilation share one interpreter context over the verified program (أنشئ_سياق_وقت_الترجمة_مسير).

Declaration annotations and decorators

@public is the public declaration modifier. Any other annotation on a function is a decorator: an ordinary function applied to the decorated function, resolved like any call target (@d, @d(args), @module.d(args)).

@d(a1, a2) def f(x: T): R { body }
// f is bound to:  def f(y: T): R { return d(a1, a2, <original f>, y); }

The decorator receives its arguments, the original function as a value of type fn(T...): R, and the call's arguments, and returns R. Everything is checked as an ordinary call, including the ownership of the forwarded arguments. Decorators compose from the declaration outwards, so @a @b def f is a applied to (b applied to f). Declaring the decorator's function parameter comptime specializes each application at compile time with a direct function value, where the decorator can validate its compile-time arguments. Decorator arguments are evaluated at each call.

def add_one(comptime f: fn(int): int, x: int): int { return f(x) + 1; }

@add_one
def triple(x: int): int { return x * 3; }

def main(): int {
    let n: int = 7;
    return triple(n);
}

The frontend performs the expansion on source text appended after the unit (compiler/frontend/annotations/decorators.sn), so diagnostics in a decorator application point into that generated text. Annotations on types and module values, and on functions that are external, generic or have compile-time parameters, are rejected with SULTANC-ANNOTATION-UNSUPPORTED. Behavior such as HTTP route registration, caching or test discovery belongs to libraries, not to the compiler.

Definition-time decorators

@@d(args) def f leaves f as written and runs d(args, f) once before the program's entry body, in module and declaration order, as Python's registering decorators and Go's init: a router's @@get("/users") adds the handler to its table. The decorator returns nothing (frontend/annotations/definition_decorators.sn; the entry's call block is added in msir/semantic_lowering.sn).

Derived JSON

@@json (@@جسون) on a record or enum type generates, from the declaration (compile-time reflection, as serde's derive): to_json__T(&value): string, from_json__T(&text): result T, string, the appending encoder ألحق_جسون__T, the decoder from a parsed value, and json_schema__T(): vector json_schema_field for OpenAPI. Records are objects in field order, options are null when absent and may be missing on input, vectors are arrays, constructors without payloads are strings and others {"tag": ..., "values": [...]}. Decoding errors name the field path (address.city: expected a string). Field types are read from their syntax: integers, floats, bool, char, string, other @@json types, vectors and options of these (frontend/annotations/json_derive.sn, library/std/json/derive.sn).

std.debug.assert

assert(condition: bool) is the English alias of the canonical Arabic declaration. assert_with_message takes a boolean and a borrowed message string. Both are always active, including in optimized and release output. They evaluate the condition exactly once and, on failure, write the diagnostic to standard error and terminate the process with exit code 1. They use the existing std.io and std.process facilities, not a compiler primitive. See Errors for the failure frame. Both are @@track_caller: under the frame they print the place of the call that failed (see Testing).

import std.debug.assert;

def main(): int {
    assert(2 + 2 == 4);
    let message: string = "invariant failed";
    assert_with_message(true, &message);
    return 0;
}

The optional message expression is evaluated by the caller as an ordinary function argument, so unlike Rust's assert! macro it is not lazy. A failing assertion needs the interpreter's std.io and std.process effects to complete, and it is not a compile-time diagnostic.

Budget, cost and diagnostics

Each compile-time evaluation runs with an instruction budget of ten million MSIR instructions and a call depth of 128. The budget comes from --comptime-budget=N (underscores allowed), else the environment variable SULTANC_COMPTIME_BUDGET, else the default. A block that knowingly does more work raises its own budget with comptime_budget(n) (ميزانية_وقت_الترجمة, std.meta) called in a function the block runs: from that point the evaluation may run n more instructions. Outside compile-time evaluation the call does nothing.

from std.meta import comptime_budget;

def table(): int {
    comptime_budget(60000000);
    // ... a long computation
    return 0;
}

def main(): int { let t: int = comptime { table() }; return t; }

Every MSIR instruction counts as one, whatever it does: extend_from, reserve_exact, truncate, append and length are one step each, and clone of a vector of plain values (no owned storage in its elements) is one block copy. A write through a field path ((*s).table[i] := x) costs the same at any table size; the aggregate is updated in place. The evaluator runs the interpreter's fast paths, charging the budget per instruction.

Compile-time code may use function values (a route table of handlers, a function passed as an argument) and read immutable module constants (lookup tables, such as those std.text formats floats with). Mutable module state, imported storage, raw pointers and host calls stay refused.

A failure inside an evaluation is reported at the failing source line, with the function it was in, a "called from here" span for each call between the comptime block and that line (a recursion shows its call once), and:

  • for SULTANC-COMPTIME-LIMIT-EXCEEDED, the limit reached (the budget, or the depth 128) and how to raise the budget;
  • for SULTANC-COMPTIME-PROHIBITED-EFFECT, the refused operation in the message language (a call to an external function, a call to a compiler primitive, a raw pointer made from a reference, …) and, for a call, the name of the function or primitive.

Limitations

  • Compile-time results must be compile-time representable values: integers, booleans, characters, text and vectors of these.
  • A value block and a statement block cannot use the enclosing function's locals or return from it.
  • At most 4096 specializations are created for one program.
  • Annotations on types, module values, external functions, generic functions and functions with compile-time parameters are not supported.
  • Compile-time execution has an instruction budget (configurable, see above) and a call-depth budget of 128, no I/O and no host state.

See also