#moonast
Code generation for MoonBit

The annotation
writes the file.

moonast reads the declarations you have already written, finds the annotations on them, and writes the code those annotations ask for. A template is real MoonBit with $holes — parsed, filled by replacing nodes, and printed by the official formatter, so the result is laid out the way moon fmt lays it out.

moonast/quote moonast/attr moonast/gen moonast/cli
describe recorded
declaration models.mbt
template the generator's own
fn $T::describe(self : $T) -> String {
  ignore(self)
  $text
}
generated describe.g.mbt
// Generated by describe from models.mbt. Edit those files, not this one.

///|
fn Point::describe(self : Point) -> String {
  ignore(self)
  "Point(x, y)"
}
loading the enginemoonbitstack/moonast
4packages: quote, attr, gen, cli
6forms a hole can take
4declaration views: struct, enum, func, other
5ways an annotation's arguments are refused
The three layers

Read a declaration. Fill a template. Write a file.

Each layer is one package and each is usable on its own. A generator is the three of them in sequence, with a main that hands the result to a file.

01 — moonast/gen

Read declarations

A file goes in, plain views come out: Struct, Enum, Func. Types are kept as the source text that wrote them, which is exactly what a generator splices into new code.

let source = "pub struct Point {\n  x : Int\n  y : Int\n}"
guard @gen.read("models.mbt", source) is [Struct(name~, fields~, ..)] else {
  abort("a struct")
}
inspect(name, content="Point")
inspect(fields.map(f => f.name).join(", "), content="x, y")
02 — moonast/quote

Fill templates

A template is MoonBit with $holes. It is parsed with the holes standing as placeholders; each fill replaces the node its placeholder stands for.

let source =
  #|fn $name() -> $T {
  #|  $value
  #|}
let code = @quote.items(source)
  .set("name", @quote.ident("answer"))
  .set("T", @quote.ident("Int"))
  .set("value", @quote.int(42))
  .render()
println(code)
output
///|
fn answer() -> Int {
  42
}
03 — moonast/gen + cli

Write the file

A generator sees every declaration of its inputs and gives back the code to write. render puts one header above it, so the same inputs give the same bytes.

async fn main {
  @cli.run(Describe::{}, @env.args()[1:])
}
describe.g.mbt
// Generated by describe from models.mbt. Edit those files, not this one.

///|
fn Point::describe(self : Point) -> String {
  ignore(self)
  "Point(x, y)"
}
One chain

From an annotation to a file, in five calls

Each step is a call you can make on its own. This is what a dev_build rule runs on every build.

1

The declaration is annotated

#demo.describe sits above pub struct Point. Nothing else about the struct changes.

2

gen reads it

@gen.read gives every declaration of the input files as a Decl, in source order.

3

attr binds the annotation

@attr.find locates it; a Spec turns its payload into typed arguments — or refuses with the line and column.

4

quote fills a template

@quote.items(...).set(...).render() gives a Code, one per annotated declaration, joined into one.

5

cli writes the file

@cli.run writes it; with --check it compares instead, and refuses when the file is stale.

the generator
struct Describe {}

impl @gen.Generator for Describe with fn name(_) {
  "describe"
}

impl @gen.Generator for Describe with fn generate(_, decls) {
  let out = []
  for decl in decls {
    guard decl is Struct(name~, fields~, attrs~, ..) &&
      @attr.find(attrs, "demo.describe") is Some(_) else {
      continue
    }
    let text = name + "(" + fields.map(f => f.name).join(", ") + ")"
    let source =
      #|fn $T::describe(self : $T) -> String {
      #|  ignore(self)
      #|  $text
      #|}
    out.push(
      @quote.items(source)
      .set("T", @quote.ident(name))
      .set("text", @quote.string(text))
      .render(),
    )
  }
  @quote.join(out)
}
That is the whole generator, and Point::describe exists. The build rule, the --check flag and a generator whose annotation takes arguments are in the guide.
The grammar

What an annotation may carry

The official parser decides this, so it is the same in every MoonBit module: strings, true/false, bare names and nested calls. A list is a repeated key, and moonast's Spec::many is how a generator collects one.

WrittenReads asKind
#app.get("/users")one string, by positionStr
cache=truea booleanBool
style=plaina bare nameIdent
response="Array[User]"text that must parse as a typeType
tags="a", tags="b"a list, written as a repeated keymany("tags", Str)

There are no arrays and no numbers in an annotation payload, a generic or qualified type is written as a string, and a keyword cannot be a label. A payload outside that grammar leaves the annotation without a name, so find does not see it at all.

Four packages

One job each

quote

Real MoonBit

A template is parsed, not assembled from strings. Escaping and layout are the official ones.

attr

Typed arguments

Declare what an annotation takes once, then read strings, names, booleans and types by key.

gen

Deterministic output

One header, one body, the same bytes for the same inputs — so a stale file is a diff you can fail on.

cli

Positions in refusals

Every refusal carries the line and column of what was wrong, not a stack trace.

PackageWhat it holds
moonast/quoteTemplates: real MoonBit with $holes, filled by replacing nodes and printed by the official formatter.
moonast/attrAnnotations as typed arguments: find #lib.name(...) and bind its payload against a spec.
moonast/genDeclarations read into plain views, the Generator trait, and render, which gives the file.
moonast/clirun: the whole main of a generator binary, native and wasm.