Writing Transforms
Structure of a handler
Section titled “Structure of a handler”Every handler script follows the same pattern:
return function(input) -- 1. Decode the entity payload local node = json.decode(input) if not node then error("failed to decode input") end
-- 2. Extract what you need local name = node.identifier and node.identifier.name if not name or name == "" then error("entity has no name") end
-- 3. Build the output text local source = "// generated for " .. name .. "\n"
-- 4. Return encoded result return json.encode({ source = source })endThe return shape recognised by the engine: source (string, written to the entity’s output file), optional inline (array of {source = "..."} objects injected at the anchor site), optional includes (array of include strings such as "<vector>" or "\"my/header.h\"").
Type mapping pattern
Section titled “Type mapping pattern”For rules that translate C++ types to another type system (TypeScript, Rust, protobuf, SQL):
local TYPE_MAP = { ["bool"] = "boolean", ["int"] = "number", ["std::string"] = "string", -- add entries as needed}
local function mapType(tSig) if not tSig or not tSig.identifier then return "unknown" end local name = tSig.identifier.name
if TYPE_MAP[name] then return TYPE_MAP[name] end
-- Handle generics recursively if name == "vector" then local args = tSig.identifier.templateArguments or {} return "Array<" .. mapType(args[1]) .. ">" end
-- Fall back to the C++ name return nameendHandler payload
Section titled “Handler payload”The handler receives a full entity JSON with these synthetic fields injected:
_namespaces(array) — the qualifying namespace path_registryId(integer) — stable entity ID_outputPath(string) — the post-grouping output file path (after grouping, if present; otherwise the 1:1 default)params(object) — rule config copied in
Use _outputPath if your handler needs to know the final output location (e.g., to compute relative paths from the output file back to the input header).
Namespace qualification
Section titled “Namespace qualification”Always build the qualified name from _namespaces; do not assume the unqualified name is unique:
local function qualifiedName(node) local ns = table.concat(node._namespaces or {}, "::") local name = node.identifier.name return ns ~= "" and (ns .. "::" .. name) or nameendBuilding multi-line output
Section titled “Building multi-line output”Use a lines table and table.concat — string concatenation in a loop is O(n²) in Lua:
local lines = {}table.insert(lines, "void process(" .. qualifiedName(node) .. " const& v) {")for _, var in ipairs(node.memberVariables or {}) do table.insert(lines, " handle(v." .. var.identifier.name .. ");")endtable.insert(lines, "}")
local source = table.concat(lines, "\n")Cross-entity lookup
Section titled “Cross-entity lookup”For rules that need to inspect more than the entity they were called for (base classes, sibling structs, methods on related types), use the native introspection API:
-- Fetch base classes (returns JSON-encoded array)local baseJson = get_base_classes(node._registryId)if baseJson then for _, base in ipairs(json.decode(baseJson)) do -- base = { access, name, qualifiedName, id } if base.id then local sibling = json.decode(get_node(base.id)) -- sibling is the full node JSON for the base class end endendCommon mistakes
Section titled “Common mistakes”Forgetting to handle VariableGroup. Declarations like int x, y, z; arrive as kind = "VariableGroup" with a variables array. Always handle both cases:
for _, varNode in ipairs(node.memberVariables or {}) do if varNode.kind == "Variable" then processVar(varNode) elseif varNode.kind == "VariableGroup" then for _, v in ipairs(varNode.variables or {}) do processVar(v) end endendReturning plain text instead of JSON. The handler must return json.encode({...}). Returning a plain string emits E009.
Reading node.annotations. The actual key is attributes (node.attributes[i].ns, node.attributes[i].name, node.attributes[i].arguments). Codex uses C++ attribute terminology throughout.
Mutating the input table. LuaU tables are shared by reference within a single invocation. Don’t mutate node; build new tables for output.
Inspecting the JSON your script will receive
Section titled “Inspecting the JSON your script will receive”Use the ast-dump tool to dump a header in the codex JSON form:
ast-dump -m codex -i include/my_header.hppThe shape printed is the same one the handler decodes (minus the _namespaces and _registryId synthetic fields and minus the heavy memberFunctions / staticMemberFunctions / constructors / destructors / operators / nestedTypes / statements fields the engine strips before invocation).
- Handlers are pure functions: decode → extract → build → encode. No side effects.
- Build qualified names from
_namespaces; never assume uniqueness of unqualified names. - The codex JSON uses
attributes(C++ terminology), notannotations. - Use
table.concatfor multi-line output; the native API for cross-entity lookup. - Return value must be
json.encode({source = "...", inline = {...}, includes = {...}}).