Skip to content

Writing Transforms

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 })
end

The 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\"").

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 name
end

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

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 name
end

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 .. ");")
end
table.insert(lines, "}")
local source = table.concat(lines, "\n")

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
end
end

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
end
end

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

Terminal window
ast-dump -m codex -i include/my_header.hpp

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

Key Takeaways
  • 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), not annotations.
  • Use table.concat for multi-line output; the native API for cross-entity lookup.
  • Return value must be json.encode({source = "...", inline = {...}, includes = {...}}).