Rule Anatomy
A rule lives in <rulesDir>/<RuleName>/ (the default <rulesDir> is .codegen/rules, override with -r). The directory contains:
Directory.codegen/rules/RuleName/
- config.yaml (optional) Engine configuration; defaults apply if absent
- transform.luau (required) Handler (transformation) script
- preamble.luau (required) File header emitter
- grouping.luau (optional) Output path router
- .env (optional) Per-rule dotenv
The directory name is the rule name — it’s how the rule is referenced on the CLI (-a RuleName) and as the C++ attribute ([[codegen::RuleName]]). Companion files are named by their role, not the rule, so a directory always looks the same.
Namespaced rules
Section titled “Namespaced rules”A rule can also live one level deeper, under a vendor directory:
Directory.codegen/rules/
DirectoryMyLocalRule/ flat —
-a MyLocalRule, matches[[codegen::MyLocalRule]]- …
Directoryacme/
DirectoryToString/ namespaced —
-a acme/ToString, matches[[acme::ToString]]- …
Directoryglobex/
DirectoryToString/ coexists: a different attribute entirely
- …
The rule is simple: a directory holding a transform.luau is a rule; one that does not is a namespace, and its children are rules. Flat is still the default and nothing about an existing tree changes — namespacing exists so two vendors can both publish a ToString and you can use both at once.
-a acme::ToString is accepted too, so you can paste the attribute spelling straight onto the command line. A bare -a ToString that two namespaces both provide is an error listing the candidates, never a silent pick.
[[acme::ToString]] is an unknown attribute namespace, exactly as [[codegen::…]] is, and compilers warn about it by default:
// clang: -Wno-unknown-attributes// gcc: #pragma GCC diagnostic ignored_attributes "acme::"struct [[acme::ToString]] Color { int r, g, b; };Components
Section titled “Components”A component is a reusable Luau module a rule reaches with require:
Directory.codegen/shared/
- helpers.luau flat — legacy prelude, and
require("helpers") Directoryacme/
- typemap.luau
require("acme/typemap") Directorycodecs/
- init.luau
require("acme/codecs")
- init.luau
- typemap.luau
- helpers.luau flat — legacy prelude, and
local typemap = require("acme/typemap")
return function(input) local node = json.decode(input) return json.encode({ source = typemap.render(node) })endA component returns its value — conventionally a table of functions. It is evaluated the first time something requires it, and it can use every global the environment provides. require works in transform.luau, preamble.luau and grouping.luau alike.
Components install from the rules registry into .codegen/shared/<vendor>/. See require for the exact contract.
The flat shared/ prelude
Section titled “The flat shared/ prelude”Files directly under <rulesDir>/../shared/ (e.g. .codegen/shared/) keep their original behaviour: every .luau file there is concatenated in alphabetical filename order and prepended to each rule’s transform at execution time, and a shared/.env file becomes the baseline dotenv. By default every shared script is included; rules can narrow the set with shared.include / shared.exclude globs in their config (see Config Schema Reference).
Namespaced components are never concatenated — they are reached only through require. That is what makes installing one safe: it cannot perturb what any existing rule already sees.
A rule that declares uses opts out of the flat prelude entirely. A rule that names what it needs should not also absorb whatever else happens to be sitting in your tree, which is exactly the isolation a published rule wants.
config.yaml
Section titled “config.yaml”Controls engine behaviour for this rule. Minimum viable config:
version: 1
output: language: cppFull schema: see Config Schema Reference.
transform.luau — handler
Section titled “transform.luau — handler”Receives one matched entity as JSON, returns JSON describing the output.
Contract:
- Input: a JSON-encoded entity payload (the codex AST node with heavy fields stripped, plus synthetic
_namespaces,_registryId, and_outputPath, plus aparamskey copied from the rule config). Decode withjson.decode(input). _outputPathis the post-grouping output path (thedefaultPathif no grouping script exists, or the value returned by grouping).- Output:
json.encode({ source = "...", inline = { {source = "..."}, ... }, includes = { "<vector>", "\"my/header.h\"" } }). Theinlineandincludeskeys are optional. - A Lua
error()skips this entity, emitsE006, and continues. An internal LuaU error is fatal (exit 2). - The script is a pure function: no I/O, no globals carried between invocations (each call gets a fresh execution).
preamble.luau — preamble
Section titled “preamble.luau — preamble”Required. Runs once per unique (rule, outputFile) pair; the result is later deduplicated by content across rules contributing to the same output file.
Contract:
- Input: a JSON-encoded context object:
{ language: "cpp", outputFile: "path/to/output.cpp", ruleName: "<RuleName>", params: <rule.params>, entities: [{registryId, qualifiedName, structName, namespaces, inputFile, includes}, ...] }. entitiescontains every entity the handler produced output for that’s bound to this output file, with handler-emittedincludesexposed for conditional preamble logic.- Output: a raw string (not JSON), prepended to the output file once per
(rule, outputFile)pair (deduplicated by content across rules contributing to the same file). Return""if you don’t need a preamble — but the file must still exist. - A Lua
error()here aborts the entire run withE007(exit 1).
grouping.luau — grouping
Section titled “grouping.luau — grouping”Optional. Runs once per rule, before handlers fire, with the list of candidate entities. Used to override each entity’s default output path (the 1:1 routing derived from the input header).
Contract:
- Input:
json.decode(input)returns{ params = <rule.params>, entities = [...] }. Each entity has only the summary fields:registryId(integer)qualifiedName(string, e.g."app::Color")structName(string, unqualified)namespaces(array of strings)inputFile(string, the source header path)defaultPath(string, the 1:1 output path derived from the input header)
- Output:
json.encode({ ["<registryId>"] = "path/to/output.cpp", ... }). Keys areregistryIds stringified; values are output paths. - Paths are resolved against the project root (the process working directory). A path that escapes the project root is rejected with
E005and the entity is skipped. An entity missing from the returned map emitsE004and is skipped. The run continues in both cases.
.env — per-rule dotenv
Section titled “.env — per-rule dotenv”Optional. Standard dotenv format (KEY=VALUE, # comments, optional surrounding quotes on the value). Loaded automatically when present; entries override the shared <rulesDir>/../shared/.env baseline. Surfaced to scripts via the env module when env capability is active.
Triggering a rule
Section titled “Triggering a rule”Two trigger mechanisms can fire a rule:
Attribute annotation at declaration sites. Honoured for any node kind in node_kinds (default: Struct only):
struct [[codegen::RuleName]] MyStruct { /* ... */ };Anchor comment in the source. Useful for entities the C++ grammar doesn’t allow attributes on (enums, free functions in some contexts) and as the injection site for inline patches:
// [[codegen::generated::RuleName::namespace::EntityName]]enum class MyEnum { A, B, C };The third segment of the anchor is the qualified name of the target entity (namespace path included). Anchors are scanned per-file and matched against the AST to find the corresponding entity. See Inline Injection for the on-disk anchor lifecycle.
Published rules
Section titled “Published rules”A rule shared through the rules registry adds three files to the directory above — rule.json, README.md, and LICENSE. The engine ignores all three, so a published rule and a hand-written one are the same thing to codegen; the extra files carry identity, version, and attribution for humans.
- Up to five files in a rule directory: only
transform.luauandpreamble.luauare required (missing either isE003and fails the run);config.yaml,grouping.luauand.envare optional. - Handler is a pure function: JSON in, JSON out (
source, optionalinline, optionalincludes). Receives_outputPath(post-grouping route). - Preamble runs once per (rule, outputFile) pair; its return is a raw string, deduplicated by content per output file. Receives the entities bound to that file.
- Grouping runs once per rule, before handlers, and rewrites output paths via a stringified-
registryIdmap. - Shared scripts in
<rulesDir>/../shared/and a shared.envare loaded automatically.