Source generators¶
A source generator writes or replaces Dream code while your program is being built. Use it for repeated code or a small custom notation. Dream does not inspect arbitrary type definitions at runtime.
At a glance
- A generator writes or replaces Dream code while your program builds.
- Generators ship as ordinary packages, like
@jsoninsystem.json.
A generator is an ordinary Dream function compiled into a small cached executable. It runs before type-checking and does one of two things:
- Replace custom syntax —
quote { … }orhtml { … }become ordinary Dream expressions at call sites. Use this when the braced body is a domain-specific shape the main parser does not understand. - Emit new Dream source — for example
@jsonaddsto_json/from_jsonto your types. Use this when call sites stay ordinary Dream and the generated code is methods or helpers.
API details live in system.codegen. The shipped @json derive is itself a Dream generator in system.json; see JSON.
Your first generator: quote¶
quote { … } turns the text inside the braces into a Dream string literal at compile time.
Expected stdout: Hello generators
Full sample: sample/generators/quote/.
Register the generator¶
Tell the compiler which file owns the generator:
**dream.toml**— list the generator next to your entry file (search walks upward from the entry file's directory).**import**— import a module that contains the@generatorfunction. This is how the standard library ships@json:import system.json;makes it available in single files, packages, dependencies,dream test, and wasm builds alike.
module gen;
import system.codegen;
// `quote { ... }` becomes a Dream string literal at compile time.
@generator
@syntax_block
public fun quote(ctx: GenContext): void {
for (let block in ctx.syntax_blocks()) {
ctx.replace(block, as_dream_string(block.body.trim()));
}
}
fun as_dream_string(s: string): string {
let escaped = s.replace("\\", "\\\\").replace("\"", "\\\"");
return "\"" + escaped + "\"";
}
The function takes a single GenContext. It queues rewrites with ctx.replace and reports failures with ctx.error. The compiler validates and applies them before type-checking.
Triggers¶
A generator runs only when the program contains something it declares interest in. If no trigger matches, the generator is skipped without building or launching anything, so importing a generator package costs nothing for programs that don't use it.
| Attribute | Runs when | Context API |
|---|---|---|
@syntax_block |
a name { … } site uses the generator's function name |
ctx.syntax_blocks() |
@on_attribute(A) |
a declaration (or one of its members) or a module declaration carries @A |
ctx.types_with<A>(), ctx.functions_with<A>(), ctx.modules_with<A>(), ctx.decls() |
@on_call(f, …) |
the program calls one of the named functions | ctx.call_sites() |
@incremental |
(modifier) | replays the cached result when the generator and its snapshot are unchanged |
Triggers are paths, not strings. @on_attribute(jsn) is a compile error ('jsn' is not a declared @attribute type), and so is naming something that is not an @attribute struct. A generator may combine several triggers; @json uses @on_attribute(JsonAttribute) and @on_call(Json.serialize, Json.deserialize, Json.from_value).
Without a matching @syntax_block generator, a name { … } site fails with “unexpanded syntax block”.
Syntax DSL rules¶
For each name { … } site, GenSyntaxBlock provides:
**body**— opaque text inside the braces (your DSL grammar).**splices**— Dream source of each{expr}splice, in order. Splices are real expressions and type-check after the rewrite.
Rules:
- The introducer is a bare identifier (
quote,html, …), not a keyword. Pick a name that will not collide with identifiers in user scope. - Text inside
{ … }is opaque to the Dream parser until your generator rewrites the site. - Errors inside a replaced site — whether the generated text fails to parse or does not type-check — are reported against the original
name { … }block, not the replacement text.
A larger example: HTML¶
Same call-site shape as quote, but the sample parses markup and turns it into ordinary Dream calls.
import system;
import html;
fun page(title: string): string {
return html {
<div class="hero">
<h1>{title}</h1>
<p>Welcome</p>
</div>
};
}
fun main() {
System.println(page("Hello"));
}
HTML is not a language builtin. The sample's gen.dream calls HtmlCompiler (in parser.dream) from its generator body, so the complexity stays in helper modules.
Full sample: sample/generators/html/.
| File | Role |
|---|---|
app.dream |
Program that uses html { … } |
gen.dream |
@generator @syntax_block replace logic |
parser.dream |
DSL → Dream source, with its own success/failure result class |
dream.toml |
[[generators]] path = "gen.dream" |
Attributes¶
Declare an attribute as a struct marked @attribute(AttributeTarget.X). The struct's fields are the attribute's arguments, and the target says where it may appear (Class, Struct, Union, Function, Field, …). Applying it elsewhere is a compile error such as '@route' cannot be applied to a class.
@attribute(AttributeTarget.Function)
public struct route {
public path: string;
}
@route("/users")
public fun list_users(): void {}
Inside a generator, read attributes as typed values: decl.has<route>(), decl.attribute<route>() (an Option<route> with path filled in), and attributes_of<A>() for repeated attributes.
Emit-style derives¶
Emit generators read declarations from the context and queue new source with ctx.emit_extend(decl, body) (adds members to an existing type) or ctx.emit_file(path, source) (adds a whole file).
Extensions retain the target declaration's module and internal visibility. Generated files
may import already-loaded modules and bind their functions using explicit import aliases;
they cannot load new source dependencies.
module gen;
import system.codegen;
@attribute(AttributeTarget.Class)
public struct dto {}
@generator
@on_attribute(dto)
public fun dto_derive(ctx: GenContext): void {
for (let t in ctx.types_with<dto>()) {
let b = CodeBuilder();
b.line("public fun describe(): string {");
b.indent();
b.line("return \"" + t.name + " is a dto\";");
b.dedent();
b.line("}");
ctx.emit_extend(t, b.to_string());
}
}
Full sample: sample/generators/dto/.
The declaration model is typed:
GenDeclcarrieskind(DeclKind.Class/Struct/Union/ …),fields,variants,methods,constructors, andgenerics.GenTypeRef.displayis a diagnostic spelling and can contain canonical module qualification. Usesource_name()in a scope importing the declaration, or emit explicit type import aliases when generating a different module.GenField.tyis aGenTypeRef. Useprimitive()(Primitive.Int, …),collection()(CollectionKind.List, …),is_option(), andarg(i)instead of comparing type names as strings.ctx.type_index()lists every type in the program by name and kind, andctx.find_type(name)looks one up.ctx.options<T>()decodesdream.toml[[generators]].optionsinto an@jsontype, andctx.additional_files()returns the files listed inadditional_files.
declof¶
declof(path) evaluates to the identity string of a type, function, field, method, or variant. Use it to compare against model identities, such as GenCallSite.callee or GenTypeRef.is_decl(id), without spelling them by hand:
let serialize = declof(Json.serialize); // "system.json::Json.serialize"
for (let call in ctx.call_sites()) {
if call.callee == serialize { /* ... */
}
}
A path that resolves to nothing is a compile error.
- Generated files
Emitted files are written to generated/<entry>/<module-path>/<generator>/ next to your project (add generated/ to .gitignore; dreamer init does). Diagnostics in generated code point at those files, so you can open and read exactly what was compiled. The language server reads the same files for go-to-definition and hover. The module path uses one directory per module segment and is omitted for an unnamed module. For a loose file without dream.toml, generated/ is created in the entry file's directory.
Generator executables and their build intermediates live in target/obj/generators/exe/<key>/ under the same project or entry directory. Deleting this directory rebuilds executables when they are next needed. Unchanged incremental results are still cached by the toolchain.
Generators in different modules may share a name. Syntax blocks resolve their generator through the source file's module, imports, and visibility; an ambiguous introducer produces a diagnostic. Generator commands accept a module::name identity, such as dream generate main.dream --explain system.json.derive::json_derive. A short name is accepted only when it identifies one registered generator.
Emitted files use the module declared in their source. Generated extension methods can access the extended type's private members. A generator's output may also use declarations explicitly selected for generation and their resolved dependencies, such as a web route's dependency provider. Unrelated file-private functions and internal declarations remain inaccessible to user-generated code.
Building source with CodeBuilder¶
When a generator emits multi-line Dream source, use CodeBuilder instead of manual indent strings:
import system.codegen;
let b = CodeBuilder();
b.line("public fun describe(): string {");
b.indent();
b.line("return \"ok\";");
b.dedent();
b.line("}");
let body = b.to_string();
Caching and speed¶
- Executables are built once per generator module and compiler, and cached under
~/.dream/cache/generators/exe/. The cache key is location-independent, so moving a project keeps its hits. - Incremental results:
@incrementalresults are replayed when the generator executable and its input snapshot are unchanged, and are still validated like a fresh run. - Parallelism: independent generator builds and runs execute in parallel;
DREAM_GEN_JOBS=Ncaps the worker count. - Prewarm:
dream generate --prewarmbuilds every standard-library generator executable ahead of time.dreamer toolchain installruns it for you, so the first@jsonbuild doesn't pay the generator build cost.
dream -v build app.dream prints one line per generator: skipped, exe hit / exe miss (build …), run …, or result hit.
The dream generate command¶
dream generate app.dream # list generators, triggers, and whether each runs
dream generate --explain json_derive app.dream # each cache-key component and what changed
dream generate --capture json_derive app.dream # save snapshot + result to target/obj/generators/captures/json_derive
dream generate --replay target/obj/generators/captures/json_derive # rerun the capture and diff against its result
dream generate --verify-incremental app.dream # rerun cached @incremental generators and diff results
dream generate --prewarm # build std generator executables into the cache
--capture and --replay let you reproduce a generator bug without the rest of the program: commit the capture directory, then replay it after changing the generator.
Debugging a generator¶
A generator is a normal Dream program, so you can set breakpoints in it, including in stdlib generators such as json_derive. Breakpoints also work in standard-library generators such as json_derive.
- VS Code: a ▶ Debug generator code lens appears above each
@generatorfunction. It launches the generator on the snapshot of the program that triggers it. The Dream: Debug generator launch-configuration snippet does the same fromlaunch.json("generator": "<name>", optional"snapshot": "<path>"). - CLI / other DAP clients:
dream debug-adapter --generator <name> app.dreamserves DAP over stdio for that generator. Add--snapshot <file or --capture dir>to debug a captured input instead of rebuilding it. - Inside a build:
dream build --debug-generator <name> app.dreamserves a DAP session on the generator, then finishes the build with its result.
If a generator crashes, the build reports the generator name and writes a crash report that includes the snapshot path, so you can rerun it with --snapshot.
Checklist¶
- Decide replace (DSL) or emit (derive).
- Write
@generatorplus a trigger:@syntax_blockonfun name(ctx: GenContext),@on_attribute(A), or@on_call(f). - For replace generators, call
ctx.syntax_blocks()→ctx.replace/ctx.error. For emit generators, callctx.types_with<A>()→ctx.emit_extend/ctx.emit_file. - Declare attributes as
@attribute(AttributeTarget.X) public struct name { … }. - Register via
[[generators]]indream.tomlor animportof the generator module. - Mark pure generators
@incremental. - Add tests in your project for successful generation, invalid input, and diagnostics.
See also¶
- system.codegen —
CodeBuilder,GenContext, declaration model - JSON —
@jsonderive - sample/generators/quote/
- sample/generators/html/
- sample/generators/dto/
Attribute contracts and enum arguments¶
Attributes are declared in Dream modules. Their fields define positional arguments, and
@attribute(AttributeTarget.X) declares their allowed placements. Compiler effects such as
inlining and C calling conventions are implemented by the compiler using the declaration's
module identity.
Core attributes need no import. Import system.interop for C/C++ attributes,
system.testing for @test and @timeout, and system.codegen for generator attributes.
JSON attributes remain in system.json.
Finite choices use enums rather than strings:
import system.interop;
@c("user32", "MessageBoxW")
@c_call(CCallConv.Stdcall)
@marshal(MarshalKind.LpWStr)
extern fun message_box(window: CPtr, text: string, title: string, flags: int): int;
MarshalKind.UserDataLast applies to NativeCallback parameters. LpStr and LpWStr
apply to C extern declarations. Compiler intrinsic declarations use
@intrinsic(Intrinsic.Print) and the other members of Intrinsic. Library paths and
external symbol names remain strings.
A trailing Option<T> field makes an argument optional: omission decodes as None, and
a supplied value as Some(value). Required fields cannot follow optional fields. A trailing
List<T> field collects remaining arguments; it cannot be combined with optional fields.
A trailing array is also variadic, allowing the bootstrap attribute declaration to use
AttributeTarget[] without depending on system.collections.
Generator triggers use DeclarationPath, which stores the dotted declaration reference in
its text field. Its symbol field carries the canonical compiler identity resolved in
that source file, including imported function aliases; unresolved references have an empty
identity. Attribute-only references participate in referenced_functions() and
referenced_decls(), so ctx.find_function(path.symbol) can inspect dependency signatures.
For example, @on_call(Json.serialize) supplies a declaration path rather than an enum member.
Unknown trigger paths are diagnosed by the generator framework.
An attribute type may use an Attribute suffix; its applied name removes the suffix and
converts the remaining name to snake_case. For example, JsAttribute is applied as @js and remains
available as JsAttribute for typed decoding. This avoids a collision with the primitive
js type.
Inspecting the input compilation¶
A generator receives syntax bodies for its selected user functions and their transitive
referenced functions. Embedded standard-library functions expose signatures only.
GenFunction.body is an Option<GenBody>; statements and expression children carry stable
identities and source locations. GenExpr.ty, symbol, overload, type_args, and
constant describe facts inferred before generation. Unresolved types are
TypeRefKind.Unknown; an unresolved symbol is the empty string. A missing constant means
that no exact scalar value was inferred.
ctx.compilation_errors() reports errors in the generator's selected declarations.
References to code that the generator has yet to produce may be unresolved in this view;
they are checked again after the generated output is added.
Use ctx.referenced_decls(), ctx.referenced_functions(), ctx.find_decl(id), and
ctx.find_function(id) to inspect dependencies. The closure is deterministic and limited
to 512 declarations and 32 levels. ctx.find_symbol(id) searches program-wide signature
views; implementations_of(id) and subtypes_of(id) traverse declared interface ancestry.
ctx.consume(function, help) takes over a selected or referenced top-level user function,
for example a function the generator translates to another language. The function stays in
the program and its body is type-checked like any other code, so a typo or type error inside
it is reported once through the normal diagnostics; the generator does not need to forward
ctx.compilation_errors(). The function is never compiled, and calling it, or using it as a
value, from ordinary code is an error naming the generator. The optional help text is shown
under that error:
error: 'blur' was consumed by generator 'shaders' and is not available as a function
help: dispatch it with Shaders.blur.run(...)
A consumed function may still call or name other consumed functions, which lets one generator translate a group of related functions. Generator-specific rules, such as "no closures", remain the generator's job. Standard-library functions and methods cannot be consumed, and two generators cannot consume the same function. Other generators' syntax blocks inside a consumed body are left to the consuming generator.
Configuration on modules¶
Generator settings belong in source, next to the code they configure. An @attribute type
whose targets include AttributeTarget.Module can be placed on a module declaration, and
@on_attribute fires for it just like for a function or type:
@kernel(64)
module shaders;
import gpu;
@kernel(128)
public fun blur(x: float): float {
return x * 0.5;
}
@kernel(0)
public fun fill(x: float): float {
return x;
}
ctx.modules_with<A>() lists the modules that carry @A, and ctx.module_of(decl) or
ctx.module_of(function) returns the trigger-carrying module that declares a declaration, so
a generator can merge module defaults with per-declaration overrides:
let size = 0;
switch (ctx.module_of(f)) {
Some(m) => {
switch (m.attribute<KernelAttribute>()) {
Some(defaults) => {
size = defaults.workgroup;
}
None => {}
}
}
None => {}
}
switch (f.attribute<KernelAttribute>()) {
Some(own) => {
if own.workgroup > 0 {
size = own.workgroup;
}
}
None => {}
}
GenModule exposes attribute<A>(), attributes_of<A>() and has<A>(). Its id is a
diagnostic target, so ctx.error_at(m.id, message) points at the module declaration.
Generator-only declarations¶
Some names exist only in a generator's target language, such as a shader's thread index.
Declare them with @generator_only(A, ...) from system.codegen. The compiler type-checks
uses like any other declaration, so generators see real types, symbols and generic arguments
in the body facts, but the declarations are never compiled:
module gpu;
import system.codegen;
@attribute(AttributeTarget.Module, AttributeTarget.Function)
public struct KernelAttribute {
public workgroup: int;
}
@generator_only(KernelAttribute)
public extern fun global_id(): uint;
@generator_only(KernelAttribute)
public extern fun backward<F>(f: F, ref gradient: float): float;
@generator_only(KernelAttribute)
public class Texture<T> {
public extern fun sample(u: float, v: float): T;
}
A generator-only declaration is a bodiless extern function, which may then be public and
generic, or a class, struct or interface, whose members are covered too. It may be used only
inside a function that carries one of the listed attributes and that a generator consumed.
Any other use is an error:
Passing a consumed or generator-only function as a value types it normally: in
backward(blur, ref gradient), ctx.type_of of the blur argument is
fun(float): float and ctx.symbol_of is the function's identity. ref parameters keep
GenParam.is_ref, and ref arguments appear as ExprKind.Ref nodes with the argument's
type, so a generator can see mutation through parameters.
Artifacts and source maps¶
ArtifactBuilder collects text and source mappings independently of Dream source emission:
let artifact = ArtifactBuilder();
artifact.mapped("external text", function.id);
ctx.emit_artifact("input.txt", artifact.to_string(), artifact.source_map);
Binary files, such as packed GPU code, use the overload that takes byte[] contents. They have
no source map; embed them with emit_asset or pass them to run_tool like text artifacts:
Artifact paths are relative to the generator directory, cannot contain .., and cannot
end in .dream. outputs.json is reserved for the compiler. Artifacts are materialized
under generated/<entry>/<module-path>/<generator>/ without a generated-source header.
Pass publish: true to emit_artifact to also copy an artifact beside the build output.
Published artifacts cannot overwrite source inputs or compiler outputs.
Source-map ranges use one-based lines and Unicode-character columns and exclude their end.
ctx.error_in_artifact(path, line, column, message) selects the innermost covering mapping
and reports the error at its originating Dream node. An uncovered point stays anchored to
the artifact file. ctx.error_at(node.id, message) also accepts body-node identities.
Pinned tools and feedback phases¶
A generator declares each external tool with @uses_tool("name"), where name is an
executable. Dream resolves it from its own table of pinned tools first:
| Tool | Install with | Executables | Override |
|---|---|---|---|
llvm |
ships with Dream releases | clang, opt, llc, llvm-*, wasm-ld, … |
DREAM_LLVM |
binaryen |
dreamer toolchain install binaryen |
wasm-opt, wasm-as, wasm-dis, … |
DREAM_WASM_OPT |
zig |
dreamer toolchain install cc |
zig |
DREAM_ZIG |
slang |
dreamer toolchain install slang |
slangc |
DREAM_SLANG |
Each tool installs into ~/.dream/toolchains/<name>-<version>. A DREAM_<TOOL> variable
points at an install root (or its bin/), except DREAM_WASM_OPT and DREAM_ZIG, which name
the executable itself. Any other tool is pinned in the project's dream.toml:
[tools.schema]
path = "tools/schema/bin/schema-checker"
version = "1.0"
hash = "<64 hexadecimal characters: BLAKE3 of the executable>"
root = "tools/schema"
timeout_secs = 60
hash is checked against the executable before every use; a mismatch reports the hash the
file actually has. The optional root names the tool's install tree. The tool can read every
file under it, such as sibling libraries and data files. timeout_secs overrides the default
30-second limit; tools from the pinned table have their own defaults.
ctx.tools() lists every declared tool. A tool that cannot be found on this machine is listed
with available == false, and requesting it with run_tool fails with the reason, such as the
install command. A generator can therefore skip a step that only an optional tool performs:
let recorder = false;
for (let tool in ctx.tools()) {
if tool.name == "dream-slang-layout" && tool.available {
recorder = true;
}
}
Tool declarations are fingerprinted before cache lookup. A tool with an install tree (every
pinned-table tool installed under a toolchains directory, and every project pin with root)
is fingerprinted over all of its files, so replacing a sibling library invalidates cached
results. An undeclared tool, or an executable whose hash differs from its pin, is rejected.
ctx.run_tool(id, tool, args, inputs, outputs, opaque_outputs) takes lists of arguments and
artifact paths. The compiler copies declared inputs into a scratch directory, invokes the tool
directly, and collects declared outputs. Network access is disabled, the environment is
minimal, and the tool can write only to its scratch directory and read only system locations,
its executable and its install tree:
- macOS runs the tool under
sandbox-exec. - Linux requires Bubblewrap; when
bwrapis missing, the error explains how to install it. - Windows runs the tool in a job object that ends every process it starts, under an AppContainer that can write only to the scratch directory.
Captured stdout and stderr are limited to 8 MiB each; individual output files are limited to
64 MiB. All requests made in one phase run in parallel, at most DREAM_GEN_JOBS at a time.
Results keep request order, and when several fail, the first failing request in that order is
the one reported.
Files listed in opaque_outputs are collected like any other output, but the next phase sees
only their path, hash and size (GenToolFile.opaque is true and contents is empty). Use them
for large or binary results the generator never reads. Refer to any tool output as
tool:<request id>/<path>:
ctx.emit_asset("kernels", "tool:compile/kernels.spv");
ctx.publish_tool_output("tool:compile/kernels.spv", "shaders/kernels.spv");
publish_tool_output copies the file next to the build output; the same rules as for
published artifacts apply.
A generator starts at ctx.phase() == 0. After tool requests, the compiler invokes the
same executable with the next phase number and ctx.tool_results(). Results include the
request identity, exit code, stdout, stderr, and output files. Binary files use base64
contents with binary == true; every file includes its BLAKE3 hash and size. The generator interprets
the tool's diagnostics and emits the final Dream files or assets. There are at most three
phases; the final phase cannot request another tool. Request identities and output paths
must be unique across phases.
Incremental results include the phase transcript: the hash of the first phase's snapshot, each phase's outputs and tool feedback, and every tool output's bytes, stored once and addressed by hash. Later phase snapshots are rebuilt from that record, so the transcript grows with the tool outputs rather than with the number of phases. Cache replay and the materialized manifest validate the same paths, outputs, mappings, hashes, and consumption targets as fresh execution. Capture/replay and incremental verification retain phase data.
Embedded assets¶
ctx.emit_asset(name, source) embeds an emitted artifact, or a tool output named
tool:<request id>/<path>.
The overload taking targets: List<string> restricts an asset to target triples or the
families native and wasm. Asset names must be unique across generators.
At runtime, Assets.bytes("name") copies the named resource into an owned byte[].
The name must be a string literal; unknown names are compiler diagnostics. Constant byte
payloads use private immutable LLVM globals, which become data segments on WebAssembly.
Selective JS runtimes also export embeddedAssets, a map of names to Uint8Array values.
Ordering follows generator name and output emission order.
The compiler persists each generator's outputs.json manifest with the materialized
sources. Replay-only builds and the language server read the manifest, including
replacements, consumption, artifacts, source maps, and assets.
Editor support¶
The language server replays the last build's manifests, so consumed functions are analyzed
like ordinary code: hover, go to definition, references and completion work inside them,
including generator-only names. Before the first build it treats a function that carries a
granting attribute as consumed. Errors a generator reported inside an artifact
(ctx.error_in_artifact) appear at the Dream code the artifact's source map points to. A
consumed function shows a "consumed by generator" code lens, followed by a lens that opens each
artifact the generator mapped back into it, such as a translated shader.