Chapter 19 Macros
SourceA macro in Silo is an ordinary compile-time word. It has a stack effect, a typed body, and local bindings — everything a runtime word has — with one distinction: its output is interpreted as compilable syntax and spliced back into the caller, where the compiler type-checks it in context.
Silo provides three macro forms, all built on the same underlying machinery:
| Form | Declared as | When it runs |
|---|---|---|
| Standalone | Top-level :macro |
When invoked by name |
| Self | :macro inside a bare :impl |
On the expression preceding .name |
| Derive | Top-level :derive |
Via a derive list on a :record / :union |
All three are hygienic — macro-introduced bindings are alpha-renamed so they can't collide with names at the call site.
The compilation pipeline
Macros don't bolt onto the compiler — they run inside a specific pipeline stage. The stages are:
- Parse — source becomes an initial AST.
- Declare — all top-level names (types, traits, words, macros) are registered before anything else happens.
- Interleaved expand + type-check — macro invocations are expanded in dependency order; each expanded AST is type-checked before the next macro sees it.
- Compile — final typed AST becomes bytecode.
- Execute — bytecode runs.
The interleaved phase is the interesting bit: a macro body can depend on types declared anywhere in the unit (because declare happened first), and the output of one macro can feed another (recursive expansion, up to a fixed depth limit).
Tokens and quasiquoting
Macros produce code. The cheapest way to build code is to write it directly and have the compiler hand you the tokens.
:quote ... :end (or its sugar '( ... )) scans its body as a balanced token stream and returns it as a TokenSeq = (Vec Token):
:quote dup 2 * :end # type: TokenSeq
'( Some v ) # same, sugar form
The tokens inside a quote are opaque to the type checker. They don't have to be well-formed Silo expressions — the compiler only checks that brackets balance. That lets a macro emit arbitrary token-level constructs.
Escape — { expr }
Inside a quote, { expr } escapes to compile time. The expression is evaluated at macro-expansion time and the result is spliced back into the surrounding token stream:
pop-> x # x is an Expr from the macro input
:quote { x } 2 * :end # splices x into the token stream
The escape type determines how splicing works:
- A
Tokenis inserted verbatim. - An
Expris inserted as a pre-parsed node; when the stream is re-parsed, the Expr passes through intact. - A
(Vec Expr)(aBlock) is spliced element-by-element.
There is no separate "unquote" vs "unquote-splice" syntax — { expr } handles both cases by looking at the type. If you escape a (Vec Expr), every element is interleaved with the surrounding tokens.
pop-> body # body is (Vec Expr)
:quote { body } dup :end # each Expr in body is spliced in,
# followed by the literal `dup` token
Parser intrinsics
When a macro needs structured access to the tokens (destructuring, pattern-matching on the AST), it calls a parser intrinsic:
| Intrinsic | Signature | What it parses |
|---|---|---|
parse-expr |
( (Vec Token) -> Expr (Vec Token) ) |
One expression off the front |
parse-pattern |
( (Vec Token) -> Pattern (Vec Token) ) |
One :match pattern off the front |
parse-type |
( (Vec Token) -> Str (Vec Token) ) |
One type expression (returned as a Str) |
parse-block |
( (Vec Token) -> (Vec Expr) (Vec Token) ) |
A body of expressions until exhausted |
Each consumes tokens from the front and returns both the parsed value and the leftover tokens, so you can parse several constructs from one stream.
:quote 1 2 + :end parse-block drop # ⌊[IntLit(1) IntLit(2) WordCall("+")]⌉
:quote Some v :end parse-pattern drop # ⌊VariantPat("Some" ["v"])⌉
The typical pattern is: :quote to produce tokens, parser intrinsic to get structure, manipulate the structured AST, then either build more tokens with :quote or construct AST nodes directly.
Built-in AST types
The compiler provides named types for every AST shape a macro might want to produce. The important ones:
| Type | What it is |
|---|---|
Token |
A single lexer token — literals, identifiers, punctuation |
TokenSeq |
Alias for (Vec Token) |
Expr |
A single expression node (literal, call, quote, match, …) |
Block |
Alias for (List Expr) — a sequence of expressions |
Pattern |
A pattern (variant / literal / range / wildcard) |
MatchArm |
A record of .pattern Pattern + .body Block |
TypeExprAst |
A type expression (concrete, var, applied, quotation, …) |
TypeDef |
Record RecordDef | Union UnionDef |
Decl |
Top-level decl: FnDecl | ImplDecl | RecordDecl | UnionDecl | AliasDecl | NewTypeDecl |
Decl's variants each carry a concrete record (FnDefAst, ImplDefAst, RecordDef, UnionDef, etc.) with all the parts you'd need — name, signature, fields, variants, body. Derive macros build these explicitly and return them as a (Seq Decl).
Standalone macros
:macro double-it ( -> TokenSeq )
:quote dup + :end
:end
Signature ( -> TokenSeq ) — no inputs, produces a token stream. At the call site the tokens are spliced into the caller's source:
5 double-it # expands to: 5 dup +
A macro that consumes input from its surroundings takes Expr / Block / Pattern etc. in its signature:
:macro when ( Expr Block -> Block )
pop-> body pop-> cond
( Vec .default) pop-> else-body
body else-body IfExpr pop-> if-node
( Vec .default) cond .push if-node .push
:end
when takes the preceding Expr (the condition) and the following Block (the body), and returns a Block that contains one IfExpr node. The macro is written as ordinary Silo code — pattern-match, build values, return them.
Self macros
A :macro declared inside a bare :impl block is a self macro. It dispatches on the name via field-access syntax and receives the preceding expression as its first argument:
:impl Point
:macro .debug-fields ( Expr -> TokenSeq )
pop-> self
:quote { self } .x "{}" format
{ self } .y "{}" format concat :end
:end
:end
my-point .debug-fields
# expands to:
# my-point .x "{}" format my-point .y "{}" format concat
The Expr input is the source of my-point above — the macro can splice it as many times as it needs, which a regular method couldn't do without duplicating the value at runtime.
Dispatch is by name. If only one type in scope defines .debug-fields, the invocation is unqualified. If two do, write (Type .name) to disambiguate:
( Point .debug-fields) my-point
Failing to disambiguate is a compile error; the compiler doesn't try to guess.
Derive macros
A :derive declaration lets a trait be auto-implemented for any type that lists it in a derive list. The macro receives the type's full TypeDef and returns a sequence of declarations:
:derive Display ( TypeDef -> ( Seq Decl ) )
pop-> def
def :match
| Record info => info derive-display-record
| Union info => info derive-display-union
:end
:end
Invocation is via the derive list on a :record or :union declaration (see chapter 5):
:record Point [ Display Eq Ord]
.x Float
.y Float
:end
[Display Eq Ord] runs the corresponding derive macros against Point's TypeDef. Each one produces a (Seq Decl) — typically one ImplDecl per trait — and the compiler splices them into the enclosing module.
:quote and declarations
When the body of a :quote contains declaration syntax (:impl, :fn, :record, …), the quote produces a (Seq Decl) instead of a TokenSeq. This is what lets a derive macro build an impl block without hand-constructing the ImplDefAst:
:derive Display ( TypeDef -> ( Seq Decl ) )
pop-> def
def :match
| Record info =>
:quote
:impl ( Display { info .name ConcreteType })
.fmt ( { info .name ConcreteType } FmtSpec -> Str )
# body using info .fields
;
:end
:end
| Union info =>
# ...
:end
:end
If the trait's semantics require constraints on the type's parameters (e.g., deriving Eq for (Option elem) needs (Eq elem elem)), the derive macro generates them on the :impl block explicitly — it's ordinary AST construction.
Hygiene
Every binding a macro's :quote introduces is alpha-renamed with a unique gensym during expansion, so it can never shadow or be shadowed by a caller's binding:
:macro swap-and-double ( Expr Expr -> TokenSeq )
pop-> b pop-> a
:quote pop-> temp { b } { a } pop-> temp :end
:end
The temp introduced inside the quote is renamed (conceptually temp#42) so it does not collide with any temp the caller happens to have in scope.
The flip side — deliberately referring to a caller's binding — requires an explicit splice ({ ... }) rather than a :quote-introduced name. The compiler treats every quote-introduced identifier as fresh; only spliced Expr values reach back into caller scope.
When to reach for a macro
Most abstraction in Silo goes through ordinary words, traits, and quotations. Reach for a macro when:
- The behaviour genuinely needs the source of its input —
assert-style macros that mention the failing expression in their error message. - You need to emit multiple declarations at once —
:derive Displayproduces a whole:implblock. - You want DSL-shaped syntax that isn't worth adding to the language — embedded query builders, test-assertion DSLs, build-spec helpers.
If a word suffices, use a word. Macros trade legibility for power: the caller has to reason about when the code runs. Save them for cases where that trade is clearly worth it.
Key points
- A macro is a compile-time word whose output is spliced into the caller's source and re-type-checked.
- Three forms — standalone
:macro, self:macroinside:impl, derive:derive— all share one AST-manipulation substrate. :quote/'(...)producesTokenSeq = (Vec Token)— opaque tokens, not structured AST.- Structured AST comes from parser intrinsics:
parse-expr,parse-pattern,parse-type,parse-block. { expr }inside a quote splices in compile-time values;Token,Expr, and(Vec Expr)splice correctly based on type.:quotecontaining declaration syntax (:impl,:fn, …) produces(Seq Decl)— the shape derive macros return.- Hygiene is automatic — compiler alpha-renames quote-introduced bindings.
Next: program structure — :main, :init, :test, and the lifecycle of a whole Silo program.