Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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.