Skip to main content

The four macro kinds

Verum ships four surface forms for macros. They differ in where they appear syntactically, what inputs they receive, and what output shape they are expected to produce. All four are ultimately meta fns — pure compile-time functions operating on token trees — but the compiler treats each form distinctly so that the surface syntax stays clean.

KindSyntaxInputTypical output
Derive@derive(Protocol) on a typeThe type declaration's ASTOne or more implement blocks
Attribute@name(args) on any itemItem AST + argsTransformed item(s)
Function-like@name(…) in expr/stmt positionArbitrary token treeAn expression or block
Declarativemacro_rules-style patternsPattern-matched tokensPattern-substituted tokens

The rest of this page treats each in turn.

Derive macros​

A derive synthesises a protocol implementation from the structural shape of a type. The user writes one line of @derive(P); the compiler produces an implement P for T { ... } block.

What compiles today, and what does not

The meta fn half of every example below is real — meta fn twice(x: Int) -> Int { x + x } type-checks clean, and grammar/verum.ebnf names meta as the way a user-defined macro is declared.

The registration markers are not. @proc_macro_derive, @proc_macro_attribute, @proc_macro and @declarative each answer warning<W0400>: unknown attribute — they are Rust's vocabulary, and the grammar has no proc_macro production. The only proc_macro_* in the tree is a Rust derive inside the LLVM bindings crate.

Nor is the other end wired. @derive(X) accepts any name and applies almost nothing:

@derive(my_marker) // warning<W0507>: not applied — no generator yet
@derive(Clone) // warning<W0507>: the same, for a built-in protocol

So a macro you write cannot yet be attached to a call site. Both halves warn rather than fail, which means the code compiles and the macro does nothing — read the warnings. The shapes below are the intended design.

Shape​

@proc_macro_derive(Clone)
pub meta fn derive_clone<T>() -> TokenStream
using [TypeInfo, AstAccess]
{
let name = TypeInfo.name_of<T>();
let fields = TypeInfo.fields_of<T>();

quote {
implement Clone for ${name} {
fn clone(&self) -> Self {
Self {
$[for f in fields {
${f.name}: self.${f.name}.clone(),
}]
}
}
}
}
}

Reference-type awareness​

A derive that blindly calls .clone() on every field will produce broken code for fields whose type is a reference (&T, &checked T, &unsafe T) — references don't have a clone method and you rarely want to deep-copy them anyway. Every shipped derive inspects field types via TypeInfo and generates the appropriate handling:

  • &T fields: copy the reference (it's just a Copy of the 16-byte ThinRef).
  • Heap<T> fields: call .clone() which goes through Clone on T.
  • Shared<T> fields: call .clone() which bumps the ref count.
  • Mut<T> fields: call .clone() only if T: Clone; otherwise emit a diagnostic naming the field and the missing bound, with a suggestion. The registered code for a derive that is accepted but generates nothing is W0507.

Variant handling​

Sum types get one arm per variant. For each variant the compiler emits a destructure, clones each payload field by the rules above, and reconstructs.

type Shape is | Circle { r: Float } | Rect { w: Float, h: Float };

// @derive(Clone) generates:
implement Clone for Shape {
fn clone(&self) -> Self {
match self {
Shape.Circle { r } => Shape.Circle { r: r.clone() },
Shape.Rect { w, h } => Shape.Rect { w: w.clone(), h: h.clone() },
}
}
}

The shipped derive catalogue​

The compiler registers nine built-in derives — Debug, Clone, Copy, Eq, PartialEq, Hash, Default, Ord, PartialOrd. Serialize and Deserialize are not among them. See Derives catalogue for the generated-code semantics.

There are no library derives yet

Serialize, Deserialize, Display, Error and Builder are not provided anywhere — not by the compiler and not by the standard library, which declares no derive macros at all:

grep -rc '@proc_macro_derive' core/ --include='*.vr' | grep -v ':0' # nothing

Applying one is not silent — the compiler answers warning<W0507>: @derive(Name) on Type was not applied — but it is a warning, so the type simply does not get the behaviour. core/ itself carries one such line.

User-defined derives​

Any meta function marked @proc_macro_derive(Name) and taking a single <T> type parameter becomes available as @derive(Name) on type declarations.

Attribute macros​

An attribute macro attaches to a declaration and transforms it. The input is the annotated item's AST; the output replaces the item. This is the most flexible form and the one most at risk of abuse — use attribute macros when derive macros are too restrictive but function-like macros would hide too much of the item's structure.

Shape​

@proc_macro_attribute(traced)
pub meta fn traced_fn(f: FnAst) -> TokenStream
using [AstAccess, Hygiene, CompileDiag]
{
// f.name is an Ident, f.params is List<Param>, f.body is BlockAst, etc.
let span_var = Hygiene.gensym("_tracer_span");
quote {
fn ${f.name}(${f.params}) -> ${f.return_type} using ${f.contexts} {
let ${span_var} = Tracer.start_span(${lift(f.name.to_text())});
let _result = ${f.body};
Tracer.end_span(${span_var});
_result
}
}
}

Usage​

@traced
fn compute(x: Int) -> Int using [Tracer] {
x * x + 1
}

Composition​

Attributes compose top-to-bottom. If you write

@traced
@verify(formal)
@traced
pure fn add(x: Int, y: Int) -> Int { x + y }

the expander runs @derive, then @verify(formal), then @traced. The macro author can ask for the current invocation order via AstAccess.attribute_chain() and decide whether to pre-process or post-process.

Mutating vs wrapping​

Attribute macros come in two conventional flavours:

  • Wrapping (@traced above): take the original function, emit a new function that calls it.
  • Mutating (@derive-like): examine the structure, synthesise additional items, leave the original in place.

Both are expressible. Convention is to prefer wrapping when the attribute adds behaviour and mutating when it adds protocol implementations.

Function-like macros​

Function-like macros appear in expression or statement position and consume arbitrary token trees. They are what you reach for when you want a DSL, an inline code generator, or a terse syntax for a pattern the compiler does not have built in.

Shape​

@proc_macro(sql_query)
pub meta fn sql_query(tokens: TokenStream) -> TokenStream
using [AstAccess, CompileDiag]
{
// `TokenStream` has no `as_text_literal`: its members are
// `TokenTree`s, and a string literal is reached by walking one —
// `first()` -> `as_token()` -> `Literal.String`. See
// [token-api](/docs/language/meta/token-api) for the walk written out.
let query_text = text_literal_of(&tokens)?;
let parsed = match sql.parse(&query_text) {
Result.Ok(p) => p,
Result.Err(e) => {
CompileDiag.emit_error(&f"sql: {e.message}", e.span);
return TokenStream.empty();
}
};
quote {
Database.execute(${lift(query_text)}, ${lift(parsed.param_names())})
}
}

Usage​

let rows = @sql_query("SELECT * FROM users WHERE id = :id");

The brace forms @name[…] and @name{…} exist for DSLs that prefer square brackets (array-like) or braces (block-like):

let m = @matrix[
[1, 0, 0],
[0, 1, 0],
[0, 0, 1]
];

@matrix here stands for any such DSL rather than for a macro that ships: no name of that shape is registered, so writing it draws warning<E0410>: unknown meta-function and the expression types as Unit. The brackets are what the block is about.

A brace-delimited invocation is parsed as an EXPRESSION, not as a raw token tree, so a DSL whose content is not Verum — HTML, say — does not get through today. quote { <div> } does, because quote takes a token tree; @html_block{ <div> } gives "standalone comparison operator is not a valid expression". The shape a DSL macro is meant to take:

let dsl = @html_block{
<div class="card">
<h2>{title}</h2>
</div>
};

All three forms have identical semantics; they differ only in which closing delimiter the parser expects.

Expression vs item position​

A function-like macro may appear:

  • In expression position: the output must parse as an expression.
  • In statement position: the output may parse as a statement or a block.
  • In item position: the output may contain one or more declarations.

The compiler picks the expected shape from syntactic context and reports a diagnostic if the expansion does not match.

Declarative macros (pattern-based)​

A declarative macro is pure syntax-directed rewriting, no meta function body required. It is Verum's answer to Rust's macro_rules! or Scheme's syntax-rules.

Shape​

@declarative
pub macro vec3 {
( $x:expr , $y:expr , $z:expr ) => quote {
Vec3 { x: $x, y: $y, z: $z }
};

( $v:expr ; $n:expr ) => quote {
{
let value = $v;
Vec3 { x: value, y: value, z: value }
}
};
}

Usage​

let a = @vec3!(1.0, 2.0, 3.0); // matches the first rule
let b = @vec3!(0.0; 3); // matches the second rule

Token-tree fragments​

Inside a pattern, $name:kind captures a token tree of a given kind. Supported kinds:

KindMatches
exprA full expression
stmtA statement
tyA type expression
patA pattern
identAn identifier
literalA literal token
pathA dotted or ::-separated path
blockA { ... } block
metaA meta token (used in attribute composition)
ttA single token tree (any shape)

Repetition​

Fragments can be repeated with $( … )sep where sep is an optional separator token:

@declarative
pub macro println {
( $fmt:literal $( , $arg:expr )* ) => quote {
IO.println(&f"${fmt}"$(, ${arg})*)
};
}

@println("x = {}, y = {}", x, y);

When to reach for each form​

Pick the minimum power:

  1. If you're deriving a protocol from a type's shape → derive macro.
  2. If you're transforming a single item (function, type, impl) → attribute macro.
  3. If your input is pure syntactic pattern-matching with no semantic analysis → declarative macro.
  4. If you need arbitrary computation over token trees, interleaved with type reflection or build-asset access → function-like macro.

Using a more powerful form than necessary makes macros harder to read, harder to maintain, and slower to compile. The declarative form is strictly faster to expand than the function-like form because it does not need to invoke the meta interpreter.

Error handling inside macros​

Every form can emit compile errors through CompileDiag:

CompileDiag.emit_error("can only derive for records", span);
CompileDiag.emit_warning("this macro is deprecated — prefer @NewApi",
Span.call_site());
CompileDiag.emit_note("see docs at /docs/language/meta/...",
Span.call_site());
return TokenStream.empty(); // nothing to splice; the error already stands

The emitted diagnostics participate in the normal Verum error pipeline: they appear in verum build output, the LSP shows them inline, and they are fully structured (severity, class, primary and secondary spans, notes, help) so test suites can match them programmatically. See Diagnostics.

Integration with the context system​

Every macro declares its capabilities with using [...]. The set of contexts the macro may request is the 14 compile-time meta-contexts documented under stdlib → meta. The most common combinations:

For …Use
a simple derive[TypeInfo, AstAccess]
a derive that emits diagnostics[TypeInfo, AstAccess, CompileDiag]
a derive that needs fresh identifiers[TypeInfo, AstAccess, Hygiene]
an attribute that reads project state[AstAccess, ProjectInfo, CompileDiag]
a literal handler[AstAccess, CompileDiag]
a schema-driven code generator[BuildAssets, TypeInfo, Schema, CompileDiag]
a build-time feature flag[ProjectInfo, CompileDiag]

A macro that requests a context it never uses emits a linter warning. A macro that uses a context it did not declare is a MacroError — the compiler refuses to expand it.

See also​

  • Compilation model — how macros are scheduled in the multi-pass pipeline.
  • Quote and hygiene — the quote { ... } template language.
  • Token-stream API — the TokenStream, FnAst, and related types macro bodies manipulate.
  • Derives — the shipped derive catalogue with exact generated-code semantics.
  • Diagnostics — diagnostic categories and structure.