Spec §9 — Const evaluation
const expr evaluates expr during compilation and replaces it
with its result: the emitted program carries the value as a literal,
never the computation. Const evaluation and macro expansion (§10) run
in the same fueled compile-time interpreter; they are the two phases
that execute Vilan code at build time.
9.1 The const expression
const is a prefix operator over an expression. It captures
greedily: everything to the end of the surrounding expression folds
(const 1 + 2 * 3 folds 7); parenthesize to narrow the extent:
in (const square(4)) + square(2) the second call runs at runtime.
A const expression may appear anywhere an expression may, including
module-level initializers, where it is also the way to run logic at
load position without runtime cost: a const initializer ships as a
plain value, participates in no platform coloring (§11.2), and cannot
violate the initializer rules of §7.1 (nothing of it remains to run at
load time).
9.2 The const environment
The evaluated expression may use exactly what the compiler can know:
- literals, and the pure operations of the language (§7.2’s evaluation order applies unchanged);
- functions whose bodies are themselves const-evaluable, transitively;
- imports, and immutable module bindings whose own initializers are const-evaluable.
Host capabilities do not exist at compile time. A call that
requires the host (the clock, the filesystem, network, randomness, any
external function without a compile-time definition) is a compile
error inside const (“now() is not const-evaluable”), not a deferred
runtime call: the answer would not be a constant.
The deliberate exception is the compile-time file channel,
std::web::asset, callable only during const evaluation, in three
directions. emit(kind, content) is the output direction for lines: it
declares a build asset (the styling system’s CSS, for example) that the
build writes beside the output; its ordered spelling
emit_keyed(kind, key, content) carries the contribution’s own sort
key, and emit(kind, content) is exactly emit_keyed(kind, content, content). read(path) is the input direction: it
returns a project file’s text so the result of parsing or transforming
it can fold into the output. bundle(path) is the output direction for
whole files: it tells the build to carry a non-code resource into the
output directory unchanged, and evaluates to the url the copy answers
on. Every path here is relative to the package root (the base
imports resolve under — never the process working directory), may not
be absolute or escape that root, and the file becomes a tracked build
input: a change to it invalidates every build product that named it,
exactly as editing a source file would, and a missing file is a compile
error at the const expression. The channel keeps const evaluation
deterministic per build-input closure: same sources and same project
files, same output — emit same lines out, read same values in,
bundle same files carried.
That containment rule is a rule about the path you write. A
symlink is a supported spelling of project layout, not an escape: a
link is an ordinary way a tree gets its name inside a package — a
shared icon set, a generator’s output directory, a vendored asset
folder — so the channel follows one, reads what it points at, and
tracks that file as the build input it is. What the fence refuses is a
path that says it is leaving (an absolute path, a ..); it does not
refuse a name that happens to resolve elsewhere, and it never claims to
have resolved anything.
The same doctrine governs the rest of the toolchain, so one project
layout means one thing everywhere: paths resolve honestly, every fence
and scope is applied to the resolved tree, and a walk that follows
links guards against cycles. Concretely — a [[build.hook]]’s declared
input is followed and hashed as what it points at, while links inside
a declared tree hash as their target path rather than being followed
(so the hash can neither leave the tree nor loop); a package’s
generated root is still that package’s products when the directory is
a link out of the package; and vilan fmt walks a directory link,
recognizes a directory it has already walked under another name, and
stops at a link whose target leaves the project — saying so, since that
is where the command’s scope ends and not a judgement about the link.
A function that reaches any verb of the channel — emit, emit_keyed,
schedule_at_end, stage, staged, read, bundle, bundle_as,
read_dir, read_dir_all, digest — is compile-time-only,
transitively, and the compiler enforces that statically. A call from
runtime code into compile-time-only territory is an error at the
outermost crossing — the call that leaves ordinary code. A crossing
made through trait dispatch counts too: a generic call whose bound
selects an emitting impl is refused at the call that selected it,
while a clean impl of the same trait member through the same generic
stays legal — the check follows the resolved instantiation, per call
site. Where the compiler cannot see which impl a dispatch selects (a
shared default body’s self call, for instance) it refuses every
receiver rather than let one slip through and throw at run time.
Because a
call made through a value has no statically known callee, a
compile-time-only function also has no runtime value form: naming
one as a value (passing it to a higher-order function, binding it, or
writing a closure literal that reaches the channel) is an error at
that reference, outside a const. Inside a const the restriction
lifts entirely — the interpreter makes the call, so
const apply(styled) is legal where apply(styled) is not. The one
other place it lifts is schedule_at_end’s own argument, below: the
name is handed to the const pass and never becomes a runtime value.
The channel has a fourth direction, and it is a direction in TIME
rather than in data: schedule_at_end(f) asks the build to call f
once, after every const evaluation of this compile has finished, in a
const context of its own. A module can then accumulate while the
program evaluates and process and emit the whole result in one go at
the end, instead of emitting each piece the moment it is minted and
being unable to take any of it back — which is how std::web::style writes
a stylesheet holding the rules that survived rather than every rule
ever built.
f must be a named function and its name is its identity: scheduling
the same function three times schedules it once, so every contributor
can ask without coordinating, and finalisers run in the order they were
first asked for. A finaliser sees every contribution made after its
scheduling, which is the point — it runs after the last const
expression, not after the one that asked for it. It may schedule
another finaliser (that one runs in the same pass); re-scheduling
itself does nothing. A finaliser that panics fails the build, naming
the function.
“The end” is the end of one compile’s const pass — per leg, like every
other const fact. Under vilan run --watch that is the end of the
round: what the round re-evaluated re-schedules and re-emits, and what
it did not touch keeps the asset the previous round wrote.
The hook’s other half is the channel’s registry. stage(kind, token, line) records a contribution without writing it, against a
liveness token; staged(kind) hands back the staged lines whose
token the build still names — the token appears in a value some
const expression evaluated to — in (token, line) order and
deduplicated on that pair. staged answers only after evaluation has
finished, so it is read from a finaliser and refuses anywhere else.
That is how std::web::style puts the rules that survived on the sheet
instead of every rule ever constructed: a condition combinator re-mints
an inner style’s rules under the composed condition and drops the
inner, so the inner’s class is in no surviving style and its rule never
reaches the file.
9.3 Failure and resource limits
Const evaluation is total by construction of the budget: each run is
bounded by the interpreter’s fuel (steps; an asset::read charges
one per byte read, so fuel bounds input size too — an asset::bundle
charges a flat cost instead, since its bytes never enter the program
and a size charge would cap resources rather than work, a listing
charges per entry, and an asset::digest charges per byte at an eighth
of read’s rate) and depth (nesting). Exhausting either, or panicking during evaluation, is a
compile error carrying the const expression’s span; a budget failure
says so, rather than reading as a program error. A runaway const
fails the build; it cannot hang it. The fuel budget is sized so that
real compile-time workloads fit with room to spare — parsing the
largest page of this book at compile time uses about an eighth of it —
while a runaway still fails fast. The budgets are compiler constants —
the manifest’s [macro] section (§11.4) sizes macro expansion only,
and does not size const.
The primary span is the const expression itself, always: the tree the
interpreter evaluates is compiled output and carries no source
positions, so there is no inner expression to anchor to. The diagnostic
instead names the function the failure occurred in and notes that
function’s declaration, with the call chain that reached it.
9.4 Results
The result of a const expression is spliced as a literal of its
type: numbers, strings, booleans, lists, tuples, structs and enums of
const-evaluable contents. The expression’s type is checked exactly
as if it ran at runtime; const never changes typing, only when the
computation happens.
A trait object may be built and called during the evaluation — a
let source: dyn Src = .. inside a const fun dispatches as it would at
runtime — but a result that is or holds one (const let C: dyn Src, a
struct with a dyn field) is refused: its method table is code the
program builds, not data a build can write down. Keep the concrete type
at the const and let the value meet the dyn type where it is used.
9.5 Inferred const
A release build additionally folds let and mut initializers
that were never marked const, where it can settle them under §9.2’s
environment. let total = 1 + 2 * 3; ships as 7 with no keyword.
The keyword remains the contract, and the difference is what happens on
failure. An explicit const that cannot be evaluated is a compile
error (§9.3). An inferred fold that cannot be evaluated — for any
reason: a runtime free variable, a host capability, a panic, a budget,
a result that is not plain data — simply does not happen, with no
diagnostic. The binding stays exactly as written and runs at runtime.
Inference can therefore never change whether a program compiles, only
when some of its arithmetic happens.
Four rules bound it:
- Observable behaviour is preserved exactly. An evaluation that
panics, prints, or exits is discarded and the binding left alone.
(An explicit
constdoes discard what its evaluation printed — that computation is what the author asked to move to compile time.) - Inference never creates a const context. Reaching a compile-time-only function (§9.2) refuses the fold rather than performing the emission, so whether a style compiles never depends on the optimizer.
- Inferred evaluation has its own, tighter budgets, and a fold
whose literal would be large is declined — explicit
constis the opt-in for expensive or bulky results. - Type parameters are out of scope. A binding inside a generic function body is never folded: its value depends on a monomorphization the const environment does not carry.
The debug preset does not infer, so a debug build keeps every such
computation where a stack trace can show it; release does. Folding is
deterministic — the same source folds identically on every build — and
the language server never runs the pass, since it produces nothing to
report.
9.6 const let and const fun
Two declarations carry the keyword. Both are statements — module level or inside a body — and both are ordinary declarations otherwise.
const let NAME[: T] = EXPR; binds a value the build computes. It
is let NAME = const EXPR; with one addition: its result may be a
closure over compile-time data, which §9.4’s plain-data rule
refuses everywhere else. Later const expressions call through it, and
runtime code gets the closure’s own body with the captured values baked
in — one emitted arrow, no wrapper:
const fun scale_step(rem: f64): |f64| f64 {
|n: f64| rem * n
}
const let space = scale_step(0.25);
fun main() {
print(const space(2f)); // 0.5 — folded at build time
print(space(8f)); // 2 — `(n) => 0.25 * n`, emitted once
}
const mut is refused: a compile-time value has no runtime mutation.
Write const let for the compile-time binding, or mut name = const ..; for a runtime binding seeded from one.
const fun NAME(..) { .. } declares a function whose body is
const-evaluable, checked here. Reaching a host capability (§9.2) is
an error at the declaration, naming the capability, instead of at
whichever const expression first tried to fold a call to it. This is
not a colouring requirement: a plain fun is still const-callable
under §9.2’s transitive rule, and a const fun is still an ordinary
function at runtime, called with runtime arguments.
A const expression that reads a plain binding, and a let x = const .. whose result is a closure, are both refused — and both refusals
name const let as the declaration that admits what was wanted. The
editor offers the edit as a quick fix.
Both forms take the export marker, which wraps the declaration under
the keyword exactly as it wraps a plain fun or a module-level let
(§4 of the visibility rules). A module publishes a compile-time helper
and the binding it computes the same way it publishes anything else:
export const fun scale_step(rem: f64): |f64| f64 {
|n: f64| rem * n
}
export const let space = scale_step(0.25);
fun main() {
print(space(8f));
}
Both take the label prefix a plain declaration takes, written ahead of the keywords (§3.2’s one order), so the deprecation policy reaches a compile-time helper too:
[deprecated("use scale_step")]
export const fun step(rem: f64): |f64| f64 {
|n: f64| rem * n
}
[internal("tuning, not a contract")]
const let base = 0.25;
fun main() {
print(base);
}