Trama reference
Static surface — the trama! resource macro
The static form is:
resource <name> = trama!(<params>){ <template> };
The handler behind it is #[token_macro] pub fn trama(name: str, params: str, s: str) -> ItemList. It receives the resource binding NAME, the raw parameter-list text, and the raw brace-body bytes at compile time, parses the template into the Trama AST, walks it, and emits a native render function via logos_emit_source.
Emitted signature:
pub fn <name>(<params>) -> Result<String, ElError> {
let mut __out: String = String::new();
// … one lowering per construct: __out.push_str(…) / push_i64(…) / …
return Result::Ok(__out);
}
The result is Result<String, ElError> — an integer overflow or divide-by-zero in an embedded {{ … }} expression becomes Err(ElError) rather than a host trap. A template with no arithmetic never errors; callers typically .unwrap().
- A missing binding name is a compile error (
trama!: needs a binding name). - At least one parameter is required (
trama!: needs at least one parameter).
Parameters
params is a genuine Logos parameter list (e.g. o: &Order, greeting: str). It is re-emitted verbatim into the generated signature, so the compiler parses and type-checks it, and parsed a second time locally to drive reflection. There is no with/data header — the parameters are the type source.
- Schema-struct params (
u: &User) are reflected viastamp_types_from_schema: field accesses ({{ u.name }}) type automatically from the schema, and the param is bound in the struct-type env so field chains and{% for v in u.field %}loops resolve. - Scalar / str params (
greeting: str) get their EL value-type set directly, so a bare{{ greeting }}renders with the correct push and the name is usable directly. - Loop variables reflect recursively:
{% for v in EXPR %}resolvesEXPR’s element typeTfrom the schema, bindsv → T(sov.memberchains and nested loops resolve), and reflectsT’s fields. A struct element binds by reference (let v: &Ty = &(iter)[__i];) to avoid moving out of an index; a scalar element binds by value.
Constructs
Every construct lowers to a TStmt schema node; each node carries its embedded expression as a single WRef<SExpr> edge — the one coupling to the expression language.
{{ expr }} — interpolation → TVar
Grammar: LMUST expr RMUST (plus the whitespace-control variants). Renders the expression by its inferred EL value-type — see the type-routing table below. A {{ tuple }} is rejected (templates render text, not rows).
{% if %} / {% elif %} / {% else %} / {% endif %} → TIf
Grammar: {% if expr %} then {% elif e2 %} … {% else %} alt {% endif %}, producing TIf { expr, body, alt }. The condition is coerced to bool by truthiness (below). Each {% elif %} is desugared at parse time into a nested TIf in the enclosing alt, so the walk is uniform.
On the static surface, if the optimizer const-folds the condition to a const bool, only the taken branch is emitted — no if wrapper, the other branch is dropped. A bare INT/STR literal condition ({% if 1 %}) is not a const bool and keeps its runtime truthiness guard; a folding comparison (1 > 0) collapses.
{% for v in coll %} … {% endfor %} → TFor
Grammar: {% for v in expr %} body {% endfor %}, producing TFor { var, expr, body }. The static path emits an indexed while over (expr).len() binding v to each element; the dynamic path iterates a Writ array binding v per element. expr may be a schema’d object-array (elements bind as nodes) or a scalar-element array (elements bind as their scalar).
Trama does not provide Jinja2’s
loop.*helpers (loop.index,loop.first,loop.last, …). A{% for %}binds only its loop variable.
{% set v = expr %} → TSet
Grammar: {% set v = expr %}, producing TSet { var, expr }. The static path emits a typed let v: <ty> = expr; whose type (str/bool/f64/i64) follows the assigned expression; the dynamic path binds v in the render-local bindings.
Literal text → TText
A run of bytes up to the next {{/{% (a bare { that does not open a tag stays text) is a TText node; the static path emits __out.push_str("…"), the dynamic path appends the de-escaped bytes.
Body-delimiter forms
Three forms are accepted after trama!(params):
| Form | Syntax | Notes |
|---|---|---|
| Double-quoted | trama!(p){ "…" } |
Simplest; escape inner ". |
| Backtick-quoted | trama!(p){ `…` } |
Lets EL string literals ({{ "(" + c + ")" }}) appear unescaped. Recommended when the template embeds ". |
| Delimiter-less | trama!(p){ … } |
The whole brace body is the template; leading/trailing whitespace is trimmed. Use a quoted form to preserve exact edge whitespace. |
The delimiter-less form is selected when the first non-space byte is neither " (34) nor ` (96).
Escapes and whitespace control
The four template metacharacters are backslash-escaped. The backslash survives the parse and is de-escaped to the bare character at render time (emit_escaped, static; push_deescaped, dynamic), so \{{ does not open a tag and a stray brace stays literal.
| Escape | Renders |
|---|---|
\{ |
{ |
\} |
} |
\` |
` |
\\ |
\ |
Each tag delimiter has a whitespace-control variant that trims the adjacent text run, accepted at every tag site:
| Trim variant | Plain form |
|---|---|
{{- / -}} |
{{ / }} |
{%- / -%} |
{% / %} |
Interpolation type-routing
{{ expr }} renders by the expression’s inferred EL value-type. The static path routes in code generation; the dynamic path mirrors it in rt_push.
| Inferred EL type | Rendering |
|---|---|
| STR | push_str (a concat is flattened to successive pushes) |
| BOOL | the words true / false |
| FLT | wql_f64_to_str — shortest round-trip %g decimal (Rust Display convention: 2.5 → "2.5", 5.0 → "5") |
| INT (default) | the i64 value via push_i64 |
A String-returning UDF is borrowed via .as_str() for the push. On the dynamic lenient surface, a Null value renders as the empty string (see below).
Truthiness
An {% if %} (and any bool coercion) follows Jinja truthiness:
| Value type | True when |
|---|---|
bool |
used bare |
i64 |
!= 0 |
str |
non-empty |
Null (lenient, dynamic) |
never — !Null is true, so the else branch is taken |
The EL coupling
Every {{ … }} and every tag-expression body is an EL expression — the same CEL-class, strongly-typed sublanguage Deem uses — reached through exactly one edge per statement node (a WRef<SExpr>). Trama specifies the template layer only; it delegates all expression grammar, typing, and Null semantics to EL. The relevant EL rules include the value-type lattice {INT, BOOL, STR, FLT}, the INT→FLT promotion rule (explicit cast), string-+-as-concatenation, the owned-String-vs-str-view distinction, and the builtins len / upper / lower / contains / starts_with. See the Deem introduction for the shared expression language.
Static error model
On the static surface every error is a compile diagnostic. Unknown-function, arity, and tuple-in-render errors are latched via error() (a build failure), and emission continues over a safe placeholder so the compiler can report further errors. A {{ tuple }} is rejected — templates render text, not projection rows. This inverts Jinja2, which surfaces template errors at render time.
Dynamic surface — Tpl::compile / render
The runtime API lives in logos.std.deem:
pub fn Tpl::compile(text: str, cat: &SchemaCatalog) -> Result<Tpl, QError>
pub fn Tpl::render(self: &Tpl, env: &QEnv) -> Result<String, QError>
compileparses the template text with the same generated parser the static surface uses, stitches theTStmtchain, runs the shared IR optimizer over every embedded expression, and runs the env-independent checks. It is compile-once. A malformed template yieldsTpl::compile: template parse error. Unknown function names defer to the render-time check (they may be env-registered UDFs).renderruns the strict per-env check (every root name must resolve to aQEnvbinding, every field to a catalog entry, with the full EL type lattice), then walks theTStmtchain building aString. It is re-entrant over different environments. A math error in an embedded expression aborts the render as aQError.
Errors are values (QError, a positioned message with a .message() accessor), never compile diagnostics — the caller is a running program.
Binding a QEnv
QEnv::new() creates an empty environment. Bind roots by kind:
| Binder | Binds |
|---|---|
bind_node(name, node) |
a schema’d Writ object (schema read off the object’s TOM) |
bind_source(name, arr) |
a Writ array of schema’d rows |
bind_i64 / bind_f64 / bind_bool / bind_str |
a scalar parameter |
bind_node_erased(name, node) |
an erased (lenient) object — fields resolve by name at runtime |
bind_source_erased(name, arr) |
an erased array of lenient rows |
The catalog comes from the schema_catalog!{ … } resource macro (let c: SchemaCatalog = cat();).
Lenient mode
Lenient-ness is a per-binding property. An erased binding types its root as dyn; fields resolve at runtime by name, and a miss yields RtVal::Null, which propagates CEL-style. Concretely:
{{ x }}whose value isNullrenders as the empty string.- A
Null(or non-array) collection in{% for %}iterates as an empty loop. - A
Null{% if %}predicate takes the else branch.
Strict bindings never produce Null, so this is a dynamic-only behavior with no static-surface analogue.
Shared UDF registry
Both surfaces resolve template functions through one registry shared with Deem, builtin-first. On the static surface the module’s top-level functions are registered as UDFs (stamp_udfs_from_module), so {{ myfn(o.total) }} routes through the shared emit_call path. On the dynamic surface, register functions on the env with register_fn(name, f, args, ret) (and aggregates with register_agg) — the same registry Query uses. A same-named builtin shadows a user function.
The AST substrate
Trama’s AST is a first-class Writ schema tree (schema category 0x0013), dogfooding ADR 0011. A template block is a null-terminated singly-linked chain: every TStmt (TText / TVar / TFor / TIf / TSet) carries next: WRef<TStmt>, and a for/if body (and if else) is the head of its own chain. The generated parser yields a WArray of statement handles; the chain_array post-pass stitches the next links. This is shared by both surfaces — the same tree, walked to emit code (static) or interpreted (dynamic).
Related
- Trama: templating Writ data — the concepts: WQL, the two surfaces, and the Jinja2 lineage.
- Trama tutorial — a worked build-up from a first render to loops, UDFs, and the dynamic API.
- Deem: querying Writ data — the sibling query half of WQL, sharing Trama’s EL expressions, catalog, and
QEnv.