Skip to content

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:

include/config.hpp
struct [[codegen::JsonSerializer]] AppConfig {
std::string host;
uint16_t port;
bool enableTls;
};

Produce, alongside the header, include/config.g.cpp:

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.

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

config.yaml
version: 1
output:
language: cpp
autoDeduceIncludes: false

autoDeduceIncludes: 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.

preamble.luau
return function(input)
return "#include <nlohmann/json.hpp>\n\n"
end

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

transform.luau
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 } }
})
end

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

Terminal window
codegen -i ./include -r .codegen/rules -a JsonSerializer

The output lands at include/config.g.cpp.

Key Takeaways
  • A rule needs three files: config.yaml, preamble.luau (required, can return ""), and the handler transform.luau.
  • The handler receives one entity as JSON and returns { source, inline?, includes? }.
  • source lands in the output file (default: next to the input header). inline items 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 via permissions:.