Writing a generator
This page walks you through building a plugin generator in Python that emits Lua. The same pattern works in any language that can read JSON on stdin and write JSON on stdout.
The full source of this example is short — about 60 lines.
Goal
Given:
// constants/limits.prim
duration TIMEOUT = 30s
u32 MAX_RETRIES = 5
Emit:
-- generated by primate-lua
local M = {}
M.TIMEOUT = 30 -- seconds
M.MAX_RETRIES = 5
return M
Step 1: read stdin, write stdout
#!/usr/bin/env python3
# scripts/primate_lua.py
import json
import sys
req = json.load(sys.stdin)
out_lines = ["-- generated by primate-lua", "local M = {}"]
for module in req["modules"]:
for c in module["constants"]:
name = c["name"]
v = c["value"]
if "nanoseconds" in v:
secs = v["nanoseconds"] / 1_000_000_000
out_lines.append(f"M.{name} = {secs} -- seconds")
else:
out_lines.append(f"M.{name} = {json.dumps(v)}")
out_lines.append("return M")
resp = {
"files": [{
"path": req["outputPath"],
"content": "\n".join(out_lines) + "\n",
"mappings": [],
}],
"errors": [],
}
json.dump(resp, sys.stdout)
Step 2: wire it into primate.toml
[generators.lua]
output = "scripts/generated/constants.lua"
command = ["python3", "scripts/primate_lua.py"]
Step 3: run it
primate build
# generated scripts/generated/constants.lua
What just happened
primate parsed .prim files, built the IR, then for the [generators.lua]
target it spawned python3 scripts/primate_lua.py. It piped a JSON
request to the plugin’s stdin (with all modules, enums, and aliases),
read the JSON response from stdout, and wrote each file in files to
disk.
Reading types
The example only handles primitive values for brevity. A real generator needs to recognize the type tags:
def render_type(t):
kind = t["kind"]
if kind in ("i32", "i64", "u32", "u64", "f32", "f64"):
return "number"
if kind == "string":
return "string"
if kind == "duration":
return "number" # seconds
if kind == "bool":
return "boolean"
if kind == "array":
return f"{render_type(t['element'])}[]"
if kind == "fixed_array":
return f"{render_type(t['element'])}[{t['length']}]"
if kind == "map":
return f"map<{render_type(t['key'])}, {render_type(t['value'])}>"
if kind == "enum":
return t["name"]
if kind == "alias":
return t["name"]
raise ValueError(f"unknown type kind: {kind}")
Generating sourcemaps
If you populate mappings in your response, the LSP can jump between
.prim source lines and your generated lines:
mappings.append({
"symbol": f"{module['namespace']}::{c['name']}",
"line": current_line_number,
"column": 1,
})
symbol is the fully-qualified constant name; line is 1-based, column
is 1-based. Keeping these accurate makes go-to-definition from generated
code into .prim work.
Returning errors
resp = {
"files": [],
"errors": [{
"message": "could not represent f128 in Lua",
"source": c["source"], # passes through file/line/column
}],
}
primate surfaces these alongside its own diagnostics; a non-empty
errors array fails the build.
Built-in generators are plugins
The Rust, TypeScript, and Python generators are linked into the
primate binary, but they implement exactly this protocol — the only
difference is that they don’t fork a subprocess. Reading their
implementations (src/generators/) is the easiest reference for what
“correct” output looks like for each shape.
See also
- Protocol — complete JSON schema reference.