Macros and Host Transforms

Lux deliberately separates two extension mechanisms:

  • macros expand explicit macro calls at compile time;
  • host transforms rewrite calls imported from a target module.

Both mechanisms live in Lux packages. They are compiler extensions, not runtime parsers hidden inside GLua hooks.

Package Phases

A Lux package may participate in three phases:

source package -> macro package -> host transform package

Runtime imports produce Lua locals. Macro imports produce compile-time bindings. Host transforms attach rewrite rules to normal runtime imports from a specific module.

Keeping the phases separate prevents a macro helper from accidentally becoming a runtime global and lets diagnostics say which phase failed.

Using Macros

Import macros with import macro. Macro calls expand before runtime codegen.

Macro call expands at compile time
import macro { color } from "@lux/gmod/macros"

local accent = color("#22cc88")

Namespace macro imports are also supported.

Namespace macro import
import macro * as gm from "@lux/gmod/macros"

gm.concommand("lux_debug", () => print("debug"))

Macro bindings are not runtime values.

Macro binding is not a runtime value
import macro { color } from "@lux/gmod/macros"

local makeColor = color

Writing Macros

Macro packages use the compiler macro API. MVP documentation shows the shape, not a promise that every helper is stable forever.

Minimal expression macro
export macro fn sqr(m) {
  m.expectArgCount(1)
  local x = m.arg(1)
  m.expr(`(${x}) * (${x})`)
}

Prefer macro helper APIs such as m.expectArgCount, m.fail, m.expr, and m.stmts over hand-built AST tables. That keeps diagnostics stable and gives errors the original call span.

exprWithSetup

Some expression macros need temporary statements before the final expression. exprWithSetup lets the macro return both.

Expression macro with setup
export macro fn cachedMaterial(m) {
  m.expectArgCount(1)
  local path = m.arg(1)
  m.exprWithSetup({
    setup = m.stmts(`
      local __lux_mat = Material(${path})
    `),
    expr = m.expr(`__lux_mat`),
  })
}

The compiler chooses a hygienic temporary name when the macro asks for one. Avoid hard-coding user-visible names unless the macro intentionally declares an API.

Statement Macros

Statement macros expand into one or more statements. They cannot be used where a value is required.

GMod hook macro
import macro { hookAdd } from "@lux/gmod/macros"

hookAdd("Think", "my_addon.tick", () => tickPlayers())

Using a statement macro in expression position is a compile-time error.

Statement macro cannot be an expression
local installed = hookAdd("Think", "id", () => tick())

Realm-safe Macros

Macro expansion must respect realm checks. A server-only macro cannot make client artifacts evaluate server-only arguments.

server-only macro does not evaluate arguments on client
server macro netMessage("InventoryUpdate") {
  sendInventory(ply)
}

This is why macros participate in the same realm availability model as normal bindings.

What Host Transforms Are

A host transform rewrites calls that target an imported API. It is useful when a runtime library wants a natural call surface while the generated Lua should be denser or more GLua-native.

Only imports from the target module are transformed
import { node } from "@lux/ui"

local view = node("Panel", {
  class = "inventory",
})

The transform is tied to the resolved imported binding, not the spelling alone. A local binding with the same name is not rewritten.

Local same-name binding does not trigger host transform
local node = makeDebugNode

local view = node("Panel")

That rule prevents accidental rewrites in large modules.

Writing Host Transforms

A host transform package declares which module it transforms and exports rewrite handlers.

Host transform package shape
host transform "@lux/ui" {
  call node(call) {
    local tag = call.arg(1)
    local props = call.arg(2)
    call.replaceExpr(`__lux_ui_create_node(${tag}, ${props})`)
  }
}

Transforms should report user-facing errors with source spans from the original call. A transform error should not look like an internal compiler crash.

call.imported and call.local

Host transform callbacks can see both the public imported name and the local alias chosen by the user.

Host transform sees imported/local names
import { node as uiNode } from "@lux/ui"

uiNode("Panel", {})

Use call.imported to decide semantics. Use call.local only for diagnostics that should echo the user's source spelling.

Choosing Macro vs Host Transform

Use a macro when the source explicitly opts into a compile-time operation:

  • compile-time constants such as colors or materials;
  • syntax-like helpers for hooks or commands;
  • compile-time validation of literal arguments.

Use a host transform when a runtime API should stay readable but the compiler can produce better Lua for known calls:

  • UI tree builders;
  • dense draw command builders;
  • domain-specific call lowering for packages.

Avoid using host transforms as invisible magic for ordinary functions. The more surprising the rewrite, the more it should be an explicit macro.

Diagnostics and Errors

Macro diagnostics distinguish user mistakes from macro author mistakes:

  • invalid macro call position;
  • invalid arity;
  • statement expansion where an expression is required;
  • macro returned a non-AST value;
  • internal macro helper misuse.

When a macro author calls m.fail, the diagnostic points at the macro call. When the macro package itself crashes, Lux reports that as a macro implementation error with both the macro package span and use-site span when possible.

Source Maps

Generated Lua should map back to the user's Lux source, not only to macro package internals. Macro expansions therefore carry expansion spans:

  • user call span for diagnostics caused by user arguments;
  • macro definition span for errors in macro code;
  • generated Lua span for runtime stack correlation.

This is especially important in GMod, where production errors usually arrive as Lua stack traces.