Your First Rule
This guide writes a new rule, JsonSerializer, that generates a toJson() function for each annotated struct. It covers the three script files every rule needs and the config that ties them together.
Given:
struct [[codegen::JsonSerializer]] AppConfig { std::string host; uint16_t port; bool enableTls;};Produce, alongside the header, include/config.g.cpp:
// Generated with Codegen - DO NOT EDIT!!!
#include <nlohmann/json.hpp>
#include "config.hpp"
nlohmann::json toJson(const AppConfig& v) { return { {"host", v.host}, {"port", v.port}, {"enableTls", v.enableTls}, };}The // Generated with Codegen - DO NOT EDIT!!! banner and the #include "config.hpp" self-include are emitted by the engine. The rule supplies the <nlohmann/json.hpp> include (via the preamble) and the function body.
Rule file layout
Section titled “Rule file layout”Directory.codegen/rules/JsonSerializer/
- config.yaml
- preamble.luau
- transform.luau
No grouping.luau — this rule uses 1:1 routing (one struct ⇒ one .g.cpp next to the input).
Step 1: config
Section titled “Step 1: config”version: 1
output: language: cpp autoDeduceIncludes: falseautoDeduceIncludes: false means the engine will emit every includes entry the rule returns verbatim, without filtering against the input header’s transitive include graph. We rely on the preamble for the JSON include, so there’s nothing to filter anyway.
Step 2: preamble
Section titled “Step 2: preamble”return function(input) return "#include <nlohmann/json.hpp>\n\n"endThe preamble runs once per (rule, outputFile) pair. Its result is deduplicated by content per output file at assembly time. With 1:1 routing, each output file ends up with one preamble emission of #include <nlohmann/json.hpp>.
Step 3: handler
Section titled “Step 3: handler”return function(input) local node = json.decode(input) if not node then error("failed to decode input") end
local structName = node.identifier and node.identifier.name if not structName then error("struct has no name") end
-- Collect field initializer-list entries local entries = {} for _, varNode in ipairs(node.memberVariables or {}) do if varNode.kind == "Variable" then local fname = varNode.identifier and varNode.identifier.name if fname then table.insert(entries, ' {"' .. fname .. '", v.' .. fname .. '},') end elseif varNode.kind == "VariableGroup" then for _, v in ipairs(varNode.variables or {}) do local fname = v.identifier and v.identifier.name if fname then table.insert(entries, ' {"' .. fname .. '", v.' .. fname .. '},') end end end end
local body = table.concat(entries, "\n")
local impl = "nlohmann::json toJson(const " .. structName .. "& v) {\n" .. " return {\n" .. body .. "\n" .. " };\n" .. "}"
local decl = "nlohmann::json toJson(const " .. structName .. "& v);"
return json.encode({ source = impl, inline = { { source = decl } } })endThe inline list injects each item as its own :begin]]/:end]] block at an anchor site in the original header (if you’ve placed one). The qualified-name segment of the anchor is built from node._namespaces plus the struct name; an anchor like // [[codegen::generated::JsonSerializer::AppConfig]] will match this struct in the global namespace. With no anchor present in the header, the engine emits diagnostic W003 and skips the inline injection — the .g.cpp is still written.
The VariableGroup branch covers int x, y, z; declarations.
Step 4: run
Section titled “Step 4: run”codegen -i ./include -r .codegen/rules -a JsonSerializerThe output lands at include/config.g.cpp.
- A rule needs three files:
config.yaml,preamble.luau(required, can return""), and the handlertransform.luau. - The handler receives one entity as JSON and returns
{ source, inline?, includes? }. sourcelands in the output file (default: next to the input header).inlineitems are injected at matching anchor comments.- No
grouping.luau⇒ 1:1 routing; one annotated entity ⇒ one output file derived from the input. - The LuaU sandbox provides
json, the native introspection API, and a stdlib subset; opt-in to HTTP/env viapermissions:.