Standard Library
Metaprogramming
Four layers, one stage: typed attributes mark declarations, rules match a shape and inject quasiquoted code, comptime runs the language itself at compile time, and procedural macros return generated code from a comptime function.
The rule stage runs between resolve pass 1 and a full re-resolve/check, so folded and injected code is checked and compiled exactly like hand-written code, on every engine. There is zero runtime reflection — injected code is cost-identical to writing it by hand. A program that uses none of this surface skips the stage entirely; metaprogramming is opt-in, not ambient overhead.
Attributes (Layer A)
An attribute is an inert, typed annotation. Its fields are the argument surface — there is no separate schema to keep in sync.
attribute Route { string method; string path; } // fields ARE the arguments
attribute Column { string name = ""; } // defaulted -> bare @Column
@Route("GET", "/users") // positional, comptime-const
Array<User> list() => ...;
@App::Tag(3) void f() { } // qualified form
attribute Name { fields } declares one; fields must be
int, float, bool, or
string. Methods, constructors, and accessors inside an
attribute body are errors — an attribute is data, not behavior.
Attributes attach to classes, structs, interfaces, members, functions, globals, and namespaces. They do nothing on their own — arguments are type-checked against the fields and must be comptime-evaluable, but the attribute only becomes active once a rule reads it.
Scope is per-file. An unqualified @Name
resolves through the namespaces the declaring file imports — its
own namespaces, its uses list, any namespace it draws a
selective use NS::name from, and std. Ambiguity
across imports is an error; qualify it, as in @Web::Route.
Rules (Layer B)
A rule matches a structural shape and injects quasiquoted code at a named anchor — the mechanism that gives attributes teeth.
namespace Web {
interface IController { }
attribute Route { string method; string path; }
rule registerRoutes {
match @Route(r) on method m in class C : IController
inject `router.record($r.method, $r.path)` at bottom of C.constructor
}
}
A rule is match <shape> inject <template> at
<anchor>, declared inside a namespace. It fires on a
declaration only in files that import the rule's namespace
— the same import graph that scopes attributes. A bulk uses NS
or a selective use NS::name both opt a file in, so reading a
file's imports tells you every rule that can touch it.
Matching
Match binds by structural shape, combining up to three clauses:
@Attr(bind)- an attribute on the declaration, binding its evaluated value to
bind.match onerequires at most one matching attribute. on <kind> m- the matched declaration itself —
method,function,class,struct,field,constructor,interface, ornamespace. in <kind> C : IFace- an enclosing declaration, optionally constrained to implement/extend a type — checked against the resolved base chain.
Holes and anchors
Holes are written $name inside the injected
quasiquote template:
| Hole form | Meaning |
|---|---|
$r.method | reifies a field of a bound attribute value to a literal |
$m / $C | splices a bound declaration's name (e.g. this.$m → the method's selector) |
$C.name / $m.name | reifies the matched declaration's simple name as a string literal — the ergonomic form, no list-comprehension dance needed |
t.$m() with $for | a $for-bound meta::Method/meta::Field value used in member-selector position splices its name as the selector — a rule can discover members and invoke them |
$C t = $C(); | $C in type position carries the matched class's identity through pass-2, resolving correctly even when the same injection declares a same-named value |
Anchors (v1): top/bottom of
C.constructor — a nullary constructor is synthesized if the class
has none, and top inserts after base constructor calls — and
member of C, which adds a member and errors on a same-name
same-type collision. top/bottom of body,
marker, and namespace N anchors, plus
where predicates, $for list splices, and
expression macros (macro name(e) => \`…\`;, called
name!(arg)) all ship as well.
Ordinary rules only add code — no silent rewrites; the loud,
explicitly-marked exception is a rewrites
rule. A local a template declares is alpha-renamed to a fresh symbol,
so injected code neither captures nor is captured by use-site names.
Injected code goes through the normal resolver/checker/backends — it is
cost-identical to hand-writing the same code, with zero
runtime reflection.
Tooling
| Flag | Effect |
|---|---|
--rules file.ext | lists every rule that fires |
--expand file.ext | re-emits the post-rules tree as compilable source — the output recompiles and runs identically, a verifiable artifact |
--ast-after-rules file.ext | the structural AST dump of that same tree |
Comptime (Layer C)
comptime runs Leviathan code itself at compile time, folding
the result to a literal.
comptime int TABLE = nextPrime(1000); // var: init folds to a literal
comptime Array<int> EVENS = [1,2,3,4].where((x) => x % 2 == 0);
int y = 1 + comptime sumTo(10); // expression form (folds rightward)
comptime if (TABLE > 100) { ... } else { ... } // untaken branch not compiled
comptime console.writeln("[build] ..."); // compile-time log (real console)
Evaluation runs on the tree-walk oracle and is hermetic:
the std::sys* floor is denied — no files, sockets, stdin,
timers, or await — except writes to stdout/stderr;
console works and emits during compilation. A
capability injection written directly inside a comptime-folded root is
denied even earlier, at the injection site: no bind is in scope during
comptime folding at all.
Step-bounded: a runaway comptime loop is a compile error
(--comptime-budget N overrides the default ~100M steps);
exhaustion is not catchable.
Results reify as literals: int,
float, bool, string,
None, arrays of these, and value-structs with a
constructor matching their fields 1:1 by name and order (reified as a
constructor call; reference classes stay non-reifiable). Later comptime
sites see earlier comptime vars by name.
A uses inside a taken comptime if branch
is supported — the branch's imports become visible to rule/attribute/macro
scoping, since the imports map is recomputed after the comptime fold. A
macro call in a comptime if condition is an
error, since it would feed the very imports map macro resolution needs.
comptime import()
std::import(path) folds a file's content to a comptime
string at build time — a project-relative, safe way to embed
templates, fixtures, or other text assets.
comptime string tpl = import("views/links/index.html");
import is an ordinary prelude function whose comptime
evaluation is intercepted — the exact inverse of the sys*
gate: denied at comptime for most things, but import() is
allowed at comptime, and its ordinary runtime body just throws.
At compile time it reads the named file and folds to its content; the file
becomes a declared build input, the same determinism class
as a .lev source.
- Paths
- plain, project-relative,
/-separated: no leading/, no\, no empty path, no./..segment — checked lexically before any filesystem contact, so an escape-shaped path is refused outright rather than normalized. - Root resolution
- splits by build shape. A
--planbuild resolves the path against the importing file's own module's declaredassetstable — plain string equality, no path arithmetic; a dependency'simport()sees only its own declared assets, never the consuming app's. A bare single file (no--plan) resolves against the source file's own directory. - Caching & determinism
- a path is read once per compile, cached by absolute path, so two comptime sites importing the same file observe identical content even if it changes mid-compile. Same inputs → same fold → same binary.
- Runtime misuse
- a program that reaches
import(...)at runtime gets an ordinary, catchableRuntimeExceptionnaming the mistake, identical on every engine.
A user's own import function declared anywhere outside
namespace std is never hijacked — the intercept keys on the
prelude declaration's identity, not the name, so shadowing is exactly as
safe as any other std-shadow. There is no file-size cap (the comptime step
budget already bounds a runaway import-in-a-loop); --assets
makes what got read visible.
Body-replacing rules (Layer D)
The one body-replacing capability, gated behind an explicit
rewrites body of <bind> header so it is greppable and
its --expand diff is the loudest.
namespace Perf {
attribute Timed { }
rule timed rewrites body of m {
match @Timed on method m
replace `
ticks = ticks + 1;
var r = $body; // $body = the original body's value
return r;
`
}
}
A rule is additive (inject) XOR a rewriter
(replace), never both. $body splices
the original body back in — replacement is composition, not
obliteration.
- Statement position (
$body;) splices the original statements verbatim; their locals are not renamed, since the body moves as one authored unit. - Expression position (
var r = $body;) is valid only when the original body is a single value expression — an arrow (=> e) or a block that is exactly{ return e; }— otherwise capture it in statement position instead. $bodymust appear exactly once: zero drops the body silently, more than once duplicates its side effects.
Ordering: all additive injects on a body
apply first, then the single replace wraps the result — so
$body captures "the method as written plus any additive
prologue/epilogue."
Confluence: two replaces on one body do not
compose — a conflict naming both rules; likewise two same-name+type
member of injections conflict, unless one is marked
distinct.
reentrant (rule N reentrant { … }):
by default a rule never matches code another rule injected.
reentrant opts a rule into re-triggering on rule-generated
code, re-run to a fixpoint — or an error if it doesn't converge in the
round budget (default 8, --reentrant-budget overrides). Only
reentrant rules re-trigger; the safe majority still sees the
tree exactly once.
Procedural macros
A procedural macro is the comptime counterpart to fixed-template expression macros: it runs a comptime function and returns generated code.
macro generated(string payload) comptime {
var parts = ["0"];
for (int i in 0..payload.length()) parts = parts.add("+ 1");
return meta::parseExpr(parts.joinToString(" "));
}
int n = generated!(`four`); // raw backtick payload -> "four"
macro name(string payload) comptime <body> takes exactly
one comptime-evaluable string and must return opaque
Ast. meta::parseExpr(string) and
meta::parseStmts(string) are compile-time-only fragment
parsers — their runtime bodies throw. A procedural expression call
requires Ast(expr); returning Ast(stmts) is a
kind error.
Backticks are accepted as raw strings only in macro-call argument
position, as in generated!(\`four\`) above — they do not
become general expressions. The body runs under the ordinary hermetic
comptime and step-budget rules (import()
remains the one declared file input). A same-named ordinary call inside
the body is recursive comptime execution and shares that budget. By
contrast, generated name!(...) syntax is rejected — generated
output is never re-scanned for macro expansion.
Every splice clones the carried tree and gives generated nodes the
call-site span, so parse failures name the macro call and render the
generated fragment line with an offset caret. Macro authors own
generated-local hygiene in v1 and use the reserved-by-convention
__<package>_... prefix. --expand prints
only the post-expansion source; procedural bodies do not survive into the
artifact.