moonast

    Code generation for MoonBit from annotated declarations.

    moonbit
    codegen
    ast
    macro
    derive
    attribute
    Download zip
    Version
    0.2.2
    License
    Apache-2.0
    Last updated
    2 days ago
    Downloads
    574

    #$# moonast

    The annotation writes the file.

    #demo.describe above a struct  ·  a file of methods out


    #What it does

    A generator reads declarations you have already written, finds the annotations on them, and writes the code those annotations ask for. moonast is the three pieces that make that possible, one job each:

    LayerPackageWhat it does
    Readmoonast/genParses a file and gives every declaration as a plain view: Struct, Enum, Func. Types are kept as the source text that wrote them.
    Writemoonast/quoteA template is real MoonBit with $holes. It is parsed, each hole is filled by replacing the node it stands for, and the official printer lays the result out.
    Generatemoonast/gen, moonast/clirender puts a header above the code; run is the whole main of a generator binary.

    Between them, moonast/attr turns an annotation's payload into typed arguments.

    #A complete derive

    The generator is written once, as a module of its own. A project that uses it writes one declaration, one rule in moon.mod, and one line in moon.pkg.

    #1 · The declaration you annotate

    // models.mbt
    #demo.describe
    pub struct Point {
    x : Int
    y : Int
    }

    #2 · The generator, written once

    // src/main.mbt — give every annotated struct a `describe` method
    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)
    }

    async fn main {
    @cli.run(Describe::{}, @env.args()[1:])
    }

    // moon.pkg of that package import { "moonbitstack/moonast/quote", "moonbitstack/moonast/attr", "moonbitstack/moonast/gen", "moonbitstack/moonast/cli", "moonbitlang/async", "moonbitlang/core/env", }

    #3 · The rules that keep it up to date

    // moon.mod of the project that uses the generator rule(name: "describe", command: "moonx <module>@<version> -C \"$mod_dir\" \"$input\" -o \"$output\"")

    // moon.pkg of the package whose declarations it reads dev_build(rule: "describe", input: "models.mbt", output: "describe.g.mbt") formatter(ignore: [ "describe.g.mbt" ])

    #4 · The file it writes

    Running moon run . --target native -- models.mbt -o describe.g.mbt writes:

    // Generated by describe from models.mbt. Edit those files, not this one.

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

    From then on moon check, moon build and moon run regenerate it whenever models.mbt changes.

    #An annotation that takes arguments

    #demo.getters(skip=token) gives every field a getter except the ones named. The payload is read against a spec, so a missing or mistyped argument is refused with the annotation's line and column instead of producing code:

    impl @gen.Generator for Getters with fn generate(_, decls) {
    let out = []
    for decl in decls {
    guard decl is Struct(name~, fields~, attrs~, ..) else { continue }
    guard @attr.find(attrs, "demo.getters") is Some(annotation) else { continue }
    let skip = @attr.Spec::new("demo.getters")
    .many("skip", Ident)
    .bind(annotation)
    .strs("skip")
    for f in fields {
    guard !skip.contains(f.name) else { continue }
    let source =
    #|fn $T::$field(self : $T) -> $Ty {
    #| self.$field
    #|}
    out.push(
    @quote.items(source)
    .set("T", @quote.ident(name))
    .set("field", @quote.ident(f.name))
    .set("Ty", @quote.ty(f.ty).render())
    .render(),
    )
    }
    }
    @quote.join(out)
    }

    #demo.getters(skip=token)
    pub struct User {
    id : Int
    name : String
    token : String
    }

    // Generated by getters from models.mbt. Edit those files, not this one.

    ///|
    fn User::id(self : User) -> Int {
    self.id
    }

    ///|
    fn User::name(self : User) -> String {
    self.name
    }

    #A template on its own

    No generator binary is needed to use a template. A package that builds code can fill one directly:

    let source =
    #|fn $T::$field(self : $T) -> $Ty {
    #| self.$field
    #|}
    let code = @quote.items(source)
    .set("T", @quote.ident("User"))
    .set("field", @quote.ident("id"))
    .set("Ty", @quote.ty("Int").render())
    .render()
    println(code)

    #Interface

    #moonast/quote

    ItemTakesGives
    items(source)MoonBit declarations with holesa Template
    expr(source)one expression with holesa Template
    ty(source)one type with holesa Template
    Template::set(hole, code)a hole's name, a Codethe template, filled
    Template::splice(hole, codes)a list hole's name, codesthe template, spliced
    Template::render()—a Code, or Refused
    ident(name)a name, or @pkg.namea Code, or Refused
    string(text)any texta Code
    int(value), bool(value)a literala Code
    join(codes)declaration codesone Code, or Refused
    Code::items()—the official syntax tree, when it is declarations
    Code::to_string()—the source text

    #moonast/attr

    ItemTakesGives
    name_of(attr)an annotationits full name, lib.name
    find(attrs, name)annotations, a namethe first one called that
    all(attrs, name)annotations, a nameevery one called that
    under(attrs, lib)annotations, a prefixevery one under lib.
    Spec::new(name)an annotation's namea spec with no parameters
    Spec::positional(key, kind)required, by position or namethe spec
    Spec::required(key, kind)required, by namethe spec
    Spec::optional(key, kind)may be left outthe spec
    Spec::many(key, kind)may be given againthe spec
    Spec::bind(attr)an annotationits arguments, or Refused
    Args::get, str, strs, bool, hasa parameter's keywhat was given for it

    Kind is Str, Bool, Ident or Type; each Args reader answers in the kind its spec asked for.

    #moonast/gen

    ItemTakesGives
    read(file, source)a file name and its sourceits declarations, or Refused
    render(generator, inputs)a generator and its inputsthe file to write
    Generator::name()—the name in the header
    Generator::generate(decls)every declarationthe code to write
    Decl—Struct, Enum, Func or Other
    Decl::attrs(), Decl::raw()—the annotations on it, the syntax tree behind it
    Field, Param, Pass—a struct field, a parameter, how it is passed

    #moonast/cli

    ItemTakesGives
    run(generator, args)a generator, [-C <dir>] <inputs...> -o <output> [--check]writes the file, or refuses when it is stale

    #Holes

    HoleTakesWritten asGives
    $Ta type or a constructorfn f(x : $T)fn f(x : Int)
    $namea value, an expression, or a name$value + 141 + 1
    @pkg.$namea name inside a packageapp : @web.$Tapp : @web.App
    $name*a list of them[$items*][1, 2, 3]
    $name in a stringa name or a string, as text"field $key""field first name"
    $$a literal dollar sign"$$5""$5"

    A hole's case is part of the grammar: upper case where a type or constructor goes, lower case where a value or a binder goes. A list hole expands among statements, array elements, and the arguments of a function application.

    #Annotation arguments

    The parser reads strings, true/false, names and nested calls as annotation arguments. There are no arrays and no numbers, a generic or qualified type has to be written as a string, and a keyword cannot be a label. A list is written as a repeated key, tags="a", tags="b", which Spec::many accepts.

    When a payload is outside that grammar, the annotation has no name as far as moonast is concerned, so find does not see it.

    #Refusals

    Every fallible call gives a Refused with a message, and with a position where there is one to give:

    FromRefused when
    quotethe template is not MoonBit, a hole is left empty, a fill names no hole, or a fill does not fit where it stands
    attrthe payload cannot be read, a required parameter is missing, one is unknown, mistyped, or given twice
    genan input file is not MoonBit
    clithe command line is wrong, or --check found the output out of date

    #Examples

    DirectoryWhat it is
    src/examples/tourThe whole surface in one run: read, bind, generate.
    src/examples/describeA minimal derive generator: #demo.describe gives a struct describe.
    src/examples/gettersA generator whose annotation takes arguments: #demo.getters(skip=token).
    src/examples/playThe same three packages compiled to JavaScript, which is what the site's playground runs.

    #Licence

    Apache-2.0.

    Powered by MoonBit

    Site sourceReport issueModulesSkillsBuild queueStatistics

    © 2026 mooncakes.io