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 Eand the braced expression formcomptime { 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
letinitializer 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,
comptimeexpressions 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:
comptimeexpressions and calls of compile-time templates on a folded-away path are never evaluated;- a
compile_errorleft 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 analyzedcompile_erroris 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.