Implementation-level questions about how the engine actually works. For the
design see DESIGN.md; for the build plan,
IMPLEMENTATION.md.
At compile, the output literal becomes a tree of OutNodes; each {{ … }} is
recorded as a hole with an expression ID. At render, the engine recursively
walks the tree and builds the output Value:
enum OutNode {
Literal(Value), // "POST", 3, true — no holes
Hole(ExprId), // {{ expr }} as a value
Object(Vec<(SmolStr, NodeId)>), // { "k": <node>, ... }
Array(Vec<NodeId>), // [ <node>, ... ] — literal brackets in the template
Interp(Vec<StrPart>), // "Hi {{ name }}!"
}
enum StrPart { Text(SmolStr), Hole(ExprId) }
fn build(node: NodeId, ctx: &Ctx) -> Result<Value, RenderError> {
match &nodes[node] {
OutNode::Literal(v) => Ok(v.clone()),
OutNode::Hole(eid) => eval_expr(*eid, ctx),
OutNode::Object(fs) => Ok(Value::Obj(
fs.iter().map(|(k, n)| Ok((k.clone(), build(*n, ctx)?))).collect()?
)),
OutNode::Array(items) => Ok(Value::Arr(
items.iter().map(|n| build(*n, ctx)).collect()?
)),
OutNode::Interp(ps) => { /* text parts as-is; holes stringified */ }
}
}- A bare hole emits the expression's typed
Value. - A quoted hole (interp string) stringifies each hole's value and concatenates with the literal text parts.
The caller hands render any impl Into<Value> — serde_json::Value,
HashMap<String, V>, or your own struct via Serialize. The From impls drop
it into Temple's Value enum once, before render starts. From there it's
just a Value::Obj(IndexMap<SmolStr, Value>) sitting under the name input.
At parse, input.value.nestedValue becomes a root + segment list — built
once at compile, not re-parsed per render:
enum Expr { Path { root: PathRoot, segs: Vec<Seg> }, /* … */ }
enum PathRoot { Input, This, Var(VarId) }
enum Seg { Field(SmolStr), Index(i64), OptField(SmolStr), OptIndex(i64) }At render, walk one segment at a time:
fn eval_path(root: &Value, segs: &[Seg], ctx: &Ctx) -> Result<Value, RenderError> {
let mut cur = root;
for seg in segs {
cur = match (cur, seg) {
(Value::Obj(m), Seg::Field(k)) => m.get(k).ok_or_else(|| missing(k, ctx))?,
(Value::Arr(v), Seg::Index(i)) => v.get(*i as usize).ok_or_else(|| out_of_range(*i, ctx))?,
(Value::Obj(m), Seg::OptField(k)) => m.get(k).unwrap_or(&Value::Null),
(Value::Arr(v), Seg::OptIndex(i)) => v.get(*i as usize).unwrap_or(&Value::Null),
(Value::Null, Seg::OptField(_) | Seg::OptIndex(_)) => &Value::Null,
(other, Seg::Field(_) | Seg::Index(_)) => return Err(type_mismatch(other.kind(), ctx)),
// …
};
}
Ok(cur.clone())
}So input.value.nestedValue is just three dictionary lookups on the input
Value. No reflection, no string parsing per call. ?. flips the same lookup
into "missing ⇒ Null, propagate."
Two distinct cases — important to keep straight:
(a) Array literals in the output template — when you write:
"keys": [{{ input.a }}, {{ input.b }}, 42]
This is a fixed-shape, three-element array. The parser builds it as
OutNode::Array([NodeId(hole_a), NodeId(hole_b), NodeId(literal_42)]). At
render, each child is built; results collect into a Value::Arr of length 3.
(b) Computed arrays inside an expression — when you write:
"items": {{ input.cart.items.map(it -> it.qty * it.price) }}
The output node is a single OutNode::Hole(eid). The expression at eid is a
method call:
enum Expr {
Method { receiver: ExprId, kind: MethodKind, args: Vec<ExprId> },
Lambda { params: Vec<VarId>, body: ExprId },
// …
}
enum MethodKind { Map, Filter, Fold, Len, Sum, Any, All }Evaluation:
fn eval_method(recv: Value, kind: MethodKind, args: &[ExprId], scope: &mut Scope) -> Result<Value, RenderError> {
let items = match recv {
Value::Arr(v) => v,
other => return Err(method_on_non_array(other)),
};
match kind {
MethodKind::Map => {
let (param, body) = unwrap_lambda(args[0]);
let mut out = Vec::with_capacity(items.len());
for it in items {
scope.vars[param.0] = it;
out.push(eval_expr(body, scope)?);
}
Ok(Value::Arr(out))
}
MethodKind::Filter => { /* same loop, keep where body == Bool(true) */ }
MethodKind::Fold => {
let init = eval_expr(args[0], scope)?;
let ([acc_p, x_p], body) = unwrap_lambda2(args[1]);
let mut acc = init;
for it in items {
scope.vars[acc_p.0] = acc;
scope.vars[x_p.0] = it;
acc = eval_expr(body, scope)?;
}
Ok(acc)
}
MethodKind::Len => Ok(Value::Int(items.len() as i64)),
// sum/any/all — literally fold under the hood
}
}Lambdas are AST nodes, not values; their params are pre-allocated VarIds, so
a call is just "write the param slot, eval the body." No allocation per call.
Yes. The two cases above aren't a constraint — they're how the AST records
statically-written arrays differently from computed arrays. From the
template author's point of view there's no choice to make: write any
expression in {{ … }}, including chained collection methods.
OutNode::Array([ … ]) exists only when the template literal has brackets:
"keys": [a, b, c]. The brackets were in your source.
If your hole's expression produces an array, the output node is
OutNode::Hole(eid), and inside the expression you have the full language:
"high_value": {{ input.items.map(it -> it.price).filter(p -> p > 100) }}
"total": {{ input.items.map(it -> it.qty * it.price).fold(0, (a, b) -> a + b) }}
"first_skus": {{ input.items.filter(it -> it.in_stock).map(it -> it.sku) }}
Each is a single output hole — OutNode::Hole(eid) — whose expression yields
a Value::Arr of whatever length. The output node doesn't care about shape;
the expression does the work.
The thing you can't do is mix the template's bracket syntax with method
syntax outside a hole — e.g. write [1, 2, 3].map(...) at the output level.
Methods live in expressions, so the chain has to be inside a {{ … }}.
For {{ input.cart.items.map(it -> it.qty * it.price).fold(0, (a, b) -> a + b) }}:
- The output AST has
Hole(eid)here.buildcallseval_expr(eid). eval_exprseesMethod { kind: Fold, … }— evaluate the receiver first.- Receiver is
Method { kind: Map, … }— evaluate its receiver first. - Receiver of
mapisPath { Input, [Field("cart"), Field("items")] }—eval_pathwalks two.gets on the input →Value::Arr(items). .map(it -> it.qty * it.price)— loop items, bindit, evaluateit.qty * it.price(two paths + aDecimal *) →Value::Arr(prices)..fold(0, (a, b) -> a + b)— loop, binda, b, evaluatea + b, accumulate →Value::Decimal(total).- That
Valuebecomes the hole's result.buildslots it into the output object at the current key.
No reflection, no string lookups, no type erasure. Everything is a match on
small enums plus array indexing.
Because of one variant. Both enums are "data in memory", but they disagree on what a number is:
serde_json::Value temple_dsl::Value
───────────────── ─────────────────
Null Null
Bool(bool) Bool(bool)
Number(Number) ◄── ONE number slot Int(i64) ◄── TWO number slots
(i64 | u64 | f64) Decimal(Decimal) ◄──
String(String) Str(SmolStr)
Array(Vec) Arr(Vec)
Object(Map) Obj(IndexMap)
In serde_json, any number with a decimal point becomes an f64 — a binary
float that physically cannot store most decimal fractions:
0.1 + 0.2 == 0.30000000000000004 ← visibly wrong
129.99 is stored as 129.99000000000000909495 ← prints as "129.99", but it's lying
The second line is the dangerous one: the error is usually invisible (printing
rounds it away) and surfaces randomly — a total off by a cent, a == that
fails. Temple's contract is exact decimals, so its Value keeps integers in
Int and fractional numbers in Decimal — and has no f64 variant at all.
The wrong type doesn't exist, so the mistake can't be made.
Owning the type also bought three things a borrowed type couldn't:
| Why it needs our own type | |
|---|---|
| Ordered keys | Obj is an IndexMap — output keys always come out in template order |
render::<Value> is a compile error |
Value deliberately doesn't implement Deserialize (see below) |
| Stack-safe drop | Value has an iterative Drop, so even a pathologically deep value can't overflow the stack — part of the no-panic guarantee |
rust_decimal::Decimal stores base-10 digits — an integer mantissa plus a
"where's the decimal point" scale — so decimal math is integer math underneath:
129.99 is stored as (mantissa: 12999, scale: 2) → 12999 × 10⁻²
0.0825 is stored as (mantissa: 825, scale: 4) → 825 × 10⁻⁴
multiply: 12999 × 825 = 10724175, scales add 2+4=6 → 10.724175 EXACT
The danger zones are the boundaries — anywhere data crosses in or out, an f64 detour could silently corrupt the digits. Every crossing is guarded:
sequenceDiagram
participant J as JSON text "129.99"
participant T as temple::Value
participant E as Evaluator
participant O as Your Rust struct
Note over J,T: ① INPUT boundary
J->>T: verbatim token "129.99" → Decimal(12999, scale 2)
Note right of T: never via as_f64() — text → Decimal directly
Note over T,E: ② TEMPLATE literals
Note right of E: `0.0825` in source is parsed by Temple's<br/>own parser straight into Decimal
Note over E: ③ MATH
E->>E: Decimal × Decimal = Decimal (exact, checked)
Note over E,O: ④ OUTPUT boundary (render::<T>)
E->>O: Decimal handed over as the digit-string "10.724175"
Note right of O: rust_decimal's Deserialize re-parses the<br/>exact digits into YOUR Decimal field
- Input — library callers build
Value::Decimaldirectly (this is whyrendertakesimpl Into<Value>, notimpl Serialize: a genericSerializewould let a struct'sf64field sneak corrupted digits in). JSON input converts via thejsonfeature'sFrom<serde_json::Value>, which parses the verbatim number token intoDecimal— neveras_f64(). - Template literals —
0.0825in template source never touches serde; Temple's parser reads the characters into aDecimal. - Math — decimal-to-decimal with checked arithmetic;
Intmixed withDecimalpromotes toDecimal. No float ever appears mid-evaluation. - Output — serde's number vocabulary is i64/u64/f64, so handing a
Decimalover as a number would force the f64 detour at the last step. Instead the customDeserializeremits it as a string of exact digits;rust_decimal's ownDeserializere-parses them losslessly into your struct'sDecimalfield. (Anf64struct field still works — that's an explicit opt-in where you chose the approximation.) Converting toserde_json::Valuevia thejsonfeature emits a real JSON number token with the exact digits, courtesy ofarbitrary_precision.
Deserializing a Value into a Value would be a pointless full copy through
serde. Because Temple owns the type, the trap became a compile error: Value
simply doesn't implement Deserialize.
template.render::<Receipt>(input) // ✓ typed output
template.render::<serde_json::Value>(input) // ✓ JSON output
template.render::<temple_dsl::Value>(input) // ✗ does not compile
template.render_value(input) // ✓ the direct way — no serde round-tripInternally this is why the AST stores literals as a separate Lit type rather
than Value: the serialized blob needs Deserialize on everything it
contains, and keeping Value out of the AST keeps it out of Deserialize.
Enable the json feature and the conversions are built in, exact in both
directions:
temple-dsl = { git = "…", branch = "release", features = ["json"] }// IN: serde_json::Value goes straight into render (via From / Into<Value>)
let input: serde_json::Value = serde_json::from_str(body)?;
let out = template.render_value(input)?;
// OUT: convert back — decimals become exact JSON number tokens
let json = serde_json::Value::from(out);The input conversion parses each number's verbatim token ("129.99" →
Decimal, never through f64); the output conversion emits decimals as real
JSON numbers with the exact digits. A number beyond Decimal's range
(≈ ±7.9 × 10²⁸) converts to Null rather than silently rounding. The core
crate stays dependency-lean: serde_json only enters the tree when the json
(or cli) feature is on.