A Template Expander
- Model a simple template engine as a tree of nodes
- Use recursive inductive types to represent template syntax
- Walk the tree with an explicit context to substitute variables
- Handle loops and conditionals with functional recursion
- Template engines like Jinja and Mustache fill in placeholders with data
- We'll build a minimal engine that supports variables, loops, and conditionals
Template Values
i
-- Values that can appear in a template context
inductive TVal where
| Str : String → TVal
| List : List (List (String × TVal)) → TVal
deriving Repr
TValis a sum type for values in the template contextTVal.Str sholds a plain stringTVal.List itemsholds a list of contexts, one per loop iteration- Each inner list is itself a
Context(a list of(name, value)pairs) for that iteration
- Each inner list is itself a
Context
i
-- A context maps variable names to template values
abbrev Context := List (String × TVal)
- A context is a list of
(name, value)pairs- Like a Python
dictbut implemented as an association list
- Like a Python
abbrevcreates a type alias without introducing a new wrapper typeContextandList (String × TVal)are fully interchangeable
- Each variable lookup scans the list linearly
- Small contexts: this is simple and fine
- Large contexts: a
HashMapwould be faster but isn't in the prelude (DEBT)
Template Nodes
i
-- Template abstract syntax tree nodes
inductive TNode where
| TText : String → TNode -- literal text
| TVar : String → TNode -- {{ var }}
| TLoop : String → TNode → TNode -- {% for x in list %} body {% end %}
| TIf : String → TNode → TNode → TNode -- {% if var %} then {% else %} else {% end %}
| TSeq : List TNode → TNode -- sequence of nodes
deriving Repr
TNodedefines the abstract syntax tree of a template- Five constructors:
TNode.TText s: literal text, output as-isTNode.TVar name: variable substitution{% raw %}{{ var }}{% endraw %}TNode.TLoop name body: loop over a list{% raw %}{% for x in items %}...{% end %}{% endraw %}TNode.TIf name thenNode elseNode: conditionalTNode.TSeq nodes: a sequence of nodes in order
TNodeis recursive: branches can contain moreTNodetrees
Variable Lookup
i
-- Look up a variable in the context
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
ctxGetsearches the context for a variable by namectx.find? (·.1 == name)returns the first pair where the key matches.1accesses the first element of the(String × TVal)tuple
.map (·.2)extracts the value from the found pair- Returns
Option TVal:someif found,noneif not- Like Python's
dict.get(name)but more explicit
- Like Python's
Expanding Templates
i
-- Expand a template node to a string using the given context
def expand (ctx : Context) : TNode → String
| TNode.TText s => s
| TNode.TVar name =>
match ctxGet ctx name with
| some (TVal.Str s) => s
| _ => ""
| TNode.TLoop listName body =>
match ctxGet ctx listName with
| some (TVal.List items) =>
items.foldl (init := "") fun acc itemCtx =>
acc ++ expand (itemCtx ++ ctx) body
| _ => ""
| TNode.TIf varName thenNode elseNode =>
match ctxGet ctx varName with
| some (TVal.Str s) => if s.isEmpty then expand ctx elseNode
else expand ctx thenNode
| some (TVal.List l) => if l.isEmpty then expand ctx elseNode
else expand ctx thenNode
| none => expand ctx elseNode
| TNode.TSeq nodes => nodes.foldl (init := "") fun acc n => acc ++ expand ctx n
expandis the heart of the engine: recursively walks the tree and builds output- Each node type has a different expansion rule:
TText: output the string directlyTVar: look up the variable; output its string value (or""if not found)TLoop: for each item in the list variable, expand the body with that item's context pushed on topTIf: check the variable; a non-emptyStror non-emptyListis truthy → then branch; empty or missing → else branchTSeq: expand all child nodes and concatenate results with++
itemCtx ++ ctxinTLooppushes the loop variable's context in front- The inner variable shadows any outer variable with the same name
foldlis used for both loops and sequences- Accumulator starts as
""and grows as each element expands
- Accumulator starts as
Tests
i
-- Tests ----------------------------------------------------------------
-- Simple variable substitution: "Hello, {{ name }}!" with name="World"
def helloTmpl : TNode :=
TNode.TSeq [TNode.TText "Hello, ", TNode.TVar "name", TNode.TText "!"]
#guard expand [("name", TVal.Str "World")] helloTmpl == "Hello, World!"
-- Loop: <ul>{% for item in items %}<li>{{ x }}</li>{% end %}</ul>
def listTmpl : TNode :=
TNode.TSeq [
TNode.TText "<ul>",
TNode.TLoop "items"
(TNode.TSeq [TNode.TText "<li>", TNode.TVar "x", TNode.TText "</li>"]),
TNode.TText "</ul>"
]
def listCtx : Context := [
("items", TVal.List [[("x", TVal.Str "apples")], [("x", TVal.Str "bananas")]])
]
#guard expand listCtx listTmpl == "<ul><li>apples</li><li>bananas</li></ul>"
-- Conditional: {% if show %}visible{% else %}hidden{% end %}
def ifTmpl : TNode :=
TNode.TIf "show" (TNode.TText "visible") (TNode.TText "hidden")
#guard expand [("show", TVal.Str "yes")] ifTmpl == "visible"
#guard expand [("show", TVal.Str "")] ifTmpl == "hidden"
#guard expand [] ifTmpl == "hidden"
-- Nested: variable inside conditional inside sequence
def nestedTmpl : TNode :=
TNode.TSeq [
TNode.TText "Status: ",
TNode.TIf "active"
(TNode.TSeq [TNode.TText "ON (", TNode.TVar "user", TNode.TText ")"])
(TNode.TText "OFF")
]
#guard expand [("active", TVal.Str "1"), ("user", TVal.Str "admin")] nestedTmpl
== "Status: ON (admin)"
#guard expand [("active", TVal.Str "")] nestedTmpl
== "Status: OFF"
- Four templates are tested with
#guard - Variable substitution:
"Hello, {{ name }}!"withname="World"→"Hello, World!" - Loop:
<ul>with two list items → full HTML string- Each loop iteration gets its own sub-context:
[("x", "apples")], then[("x", "bananas")]
- Each loop iteration gets its own sub-context:
- Conditional:
"visible"when truthy,"hidden"when falsy or missing - Nested: variable inside conditional inside sequence
- Notice the loop body uses
xas the loop variable name- The template defines
{{ x }}in the body, and the data providesxin each item context
- The template defines
A Hello Template
i
-- Render a simple hello template
def helloTemplate : TNode :=
TNode.TSeq [
TNode.TText "<h1>Welcome, ",
TNode.TVar "user",
TNode.TText "!</h1>",
TNode.TText "\n",
TNode.TText "<p>You have ",
TNode.TVar "count",
TNode.TText " items.</p>"
]
def helloCtx : Context := [
("user", TVal.Str "Alice"),
("count", TVal.Str "3")
]
helloTemplateis a larger example: HTML with two variable substitutionshelloCtxprovides values for both"user"and"count"- This template would render to:
<h1>Welcome, Alice!</h1>
<p>You have 3 items.</p>
A HashMap Context
i
-- A HashMap context: O(1) lookup keyed by variable name
abbrev HashCtx := HashMap String TVal
HashMap String TValmaps variable names directly to values- Lookup is O(1) instead of O(n) scan down the association list
- From the
Batterieslibrary:import Batteriesandopen Stdare required
ctx[name]?returnsOption TVal— same return type as the list version- The change is entirely inside
HashCtxandctxGet: nothing else in the engine needs to know
i
-- O(1) lookup: index directly into the HashMap
def ctxGet (ctx : HashCtx) (name : String) : Option TVal :=
ctx[name]?
ctx[name]?is HashMap's subscript operator — equivalent toHashMap.getElem? ctx name- Like Python's
ctx.get(name)on a dict
- Like Python's
What Stays the Same
i
-- Same template AST as the association-list version — unchanged
inductive TNode where
| TText : String → TNode
| TVar : String → TNode
| TLoop : String → TNode → TNode
| TIf : String → TNode → TNode → TNode
| TSeq : List TNode → TNode
deriving Repr
TNodeis unchanged — it describes template syntax, not how context is storedexpandis also unchanged in structure: it callsctxGetand recurses on nodes
i
-- expand is structurally identical to the list version;
-- only the Context type and ctxGet implementation differ
def expand (ctx : HashCtx) : TNode → String
| TNode.TText s => s
| TNode.TVar name =>
match ctxGet ctx name with
| some (TVal.Str s) => s
| _ => ""
| TNode.TLoop listName body =>
match ctxGet ctx listName with
| some (TVal.List items) =>
items.foldl (init := "") fun acc itemCtx =>
-- merge loop-iteration variables on top of outer context
let inner := itemCtx.foldl (fun m (k, v) => m.insert k v) ctx
acc ++ expand inner body
| _ => ""
| TNode.TIf varName thenNode elseNode =>
match ctxGet ctx varName with
| some (TVal.Str s) => if s.isEmpty then expand ctx elseNode
else expand ctx thenNode
| some (TVal.List l) => if l.isEmpty then expand ctx elseNode
else expand ctx thenNode
| none => expand ctx elseNode
| TNode.TSeq nodes => nodes.foldl (init := "") fun acc n => acc ++ expand ctx n
- The only difference in
expandis the loop case: merging an item's context into the HashMapitemCtx.foldl (fun m (k, v) => m.insert k v) ctxinserts the loop variables on top- Instead of
itemCtx ++ ctx(list prepend), we fold-insert into the parent map
- Every other branch is structurally identical to the association-list version
- This is encapsulation in practice:
TNodeand the core ofexpandare stable; only the context implementation changed
Running the HashMap Version
i
-- The same tests pass with the new context type
def helloTmpl : TNode :=
TNode.TSeq [TNode.TText "Hello, ", TNode.TVar "name", TNode.TText "!"]
#guard expand (HashMap.ofList [("name", TVal.Str "World")]) helloTmpl == "Hello, World!"
def listTmpl : TNode :=
TNode.TSeq [
TNode.TText "<ul>",
TNode.TLoop "items"
(TNode.TSeq [TNode.TText "<li>", TNode.TVar "x", TNode.TText "</li>"]),
TNode.TText "</ul>"
]
#guard expand
(HashMap.ofList [("items", TVal.List
[[("x", TVal.Str "apples")], [("x", TVal.Str "bananas")]])])
listTmpl
== "<ul><li>apples</li><li>bananas</li></ul>"
#guard expand (HashMap.ofList [("show", TVal.Str "yes")])
(TNode.TIf "show" (TNode.TText "visible") (TNode.TText "hidden"))
== "visible"
#guard expand ({} : HashCtx)
(TNode.TIf "show" (TNode.TText "visible") (TNode.TText "hidden"))
== "hidden"
i
def main : IO Unit := do
let ctx : HashCtx := HashMap.ofList [
("user", TVal.Str "Alice"),
("count", TVal.Str "3")
]
let tmpl : TNode :=
TNode.TSeq [
TNode.TText "<h1>Welcome, ", TNode.TVar "user", TNode.TText "!</h1>\n",
TNode.TText "<p>You have ", TNode.TVar "count", TNode.TText " items.</p>"
]
IO.println (expand ctx tmpl)
IO.println "Done."
- The same tests pass with no changes to the test inputs or expected outputs
HashMap.ofList [...]builds a context from a list of pairs — like Python'sdict([...])- In a real project,
TVal,TNode, andexpandwould live in a shared module (see build) and both the association-list and HashMap versions would import from it
Running the Program
i
-- Entry point: render a template and print the result
-- Run with: lean --run template/code.lean
def main : IO Unit := do
let output := expand helloCtx helloTemplate
IO.println output
IO.println "Done."
mainrendershelloTemplateand prints the resultIO.println outputdisplays the rendered HTML- Compile and type-check with:
lake env lean template/code.lean
- Run interactively with:
lean --run template/code.lean
- The output is plain text — adding file I/O is left as an exercise (see io)
Exercises
Fix: Variable Not Found
i
-- ctxGet returns Option TVal, but this code treats it as a String. Fix it.
abbrev Context := List (String × TVal)
inductive TVal where | Str (s : String) | List (items : List Context)
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
def getGreeting (ctx : Context) : String :=
let val := ctxGet ctx "user"
s!"Hello, {val}!"
#guard getGreeting [("user", TVal.Str "Alice")] == "Hello, Alice!"
#guard getGreeting [] == "Hello, unknown!"
hint
ctxGet ctx "user"returnsOption TVal, but the code tries to use it directly as a string- Add a
matchexpression to handlesome (TVal.Str s)and_(not found) - Return
"unknown"when the variable is missing
Fix: Loop Skips Items
i
-- The loop only processes the first item instead of all. Fix it.
abbrev Context := List (String × TVal)
inductive TVal where | Str (s : String) | List (items : List Context)
inductive TNode where
| TText (s : String) | TVar (name : String)
| TLoop (name : String) (body : TNode) | TSeq (nodes : List TNode)
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
def expand (ctx : Context) : TNode → String
| TNode.TText s => s
| TNode.TVar name =>
match ctxGet ctx name with
| some (TVal.Str s) => s
| _ => ""
| TNode.TLoop listName body =>
match ctxGet ctx listName with
| some (TVal.List items) =>
match items.head? with
| some itemCtx => expand (itemCtx ++ ctx) body
| none => ""
| _ => ""
| TNode.TSeq nodes => nodes.foldl (init := "") fun acc n => acc ++ expand ctx n
def body : TNode := TNode.TSeq [TNode.TText "- ", TNode.TVar "x"]
def tmpl : TNode := TNode.TLoop "items" body
def ctx : Context := [("items", TVal.List [[("x", TVal.Str "a")], [("x", TVal.Str "b")]])]
#guard expand ctx tmpl == "- a- b"
hint
- The loop only processes the first item in the list instead of all of them
items.head?returns only the first element (ornonefor empty)- Replace with
items.foldlover all items, like the workingexpandfunction
Fix: Empty String Is Truthy
i
-- An empty string "" should be falsy, but this treats it as truthy. Fix it.
abbrev Context := List (String × TVal)
inductive TVal where | Str (s : String) | List (items : List Context)
inductive TNode where
| TText (s : String) | TIf (name : String) (t e : TNode)
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
def expand (ctx : Context) : TNode → String
| TNode.TText s => s
| TNode.TIf varName thenNode elseNode =>
match ctxGet ctx varName with
| some (TVal.Str s) => expand ctx thenNode
| _ => expand ctx elseNode
def tmpl : TNode := TNode.TIf "flag" (TNode.TText "ON") (TNode.TText "OFF")
#guard expand [("flag", TVal.Str "yes")] tmpl == "ON"
#guard expand [("flag", TVal.Str "")] tmpl == "OFF"
#guard expand [] tmpl == "OFF"
hint
- An empty string
""should be treated as falsy (like Python) - The function currently treats every
somevalue as truthy - Add a check:
if s.isEmpty then ... else ...
Fix: Sequence Concatenation Order
i
-- reverse.foldl produces backward output. Fix it.
abbrev Context := List (String × TVal)
inductive TVal where | Str (s : String) | List (items : List Context)
inductive TNode where
| TText (s : String) | TSeq (nodes : List TNode)
def expand (ctx : Context) : TNode → String
| TNode.TText s => s
| TNode.TSeq nodes => nodes.reverse.foldl (init := "") fun acc n => acc ++ expand ctx n
def tmpl : TNode :=
TNode.TSeq [TNode.TText "A", TNode.TText "B", TNode.TText "C"]
#guard expand [] tmpl == "ABC"
hint
nodes.reverse.foldlreverses the list before folding, producing backward output- Remove
.reverseto keep the original left-to-right order
Fix: Context Shadowing
i
-- Loop context should shadow outer context, but this puts outer first. Fix it.
abbrev Context := List (String × TVal)
inductive TVal where | Str (s : String) | List (items : List Context)
inductive TNode where
| TText (s : String) | TVar (name : String)
| TLoop (name : String) (body : TNode) | TSeq (nodes : List TNode)
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
def expand (ctx : Context) : TNode → String
| TNode.TText s => s
| TNode.TVar name =>
match ctxGet ctx name with
| some (TVal.Str s) => s
| _ => ""
| TNode.TLoop listName body =>
match ctxGet ctx listName with
| some (TVal.List items) =>
items.foldl (init := "") fun acc itemCtx =>
acc ++ expand (ctx ++ itemCtx) body
| _ => ""
| TNode.TSeq nodes => nodes.foldl (init := "") fun acc n => acc ++ expand ctx n
def tmpl : TNode :=
TNode.TSeq [
TNode.TText "outer: ",
TNode.TVar "x",
TNode.TText " | ",
TNode.TLoop "items" (TNode.TVar "x")
]
-- Outer x is "outer", inner x is "a". The loop should print "a", not "outer".
def ctx : Context := [("x", TVal.Str "outer"), ("items", TVal.List [[("x", TVal.Str "a")]])]
#guard expand ctx tmpl == "outer: outer | a"
hint
- In the loop body, the inner context should shadow outer variables
- The code uses
ctx ++ itemCtxwhich puts the outer context first - Swap to
itemCtx ++ ctxso the loop variable takes precedence
Fix: Wrong Type in Lookup
i
-- ctxGet returns Option TVal, not Option String. Fix the type mismatch.
abbrev Context := List (String × TVal)
inductive TVal where | Str (s : String) | List (items : List Context)
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
def lookupString (ctx : Context) (name : String) : String :=
match ctxGet ctx name with
| some s => s
| none => ""
#guard lookupString [("x", TVal.Str "hello")] "x" == "hello"
#guard lookupString [] "x" == ""
hint
ctxGetreturnsOption TVal, but the code tries to treat it asOption StringTValis notString— you need to pattern-match and extract theTVal.Str svalue- Add
matchand return""when the variable has a different type or is missing
Write: Greeting Template
i
-- Build a TNode tree for the template "Hello, {{ name }}!" and test it
inductive TVal where
| Str (s : String)
| List (items : List (List (String × TVal)))
abbrev Context := List (String × TVal)
inductive TNode where
| TText (s : String) | TVar (name : String) | TSeq (nodes : List TNode)
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
def expand (ctx : Context) : TNode → String
| TNode.TText s => s
| TNode.TVar name =>
match ctxGet ctx name with
| some (TVal.Str s) => s
| _ => ""
| TNode.TSeq nodes => nodes.foldl (init := "") fun acc n => acc ++ expand ctx n
def greetingTmpl : TNode :=
TNode.TSeq []
#guard expand [("name", TVal.Str "World")] greetingTmpl == "Hello, World!"
hint
- Build a
TNode.TSeqwithTNode.TText "Hello, ",TNode.TVar "name", andTNode.TText "!" - If
nameis missing,expandalready returns""for missing variables - The
#guardexpects"Hello, World!"withname="World"
Write: List Items
i
-- Build a template that renders a bulleted list from a "items" list
inductive TVal where
| Str (s : String)
| List (items : List (List (String × TVal)))
abbrev Context := List (String × TVal)
inductive TNode where
| TText (s : String) | TVar (name : String)
| TLoop (name : String) (body : TNode) | TSeq (nodes : List TNode)
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
def expand (ctx : Context) : TNode → String
| TNode.TText s => s
| TNode.TVar name =>
match ctxGet ctx name with
| some (TVal.Str s) => s
| _ => ""
| TNode.TLoop listName body =>
match ctxGet ctx listName with
| some (TVal.List items) =>
items.foldl (init := "") fun acc itemCtx =>
acc ++ expand (itemCtx ++ ctx) body
| _ => ""
| TNode.TSeq nodes => nodes.foldl (init := "") fun acc n => acc ++ expand ctx n
def listItemsTmpl : TNode :=
TNode.TLoop "items" (TNode.TSeq [])
def ctx : Context := [
("items", TVal.List [
[("item", TVal.Str "apples")],
[("item", TVal.Str "bananas")]
])
]
#guard expand ctx listItemsTmpl == "* apples* bananas"
hint
- Use
TNode.TLoopwith list name"items"and a body that wrapsTNode.TVar "item"in"* "prefix - The body should be
TNode.TSeq [TNode.TText "* ", TNode.TVar "item"] - Use
TVal.Listwith each item being[("item", TVal.Str "...")]
Write: HTML Page
i
-- Build a template that renders a full HTML page with a title
inductive TVal where
| Str (s : String)
| List (items : List (List (String × TVal)))
abbrev Context := List (String × TVal)
inductive TNode where
| TText (s : String) | TVar (name : String) | TSeq (nodes : List TNode)
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
def expand (ctx : Context) : TNode → String
| TNode.TText s => s
| TNode.TVar name =>
match ctxGet ctx name with
| some (TVal.Str s) => s
| _ => ""
| TNode.TSeq nodes => nodes.foldl (init := "") fun acc n => acc ++ expand ctx n
def htmlPageTmpl : TNode :=
TNode.TSeq []
#guard expand [("title", TVal.Str "My Site")] htmlPageTmpl == "<html><body><h1>My Site</h1></body></html>"
hint
- Build a full
<html><body>...</body></html>structure usingTNode.TSeq - Include a
TNode.TVar "title"inside<h1>tags - The
#guardchecks for<html><body><h1>My Site</h1></body></html>
Write: Show/Hide Section
i
-- Use TIf to show or hide a section based on "show_section"
inductive TVal where
| Str (s : String)
| List (items : List (List (String × TVal)))
abbrev Context := List (String × TVal)
inductive TNode where
| TText (s : String) | TIf (name : String) (t e : TNode)
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
def expand (ctx : Context) : TNode → String
| TNode.TText s => s
| TNode.TIf varName thenNode elseNode =>
match ctxGet ctx varName with
| some (TVal.Str s) => if s.isEmpty then expand ctx elseNode
else expand ctx thenNode
| _ => expand ctx elseNode
def showHideTmpl : TNode :=
TNode.TIf "show_section" (TNode.TText "Section content") (TNode.TText "")
#guard expand [("show_section", TVal.Str "yes")] showHideTmpl == "Section content"
#guard expand [("show_section", TVal.Str "")] showHideTmpl == ""
#guard expand [] showHideTmpl == ""
hint
- Use
TNode.TIfto conditionally render the section - Check the
"show_section"variable - When true, render "Section content"; when false, render ""
Write: Repeat N Times
i
-- Build a template that repeats a word N times using a loop
inductive TVal where
| Str (s : String)
| List (items : List (List (String × TVal)))
abbrev Context := List (String × TVal)
inductive TNode where
| TText (s : String) | TVar (name : String)
| TLoop (name : String) (body : TNode) | TSeq (nodes : List TNode)
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
def expand (ctx : Context) : TNode → String
| TNode.TText s => s
| TNode.TVar name =>
match ctxGet ctx name with
| some (TVal.Str s) => s
| _ => ""
| TNode.TLoop listName body =>
match ctxGet ctx listName with
| some (TVal.List items) =>
items.foldl (init := "") fun acc itemCtx =>
acc ++ expand (itemCtx ++ ctx) body
| _ => ""
| TNode.TSeq nodes => nodes.foldl (init := "") fun acc n => acc ++ expand ctx n
def repeatTmpl : TNode :=
TNode.TLoop "times" (TNode.TVar "word")
def ctx : Context := [
("times", TVal.List (List.replicate 3 [("word", TVal.Str "go")]))
]
#guard expand ctx repeatTmpl == "gogogo"
hint
- Create a context with a list of
nidentical items - Use
List.replicate n ctxto createncopies of the same context - The loop body just references
TNode.TVar "word"
Write: Join With Separator
i
-- Build a template that joins items with ", " but no trailing separator
inductive TVal where
| Str (s : String)
| List (items : List (List (String × TVal)))
abbrev Context := List (String × TVal)
inductive TNode where
| TText (s : String) | TVar (name : String)
| TLoop (name : String) (body : TNode) | TSeq (nodes : List TNode)
def ctxGet (ctx : Context) (name : String) : Option TVal :=
(ctx.find? (·.1 == name)).map (·.2)
def expand (ctx : Context) : TNode → String
| TNode.TText s => s
| TNode.TVar name =>
match ctxGet ctx name with
| some (TVal.Str s) => s
| _ => ""
| TNode.TLoop listName body =>
match ctxGet ctx listName with
| some (TVal.List items) =>
items.foldl (init := "") fun acc itemCtx =>
acc ++ expand (itemCtx ++ ctx) body
| _ => ""
| TNode.TSeq nodes => nodes.foldl (init := "") fun acc n => acc ++ expand ctx n
def joinTmpl : TNode :=
TNode.TLoop "items" (TNode.TSeq [
TNode.TVar "x",
TNode.TText ", "
])
def ctx : Context := [
("items", TVal.List [
[("x", TVal.Str "a")],
[("x", TVal.Str "b")],
[("x", TVal.Str "c")]
])
]
def renderWithJoin (ctx : Context) (tmpl : TNode) : String :=
let raw := expand ctx tmpl
if raw.isEmpty then raw else raw.dropRight 2
#guard renderWithJoin ctx joinTmpl == "a, b, c"
hint
- Expand the loop body to produce
item ++ ", "for each item - Handle the trailing separator: either check if it's the last item, or trim after
String.dropRight 2can remove the trailing", "after expansion- Or use
items.foldldirectly with index awareness