Skip to content

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.

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.hcolor.g.cpp next to the input.

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.

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:

.codegen/rules/MyRule/MyRule.grouping.luau
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)
end

Each 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:

  1. Looks up each entity’s new path in the returned map.
  2. Resolves it against the project root (CWD); rejects escapes with E005.
  3. Groups entities by output path for later assembly.
  4. Calls the handler with the post-grouping output path (available as _outputPath in the handler).
  5. Runs the preamble once per unique (rule, outputFile) pair, passing the entities bound to that file.
  6. Concatenates each entity’s source into the assembled 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:

grouping.luau
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)
end

The 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.

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 source for the primary file (e.g. the implementation .cpp).
  • Use inline to 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.

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)] = output
end

Routing 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.

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.

Key Takeaways
  • 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> → path map; 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.