GuideThe Silo HandbookMacros

Chapter 19 Macros

Source

A 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:

  1. Parse — source becomes an initial AST.
  2. Declare — all top-level names (types, traits, words, macros) are registered before anything else happens.
  3. Interleaved expand + type-check — macro invocations are expanded in dependency order; each expanded AST is type-checked before the next macro sees it.
  4. Compile — final typed AST becomes bytecode.
  5. 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 Token is inserted verbatim.
  • An Expr is inserted as a pre-parsed node; when the stream is re-parsed, the Expr passes through intact.
  • A (Vec Expr) (a Block) 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 Display produces a whole :impl block.
  • 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 :macro inside :impl, derive :derive — all share one AST-manipulation substrate.
  • :quote / '(...) produces TokenSeq = (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.
  • :quote containing 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.