Metacall reference
This page is the precise surface of Metacall, Logos’s metaprogramming system. It covers the metacall keyword, the #[fn_macro] / #[token_macro] invocation surface, the quote_*! family, #[metaprog_handler] derives, the splice/typing model, the dispatch fixpoint, and hygiene — closing with an enumeration of what is shipping versus designed. Spec rule ids (e.g. metaprog.metacall.no-nested-metacall) name the canonical rule for each behavior. Throughout, Metacall is the system and the metacall keyword is one operator within it; the two are never interchangeable.
The metacall keyword
metacall is Logos’s explicit compile-time-evaluation operator. Logos has no implicit const-eval; metacall is its replacement (metaprog.metacall.forms). It runs its operand at compile time and splices the result back into the program as a literal or an AST fragment. For every form the compiler synthesizes a no-argument thunk, JIT-compiles it, invokes it, and replaces the AST node with the result.
Expression forms
Three operand shapes are accepted in expression position (metaprog.metacall.forms):
| Form | Shape | What runs at compile time |
|---|---|---|
| Call | metacall foo(<args>) |
foo with each <arg> CTFE-folded to a literal first. Includes generic foo::<T>(…) and static Type::m(…) calls. |
| Paren-expr | metacall (<expr>) |
An arbitrary expression — operators, calls, casts. |
| Block | metacall { <stmts>; <tail> } |
A full block; the trailing tail expression is the value. |
A block operand must end in a tail expression with no trailing semicolon (metaprog.metacall.block-tail-required); the block’s value type is the type of that tail.
Item form
At module-item position (top level, or inside impl), metacall takes the call form only, terminated by ; (metaprog.metacall.item-position, metaprog.metacall-item.callee-form):
metacall emit_synth();
The callee must be a free-function, turbofish, or static-method call, and its return type must be QuoteItemBlob (a single item) or ItemList (multiple items) — metaprog.metacall-item.return-type. The produced items are spliced into the enclosing module in place of the metacall item during discovery (metaprog.metacall.item-position-splice). Block and paren-expr forms are expression-position only. Item-form arguments are also CTFE-evaluated, resolving module-level consts (metaprog.metacall-item.args-const-eval).
Return-type restriction (expression forms)
The type produced by a metacall operand must be one of (metaprog.metacall.return-type):
- a primitive scalar —
bool; integer kindsi8/i16/i24/i32/i56/i64and theirucounterparts;f32/f64; integer/float literal types; &strorSlice<u8>;WritStatic;Writ(includingRc<Writ>) — call form only; it auto-freezes toWritStatic, and user code observes the spliced value asWritStatic(metaprog.metacall.writ-autofreeze);ExprBlob— an AST-expression fragment.
Any other result type is a compile error (the metacall_non_primitive failing test returns a struct and is rejected).
When a metacall returns an ExprBlob, pass-1 typing is deferred: let x: T = metacall foo() accepts any annotated T over the ExprBlob right-hand side, and the real type is recovered after the driver splices the blob and pass-2 sema re-lowers it (metaprog.metacall.exprblob-deferred-typing). Until the driver substitutes the literal, a metacall lowers as a pass-through of its operand’s value so borrow/type checks stay valid; this pass-through never reaches codegen (metaprog.metacall.runtime-passthrough).
Staging rules
Three constraints are enforced, each with a failing test:
- Arguments must be CTFE constants (
metaprog.metacall.args-ctfe-constant). Each call-form argument must fold to a constant literal; a bare identifier naming a module-levelpub constfolds (metaprog.metacall.const-resolver). A runtime local does not (metacall_runtime_argis rejected). - No runtime-local capture in the block/expr forms (
metaprog.metacall.no-runtime-capture). Every variable reference must resolve to a binding introduced inside the operand (let,for,for_each, match-arm pattern), a module-level const, or a known function. A reference to an enclosing runtime local is an error; the diagnostic hints to hoist into apub constor pass via ametacallargument. - No nested
metacall(metaprog.metacall.no-nested-metacall). Ametacalloperand may not contain anothermetacall; it is a one-shot lift to compile time whose result is a runtime value (nested_metacallis rejected). Compose the inner calls into one operand instead.
Function-style macros
The name!(...) surface (expr.macro.fn-style-call) runs on the same JIT. The parser captures the balanced-delimiter contents as raw text; sema interprets them per the callee’s marker. Three delimiter forms exist: name!(args) and name![args] are expression-position only; name!{ … } is item-position at module top level and expression-position elsewhere.
#[fn_macro] versus #[token_macro]
The marker decides what the callee receives. A #[fn_macro] gets parsed argument ASTs (each an ExprBlob); a #[token_macro] gets the raw source bytes as str, unparsed (metaprog.token-macro.raw-text-as-str). Accepted signatures:
| Marker | Position | Callee signature | Invocation |
|---|---|---|---|
#[fn_macro] |
expression | (ExprBlob) -> ExprBlob (exactly one arg) |
name!(e) / name![e] |
#[fn_macro] |
expression | (Vec<ExprBlob>) -> ExprBlob (N args) |
name!(a, b, …) |
#[fn_macro] |
item | (Vec<ExprBlob>) -> ItemList | QuoteItemBlob, or () -> … |
name!{ … } |
#[token_macro] |
expression | (str) -> ExprBlob |
name!(…) / name![…] / name!{…} |
#[token_macro] |
item | (str) -> ItemList | QuoteItemBlob |
name!{ … } |
#[token_macro] |
item / resource | (name: str, body: str) -> … |
resource <name> = h!{ … }; |
#[token_macro] |
item / resource | (name: str, params: str, body: str) -> … |
resource <name> = h!(<params>){ … }; |
Expression-position signatures are checked by metaprog.fn-macro.signature-shapes; item-position parameter shapes by metaprog.fn-macro-item.param-signature; item-position return types (ItemList or QuoteItemBlob) by metaprog.fn-macro-item.return-type. A name!(...) callee must be marked #[fn_macro] or #[token_macro] — an unmarked callee is a distinct diagnostic (metaprog.fn-macro.callee-must-be-marked).
Argument handling. For a #[fn_macro], name!(...) arguments parse as a comma-separated expression list (metaprog.fn-macro.args-are-expr-list); each argument is serialized as the ExprBlob of its AST subtree (metaprog.fn-macro.arg-passed-as-ast-blob) — the callee receives syntax, not a runtime value. The single-arg (ExprBlob) form requires exactly one argument (metaprog.fn-macro.single-arg-arity). For a #[token_macro], the raw bytes are forwarded verbatim as one str with no parsing.
The resource form. resource <name> = h!(<params>){ <body> } supplies the LHS binding as a NAME, the parenthesized text as PARAMS, and the brace group as body — all as opaque str values passed byte-for-byte (metaprog.token-macro-item.raw-text-verbatim). The PARAMS slot is valid only for the 3-arg (name, params, body) form, and that form requires it (metaprog.token-macro-item.params-slot-scope); the NAME slot is required for the 2- and 3-arg forms and is accepted-but-discarded for the 1-arg (str) form (metaprog.token-macro-item.name-slot-scope). A macro author typically re-emits params verbatim into a generated signature (so the compiler parses and type-checks it) and splices body as the function’s tail. Logos’s own deem / wql / trama DSLs are the 3-arg form: #[token_macro] pub fn deem(name: str, params: str, body: str) -> ItemList.
Built-in macros. Before user resolution, a fixed set is handled directly by the compiler (metaprog.fn-macro.builtin-macro-list): cfg!, line!, column!, file!, include!, include_str!, include_bytes!, env!, concat!, concat_bytes!, stringify!, compile_error!. The format! family (format!, println!, print!, eprintln!, eprint!, panic!, format_args_str!, plus write! / writeln!) is sema-resident: sema parses the format string at compile time and synthesizes a Formatter-driven block, skipping the JIT thunk. Notably absent built-ins: assert!, assert_eq!, matches!, dbg!.
The quote family
quote_*! forms produce typed AST literals — the body is parsed as the corresponding syntactic form, deep-cloned into a fresh Writ document, and emitted as a blob (metaprog.quote.typed-ast-literals):
| Form | Body parsed as | Result type |
|---|---|---|
quote_item! { item* } |
one or more items | QuoteItemBlob |
quote_expr! { expr } |
one expression | ExprBlob |
quote_ty! { type } |
one type | Type (a runtime reflection value) |
quote_item! builds a synthetic package main module carrying the cloned items (metaprog.quote-item.synthetic-main-module) and inherits the enclosing metafunction’s use scope so unqualified names resolve (metaprog.quote-item.inherit-import-scope). quote_expr! with no antiquots emits a static rodata blob wrapped as ExprBlob { ptr } (metaprog.quote-expr.reify-ast-to-exprblob); with antiquots it lowers to a substitution call at runtime. quote_ty! reifies a type into a Type struct { kind, name, size, align, uid } (metaprog.quote-ty.reify-type-to-struct).
Antiquotation and repeats
The antiquotation spelling differs by form:
quote_item!—#(name)splices anIdentat name / type-name positions (struct name, impl target, fn name, bare-named param/return types, generic args);#(blob)splices anExprBlobat a fn body orreturnsite; bare#nameis accepted inside<…>generic-argument lists (metaprog.quote-item.name-antiquot-forms).quote_expr!— bare#xis the everyday form; a scalar antiquot must beIdentorExprBlob(Ident-only in ident-only positions such as field names) —metaprog.quote-expr.scalar-antiquot-type. Struct-literal antiquots are positions insidequote_expr!:Foo { #fname: e },Foo { #(#fnames: e),* }, and field-readrecv.#fname. A#xantiquot must name a bound local (metaprog.quote-expr.antiquot-must-be-in-scope).quote_ty!— antiquotation is$-only:$identfor a boundType(metaprog.quote-ty.antiquot-type-var),$ts...for a pack-splice (metaprog.quote-ty.pack-splice). There is no#(expr)form insidequote_ty!.
Repeat groups — #(...)*, #(...),*, #(...)&&* — expand their body once per element of a cursor pack referenced inside, joined by nothing / , / &&. A cursor must be [Ident; N], Vec<Ident>, or Vec<ExprBlob> (metaprog.quote-expr.repeat-cursor-type); multiple cursors in one group zip by length; a group must contain at least one cursor (metaprog.quote-expr.repeat-needs-cursor); fixed-length [Ident; N] siblings must agree on N (metaprog.quote-expr.repeat-cursor-length-agree). Repeats do not nest in quote_expr! (metaprog.quote-expr.no-nested-repeat); in quote_item! they nest at most 2 levels (metaprog.quote-item.repeat-nesting-limit). Placeholder order is fixed by a deterministic depth-first walk of the quoted subtree (metaprog.quote-item.placeholder-walk-order).
Unshipped quote forms
quote_stmt!, quote_pat!, and quote_ident! are design-only — not parsed. Also unimplemented: method-call-name antiquotation in quote_expr!, pattern-position antiquotation in quote_item! bodies, and a generic-instantiation Type→AST bridge (Type::ident() is bare-name only; Foo<i32>-shaped splices need a richer reflector).
#[metaprog_handler] derives
A derive-style hook is registered with #[metaprog_handler("trigger")] on a function; the first positional string literal is the trigger name (metaprog.handler.register). After all modules finish collection, the compiler scans top-level annotations; a #[trigger] attribute immediately preceding an item is recorded as a metaprog target (metaprog.trigger.annotation-scan), and the registered handler is invoked on each match during discovery. The handler receives its target’s AST offset, builds one or more QuoteItemBlobs (typically via quote_item!), and emits them as sibling items with logos_emit_item_blob_subst (single item) or assembles a Vec<QuoteItemBlob> for an ItemList thunk (metaprog.derive.trigger-may-emit-items).
#[metaprog_handler("derive_clone")]
fn derive_clone_hook(target_offset: u32) -> () {
// build a QuoteItemBlob via quote_item! { ... }, then:
unsafe { logos_emit_item_blob_subst(&blob); }
}
Logos does not accept Rust’s #[derive(Trait, …)] syntax; that is an error (metaprog.derive.no-rust-derive-syntax). Each derive is one trigger annotation #[derive_<trait>] paired with an in-scope #[metaprog_handler("derive_<trait>")]. The worked example in the standard library (stdlib/std/compiler/metaprog/derive_clone.logos) covers both non-generic and generic struct targets through a single hook, using impl<#( #tparams: Clone ),*>-style repeat groups.
Splice and typing model
All routes end in an item or expression fragment spliced into the program and then re-checked as ordinary code.
ExprBlobcarries a serialized AST-expression Writ blob. When spliced, aWRIT_BLOBwhose root is an AST-category expression node is lowered by recursively type-checking that node as an ordinary expression (metaprog.writ-blob.ast-fragment-recurse); a non-AST blob falls back to an opaqueWritStaticliteral (metaprog.writ-blob.opaque-static-fallback). This is why anExprBlobresult defers its typing until after the splice.QuoteItemBlobis a single-item blob{ template_ptr, template_size, idents_blob, blobs_blob, cursors_blob }(metaprog.quote-item.blob-result-type); at item position the compiler synthesizes a void thunk that emits it, substituting captured identifiers, then releases its buffers (metaprog.item-emit.quoteitemblob-single).ItemListis aVec<QuoteItemBlob>; its thunk iteratesblobsand emits each in turn (metaprog.item-emit.itemlist-iteration).
The number of antiquot placeholders discovered in the source must equal the number rewritten in the cloned destination, or it is a compile error (metaprog.quote-item.placeholder-walk-balance).
Dispatch: fixpoint and monotonicity
Item generation runs as a discovery loop (metaprog.dispatch.fixpoint-iteration). Each iteration re-lowers the program and fires triggers, item-position metacalls, and item macros that may emit new items; the loop repeats until an iteration emits nothing new (fixpoint), bounded by a hard cap of 16 iterations. Termination rests on monotonicity: a metaprogram only ever adds entities and never mutates or removes existing ones, so each iteration strictly grows the program or halts. There is deliberately no AST-rewrite surface. (During the discovery pass, entry-file function bodies are skipped — only signatures and items needed for trigger discovery are processed: metaprog.discovery.entry-body-skipped.)
Hygiene
References written literally inside a quote resolve at the call site by default — like Rust macro_rules! non-hygienic references. Macro-synthesized locals (e.g. format!'s __buf) live in fresh block scopes and cannot collide with user names. For a guaranteed-unique name, gensym(prefix: str) -> Ident (in std.compiler.metaprog.ast) returns a fresh <prefix>__hyg_<N> whose bytes are host-owned and bound on both JITs — this resolves the ODR conflict when a hook is invoked more than once. Full hybrid hygiene — a separate scope for literal-internal versus antiquoted names — remains future work.
Status & gaps
Metacall’s shipping substrate is the metacall keyword, #[fn_macro] / #[token_macro], #[metaprog_handler], and quote_item! / quote_expr! / quote_ty! with antiquotation, repeats, and gensym. The following are designed but not built — treat them as roadmap, not API:
- The ADR 0003 metafunction model. Capability gating (
ReflectCtx/InjectCtx/QueryCtxtokens;IO/Nondet/FFIforbidden), signature-as-contract dependency-set scheduling, content-addressed incremental caching with provenance,typearg(T)type reification intoTypeDef/ClassDef, implicitmetacallin declaration / type / constraint positions (Buffer<T, const N = optimal_size_for(T)>), andmetacall optimal_size_for(...)in declaration position. Status: Draft, not implemented. The JIT symbol sandbox that would enforce the capability set is likewise not yet in place. templatebodies.template <decl>parses, but the body is then silently dropped — not persisted, not expandable. There is noapply_template, no#[apply(...)], no#Xplaceholder grammar.template_of::<X>()exposes onlyname()andtype_param_count(). Code generation goes through#[metaprog_handler]/metacall+quote_item!instead.- Extra quote forms.
quote_stmt!,quote_pat!,quote_ident!are design-only (not parsed); method-call-name antiquotation andquote_item!pattern-position antiquotation are unimplemented; the generic-instantiation Type→AST bridge is bare-name only. - Full hygiene. Only
gensym-based opaque-name uniqueness ships; hybrid literal-internal versus antiquoted scoping is future work. - Transformative passes. Phase 2 whole-program
Pass<Rewrites, Diagnostics>(AOP, bytecode rewriting, lints) is design only. Today’s system is generative — it adds code, it does not rewrite it. metacallcapture of surrounding locals. Deliberately rejected, not a gap to be closed: compile-time evaluation has no access to runtime locals. Hoist topub constor pass as ametacallargument.- Missing built-in macros.
assert!,assert_eq!,matches!,dbg!are not provided.
For the design rationale behind the unbuilt model, see the repository’s ADR 0003 (Metafunctions — Design Rationale).
Related
- Metacall introduction — the conceptual model: metaprograms as ordinary functions, the three surfaces, quote-and-splice, ASTs as Writ maps, and monotonicity.
- Metacall tutorial — a progressive, test-driven walk through every construct on this page.
- Writ: the data substrate — the tagged-map format that backs
ExprBlob/QuoteItemBlob/WritStatic; Metacall’s ASTs are Writ documents. - Deem and Trama — the DSLs built on the 3-arg
#[token_macro]resourceform; Language overview for where metaprogramming sits in the language.