Grouping & Fan-in
The grouping.luau script is the most powerful part of the codegen rule system. It controls the mapping from matched C++ entities to output files. Understanding it is the key to unlocking codegen’s full capability.
The default: 1:1 routing
Section titled “The default: 1:1 routing”Without a grouping script, each entity produces output written to a file derived from its input header’s path. One header → one output file. For simple cases (enum-to-string functions, for example) this is the right behaviour: the ToString rule produces color.h → color.g.cpp next to the input.
The problem with 1:1
Section titled “The problem with 1:1”Consider generating API documentation. You have 40 structs spread across 15 headers. The 1:1 policy produces 15 separate Markdown files. But you want one api-reference.md.
Or a serialization registry: one registry.cpp calling register<T>() for every annotated type.
Or a TypeScript bindings manifest: one bindings.ts, one export interface per C++ struct, sorted alphabetically.
These are fan-in patterns. Many entities, one output file. The inverse — fan-out, one entity into multiple files — is also possible by combining grouping with multi-anchor inline patches.
How grouping.luau fits the lifecycle
Section titled “How grouping.luau fits the lifecycle”Grouping runs before handlers, as the second phase of entity routing. It takes all collected candidate entities and returns a map overriding their default output paths:
return function(input) local data = json.decode(input) local result = {} for _, entity in ipairs(data.entities) do result[tostring(entity.registryId)] = "generated/docs/api-reference.md" end return json.encode(result)endEach entity exposes only: registryId, qualifiedName, structName, namespaces, inputFile, defaultPath (the 1:1 output path derived from the input header), plus the rule’s params at the top level. There is no full AST node or attribute list at this stage — if you need that for routing, do the work in the handler and stuff the result into the includes or use the params channel.
The engine then:
- Looks up each entity’s new path in the returned map.
- Resolves it against the project root (CWD); rejects escapes with
E005. - Groups entities by output path for later assembly.
- Calls the handler with the post-grouping output path (available as
_outputPathin the handler). - Runs the preamble once per unique (rule, outputFile) pair, passing the entities bound to that file.
- Concatenates each entity’s
sourceinto the assembled file.
Fan-in: many structs, one file
Section titled “Fan-in: many structs, one file”The MarkdownDocs example demonstrates this pattern. Every struct annotated [[codegen::MarkdownDocs]], regardless of which header it lives in, is routed to a single api-reference.md:
return function(input) local data = json.decode(input) if not data then error("failed to decode input") end
local OUTPUT = "generated/docs/api-reference.md"
local result = {} for _, ent in ipairs(data.entities) do result[tostring(ent.registryId)] = OUTPUT end
return json.encode(result)endThe preamble runs once for the rule; its result lands at the top of api-reference.md exactly once thanks to per-output-path content deduplication. Each entity’s handler-rendered source follows in match order.
Fan-out: one entity, multiple files
Section titled “Fan-out: one entity, multiple files”Today’s grouping API maps each entity to one primary path. To split content across multiple files for a single entity, the cleanest pattern is:
- Use
sourcefor the primary file (e.g. the implementation.cpp). - Use
inlineto inject declarations back into the original header at an anchor.
Routing the same entity to two distinct generated files isn’t directly expressible in one grouping.luau invocation; the typical workaround is to define two rules and annotate the entity with both attributes.
Conditional routing
Section titled “Conditional routing”Grouping only sees summary metadata. Routing decisions you can express directly:
for _, entity in ipairs(data.entities) do local isInternal = entity.inputFile:find("internal/") ~= nil local output = isInternal and "generated/internal-api.md" or "generated/public-api.md" result[tostring(entity.registryId)] = outputendRouting decisions that need full AST data (attribute arguments, base classes, member shape) need to happen in the handler, with the conclusion baked into a params value or the rendered source, then read out in grouping.
Ordering guarantees
Section titled “Ordering guarantees”Within an output file, entities appear in match order: the order their input headers were enumerated, then declaration order within each header. To impose a different order (alphabetical, for example), sort the entity list inside grouping before assigning paths — assembly visits entities in the post-grouping order.
- Without
grouping.luau, routing is 1:1 (one entity → one file derived from the input path). - Grouping runs before handlers, with summary metadata only (no full AST node, no handler output).
- Output is a
<stringified-registryId> → pathmap; missing keys skip the entity (E004). - Paths must stay under the project root (CWD); escapes are rejected with
E005. - The preamble dedup happens at assembly time per output path, so fan-in produces a single header.