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.
fn $T::describe(self : $T) -> String {
ignore(self)
$text
}
// Generated by describe from models.mbt. Edit those files, not this one.
///|
fn Point::describe(self : Point) -> String {
ignore(self)
"Point(x, y)"
}
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.
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")
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)
///|
fn answer() -> Int {
42
}
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:])
}
// Generated by describe from models.mbt. Edit those files, not this one.
///|
fn Point::describe(self : Point) -> String {
ignore(self)
"Point(x, y)"
}
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.
The declaration is annotated
#demo.describe sits above pub struct Point. Nothing else
about the struct changes.
gen reads it
@gen.read gives every declaration of the input files as a
Decl, in source order.
attr binds the annotation
@attr.find locates it; a Spec turns its payload into typed
arguments — or refuses with the line and column.
quote fills a template
@quote.items(...).set(...).render() gives a Code, one per
annotated declaration, joined into one.
cli writes the file
@cli.run writes it; with --check it compares instead, and
refuses when the file is stale.
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, andPoint::describeexists. The build rule, the--checkflag and a generator whose annotation takes arguments are in the guide.
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.
| Written | Reads as | Kind |
|---|---|---|
#app.get("/users") | one string, by position | Str |
cache=true | a boolean | Bool |
style=plain | a bare name | Ident |
response="Array[User]" | text that must parse as a type | Type |
tags="a", tags="b" | a list, written as a repeated key | many("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.
One job each
Real MoonBit
A template is parsed, not assembled from strings. Escaping and layout are the official ones.
Typed arguments
Declare what an annotation takes once, then read strings, names, booleans and types by key.
Deterministic output
One header, one body, the same bytes for the same inputs — so a stale file is a diff you can fail on.
Positions in refusals
Every refusal carries the line and column of what was wrong, not a stack trace.
| Package | What it holds |
|---|---|
moonast/quote | Templates: real MoonBit with $holes, filled by replacing nodes and printed by the official formatter. |
moonast/attr | Annotations as typed arguments: find #lib.name(...) and bind its payload against a spec. |
moonast/gen | Declarations read into plain views, the Generator trait, and render, which gives the file. |
moonast/cli | run: the whole main of a generator binary, native and wasm. |