A Template Expander

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

Context

i
-- A context maps variable names to template values
abbrev Context := List (String × TVal)

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

Variable Lookup

i
-- Look up a variable in the context
def ctxGet (ctx : Context) (name : String) : Option TVal :=
  (ctx.find? (·.1 == name)).map (·.2)

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

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"

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")
]
<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
i
-- O(1) lookup: index directly into the HashMap
def ctxGet (ctx : HashCtx) (name : String) : Option TVal :=
  ctx[name]?

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
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

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."

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."

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" returns Option TVal, but the code tries to use it directly as a string
  • Add a match expression to handle some (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 (or none for empty)
  • Replace with items.foldl over all items, like the working expand function

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 some value 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.foldl reverses the list before folding, producing backward output
  • Remove .reverse to 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 ++ itemCtx which puts the outer context first
  • Swap to itemCtx ++ ctx so 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
  • ctxGet returns Option TVal, but the code tries to treat it as Option String
  • TVal is not String — you need to pattern-match and extract the TVal.Str s value
  • Add match and 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.TSeq with TNode.TText "Hello, ", TNode.TVar "name", and TNode.TText "!"
  • If name is missing, expand already returns "" for missing variables
  • The #guard expects "Hello, World!" with name="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.TLoop with list name "items" and a body that wraps TNode.TVar "item" in "* " prefix
  • The body should be TNode.TSeq [TNode.TText "* ", TNode.TVar "item"]
  • Use TVal.List with 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 using TNode.TSeq
  • Include a TNode.TVar "title" inside <h1> tags
  • The #guard checks 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.TIf to 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 n identical items
  • Use List.replicate n ctx to create n copies 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 2 can remove the trailing ", " after expansion
  • Or use items.foldl directly with index awareness