Macros

A macro is an ordinary mu function that runs at compile time. It takes code and returns code:

fn(CODE…) -> CODE

Everything else follows from that. There is no separate macro language, no AST map format to learn, and no host API to memorise.

Code is a value

quote { … } evaluates to a code value — mu’s CODE type, as first-class as a list or a closure. Inside a quote, unquote is a hole: the expression is evaluated in the enclosing scope and its code is spliced in.

c := quote { x + 1 }
print(type(c))     // CODE

A quote is a literal with holes, not a string. Everything inside it is real mu syntax, parsed and checked as such, and source positions survive into the expansion — so a diagnostic can point at where the code was actually written.

Define a macro

import "macro"

macro.define("unless", fn(condition, body) {
  return quote {
    if !unquote condition {
      unquote body
    }
  }
})

unless(ready, start())

The handler receives one code value per argument the caller wrote, and returns a code value. Argument counts are checked like any other mu call.

A macro may be variadic, in which case the trailing parameter holds a list of code values that ... splices back out:

macro.define("listOf", fn(items...) {
  return quote { [unquote items...] }
})

listOf(1, 2, 3)     // [1, 2, 3]
listOf()            // []

macro.define must appear at top level, but it does not have to precede the first call that uses it. The compiler scans the whole file for macro registrations before expansion; keeping definitions near their uses is a style choice, not a language restriction.

Two things worth knowing

unquote binds like a prefix operator, at the same level as ! and -. It binds tighter than every infix operator but still absorbs call, index and member chains:

quote { (unquote list)[0] }      // index the caller's list
quote { unquote list[0] }        // index the code value — almost never right

quote always yields block code, never an expression. That means a quote’s kind never depends on runtime data. The cost is paid at the splice site: block code used where an expression is expected unwraps when it holds exactly one expression, and is an error otherwise.

A worked example: writing iterators

The clearest sign a macro is the right tool is a return that belongs to the caller. A function cannot return on its caller’s behalf, so anything that has to leave the calling function has to expand into it.

lib/iter.mu exports two such macros. foreach pulls an iterator until it is dry, and emit publishes one value — returning from the producer if the consumer has cancelled it:

import "iter"

fn double(source) {
  return iter.iterator(fn(yield) {
    iter.foreach(item, source, fn() {
      iter.emit(yield, item * 2)
    })
    return nil
  }, source)
}

print(iter.collect(double(iter.from_array([1, 2, 3]))))   // [2, 4, 6]

Written out by hand that same combinator is a while true, a pull, an end-of-stream test and a cancellation guard — four lines of protocol around one line of work, repeated in every combinator, and wrong the first time anybody forgets the guard.

foreach splices its body rather than calling it, which is what makes return, break and continue mean what they look like:

fn find_over(source, limit) {
  iter.foreach(value, source, fn() {
    if value > limit {
      iter.stop(source)
      return value          // returns from find_over
    }
  })
  return nil
}

A higher-order function taking a callback can offer none of those three. That is the difference between a macro and a function here, and it is the whole reason iter.each cannot replace iter.foreach.

One rule comes with writing macros like this: use each argument once, or bind it first. foreach binds its source to a temporary before the loop, so iter.foreach(x, from_array(xs), …) builds one iterator rather than a fresh one on every pass. Hygiene protects names; nothing protects evaluation count.

Where macros run

Macros expand on the build machine, before bytecode is emitted, so nothing about them survives into a compiled binary — there is no runtime cost and no runtime dependency.

For the same reason the macro phase withholds capabilities that would make a build depend on where it ran: the filesystem, the environment, and raw syscalls. A module that genuinely needs one opts in with macro.requires(["env"]), which also records that its expansions are not reproducible across machines.

Read more in docs/Macros.md, docs/MacroTutorial.md, and — for the reasoning and the rejected alternatives — docs/MacroOverhaulRFC.md.

Next steps