LEVIATHAN v0.1 · in development

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 one requires at most one matching attribute.
on <kind> m
the matched declaration itself — method, function, class, struct, field, constructor, interface, or namespace.
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 formMeaning
$r.methodreifies a field of a bound attribute value to a literal
$m / $Csplices a bound declaration's name (e.g. this.$m → the method's selector)
$C.name / $m.namereifies the matched declaration's simple name as a string literal — the ergonomic form, no list-comprehension dance needed
t.$m() with $fora $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.

Additive and hygienic by default

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

FlagEffect
--rules file.extlists every rule that fires
--expand file.extre-emits the post-rules tree as compilable source — the output recompiles and runs identically, a verifiable artifact
--ast-after-rules file.extthe 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)
Hermetic evaluation

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 --plan build resolves the path against the importing file's own module's declared assets table — plain string equality, no path arithmetic; a dependency's import() 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, catchable RuntimeException naming 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.
  • $body must 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.