Skip to content

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.

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.

A vendor attribute needs a compiler suppression

[[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; };

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")
transform.luau
local typemap = require("acme/typemap")
return function(input)
local node = json.decode(input)
return json.encode({ source = typemap.render(node) })
end

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

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.

Controls engine behaviour for this rule. Minimum viable config:

version: 1
output:
language: cpp

Full schema: see Config Schema Reference.

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 a params key copied from the rule config). Decode with json.decode(input).
  • _outputPath is the post-grouping output path (the defaultPath if no grouping script exists, or the value returned by grouping).
  • Output: json.encode({ source = "...", inline = { {source = "..."}, ... }, includes = { "<vector>", "\"my/header.h\"" } }). The inline and includes keys are optional.
  • A Lua error() skips this entity, emits E006, 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).

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}, ...] }.
  • entities contains every entity the handler produced output for that’s bound to this output file, with handler-emitted includes exposed 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 with E007 (exit 1).

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 are registryIds 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 E005 and the entity is skipped. An entity missing from the returned map emits E004 and is skipped. The run continues in both cases.

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.

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.

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.

Key Takeaways
  • Up to five files in a rule directory: only transform.luau and preamble.luau are required (missing either is E003 and fails the run); config.yaml, grouping.luau and .env are optional.
  • Handler is a pure function: JSON in, JSON out (source, optional inline, optional includes). 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-registryId map.
  • Shared scripts in <rulesDir>/../shared/ and a shared .env are loaded automatically.