diff --git a/AGENTS.md b/AGENTS.md index df50195..07c1c9c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,7 +28,7 @@ public/ ← build output (generated) | `sectionate.odin` | `wrap_sections` proc — splits HTML at `
` blocks.
- highlight.js removed; syntax highlighting is build-time only (no fallback if Tree-sitter fails).
+- `format_f64` in mustache brute-forces shortest float representation (Odin's `strconv` doesn't produce shortest round-trip for all values like `3.3`).
+- Block indent uses output-level indentation (`write_indented`), not source-level re-parse. Multi-line interpolated content inside a standalone block would get incorrectly indented. No spec test exercises this.
## Design decisions
See `HUGO.md` for analysis of why thor doesn't need Hugo's shortcode context isolation.
+See `mustache/PARTIAL_INDENT.md` for whitespace handling analysis.
+See `mustache/SPEC.md` for the original implementation specification.
## TODO
diff --git a/mustache/PARTIAL_INDENT.md b/mustache/PARTIAL_INDENT.md
deleted file mode 100644
index a6db0dc..0000000
--- a/mustache/PARTIAL_INDENT.md
+++ /dev/null
@@ -1,137 +0,0 @@
-# Partial Indentation — Problem & Solutions
-
-## Problem
-
-When a partial tag `{{> name}}` is standalone (only non-whitespace on its line), the mustache spec requires that its leading whitespace be treated as indentation and **prepended to each line of the partial source before rendering**.
-
-This is a source-level transformation, not output post-processing. The distinction matters when interpolated content contains newlines:
-
-```
-partial source: "|\n{{{content}}}\n|\n"
-content value: "<\n->"
-indent: " "
-
-Expected output: " |\n <\n->\n |\n"
-```
-
-The line `->` gets NO indent — it comes from expanded content (`<\n->`), not from a partial source line. Post-processing the output would incorrectly indent it.
-
-### Spec tests that require this
-
-- **Standalone Without Previous Line** — indent at start of template
-- **Standalone Without Newline** — indent at end of template
-- **Standalone Indentation** — indent with multi-line interpolated content
-
-3 of 14 tests in `partials.json`. The other 11 (basic lookup, context, recursion, nesting, inline usage, failed lookup, padding) work without indentation handling.
-
-### thor's real usage
-
-Thor's partials (`{{> nav}}`, `{{> footer}}`, `{{>* icon}}`) are typically standalone with indentation. Without indentation handling, HTML output has wrong indentation — ugly but functional since HTML ignores whitespace.
-
-## Solution 1: Store source, re-parse with indent (Recommended)
-
-Add `source: string` to `Template`. When rendering a standalone partial with non-empty indent:
-
-1. Prepend indent to each line of `partial.source`
-2. Re-tokenize + re-parse with `context.temp_allocator`
-3. Render the re-parsed nodes
-
-When indent is empty, render the pre-parsed nodes directly (no re-parse).
-
-### Pros
-- Correct by construction — exactly matches spec ("prepended to each line before rendering")
-- ~20 lines of code
-- Nested partials accumulate indentation naturally
-- No line-start state tracking
-
-### Cons
-- Template gains a `source: string` field
-- Standalone partials with indent get re-parsed at render time (negligible for small fragments)
-
-### Implementation sketch
-
-```odin
-// tokenizer: capture indent during trim_standalone_whitespace
-// for Partial tokens, before stripping left whitespace:
-tokens[i].indent = text[nl+1:] // capture indentation
-
-// mustache.odin: Template gains source field
-Template :: struct {
- nodes: [dynamic]Node,
- source: string,
-}
-
-// parse: store source
-tmpl.source = source
-
-// renderer: re-parse if indent
-case .Partial:
- pt, found := partials[name]
- if !found do break
- if len(node.indent) > 0 {
- indented := indent_lines(pt.source, node.indent)
- reparse, err := parse(indented, context.temp_allocator, context.temp_allocator)
- if err == nil {
- defer delete(reparse.nodes)
- render_nodes(reparse.nodes[:], reparse.nodes[:], ctx, partials, b)
- }
- } else {
- render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b)
- }
-```
-
-## Solution 2: Modify text nodes during rendering
-
-Track "at line start" state while rendering the partial's nodes. Insert indent before text-node content that begins a new line. Variables/sections render normally — their output is NOT indented.
-
-### Pros
-- No source storage
-- No re-parsing
-
-### Cons
-- Complex: must track line-start state across nodes
-- Variables producing multi-line output need careful handling (their newlines don't create indented lines)
-- Trailing newline edge case (partial ending with `\n` shouldn't leave trailing indent)
-- Nested partials with accumulated indentation need extra logic
-- ~60+ lines of fiddly code
-
-### Implementation sketch
-
-```odin
-render_partial :: proc(nodes, ctx, partials, b, indent) {
- at_line_start := true
- for node in nodes {
- switch node.kind {
- case .Text:
- // Walk text, prepend indent at line starts
- // Insert indent after each \n
- // Don't indent after final \n of last text node
- ...
- at_line_start = (text ends with \n)
- case .Variable, .Unescaped:
- // Render normally — no indent applied to output
- write_value(b, val, ...)
- at_line_start = false // can't know if output ends with \n
- case .Section, .Inverted:
- // Complex: nested text nodes need indent too?
- ...
- }
- }
-}
-```
-
-The "can't know if variable output ends with \n" problem makes `at_line_start` unreliable, requiring heuristics or output buffering.
-
-## Adjacent Tag Standalone Detection
-
-**Problem:** When a block-type tag and its close tag are adjacent (no text between them), like `{{a}}\n {{>b}}` — `{{>b}}` gets `indent=""` because its left text was consumed by `{{>a}}`'s right-trim). Blocks recover via `deindent_blocks`, but partials/parents have no fallback.
+- [ ] lazy load partials ? (with mutex)
diff --git a/mustache/data.odin b/mustache/data.odin
index 6314480..6f2dfa3 100644
--- a/mustache/data.odin
+++ b/mustache/data.odin
@@ -9,19 +9,27 @@ import "core:strings"
// effective unwraps union variants and strips Named/Distinct layers,
// returning the "peeled" any value and its base type info.
effective :: proc(a: any) -> (val: any, info: ^runtime.Type_Info) {
+ if a == nil {
+ return
+ }
+
val = a
info = nil
- if val == nil do return
ti := type_info_of(val.id)
- if ti == nil do return
+ if ti == nil {
+ return
+ }
base := runtime.type_info_base(ti)
if _, ok := base.variant.(runtime.Type_Info_Union); ok {
variant := reflect.get_union_variant(val)
- if variant == nil do return {}, nil
- return effective(variant)
+ if variant == nil {
+ return {}, nil
+ } else {
+ return effective(variant)
+ }
}
info = base
@@ -31,35 +39,36 @@ effective :: proc(a: any) -> (val: any, info: ^runtime.Type_Info) {
// lookup_in resolves a single key in a container (struct or map).
// Returns found=false if the key doesn't exist or the container is not a struct/map.
lookup_in :: proc(container: any, key: string) -> (result: any, found: bool) {
- if container == nil do return nil, false
+ if container == nil {
+ return
+ }
val, info := effective(container)
- if info == nil do return nil, false
+ if info == nil {
+ return
+ }
#partial switch v in info.variant {
case runtime.Type_Info_Struct:
result = reflect.struct_field_value_by_name(val, key, allow_using = true)
found = result != nil
- return
-
case runtime.Type_Info_Map:
mi := v
rm_ptr := (^runtime.Raw_Map)(val.data)
- if rm_ptr.len == 0 do return nil, false
+ if rm_ptr.len == 0 {
+ return
+ }
k := key
seed := runtime.map_seed(rm_ptr^)
h := mi.map_info.key_hasher(&k, seed)
value_ptr := runtime.__dynamic_map_get(rm_ptr, mi.map_info, h, &k)
- if value_ptr == nil do return nil, false
-
- result = any{value_ptr, mi.value.id}
- found = true
- return
-
- case:
- return nil, false
+ if value_ptr != nil {
+ result = any{value_ptr, mi.value.id}
+ found = true
+ }
}
+
return
}
@@ -68,8 +77,11 @@ lookup_in :: proc(container: any, key: string) -> (result: any, found: bool) {
// resolve against the prior result only.
resolve_name :: proc(name: string, ctx: []any) -> any {
if name == "." {
- if len(ctx) > 0 do return ctx[len(ctx) - 1]
- return nil
+ if len(ctx) > 0 {
+ return ctx[len(ctx) - 1]
+ } else {
+ return nil
+ }
}
parts: [16]string
@@ -89,22 +101,33 @@ resolve_name :: proc(name: string, ctx: []any) -> any {
part_count += 1
}
- if part_count == 0 do return nil
+ if part_count == 0 {
+ return nil
+ }
+
dot_parts := parts[:part_count]
result: any = nil
found := false
for i := len(ctx) - 1; i >= 0; i -= 1 {
result, found = lookup_in(ctx[i], dot_parts[0])
- if found do break
+ if found {
+ break
+ }
}
- if !found do return nil
- if len(dot_parts) == 1 do return result
+ if !found {
+ return nil
+ }
+ if len(dot_parts) == 1 {
+ return result
+ }
for i := 1; i < len(dot_parts); i += 1 {
result, found = lookup_in(result, dot_parts[i])
- if !found do return nil
+ if !found {
+ return nil
+ }
}
return result
@@ -112,11 +135,17 @@ resolve_name :: proc(name: string, ctx: []any) -> any {
// is_truthy checks mustache truthiness.
is_truthy :: proc(a: any) -> bool {
- if a == nil do return false
+ if a == nil {
+ return false
+ }
val, info := effective(a)
- if info == nil do return false
- if reflect.is_nil(val) do return false
+ if info == nil {
+ return false
+ }
+ if reflect.is_nil(val) {
+ return false
+ }
#partial switch _ in info.variant {
case runtime.Type_Info_Slice, runtime.Type_Info_Dynamic_Array, runtime.Type_Info_Map:
@@ -130,7 +159,9 @@ is_truthy :: proc(a: any) -> bool {
// Returns elem_info=nil if the value is not a list.
list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: rawptr) {
val, info := effective(a)
- if info == nil do return nil, 0, nil
+ if info == nil {
+ return
+ }
#partial switch v in info.variant {
case runtime.Type_Info_Slice:
@@ -148,7 +179,9 @@ list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: r
// any_to_string converts a scalar value to a string using the temp allocator.
any_to_string :: proc(a: any) -> string {
- if a == nil do return ""
+ if a == nil {
+ return ""
+ }
val, _ := effective(a)
switch v in val {
@@ -174,14 +207,18 @@ format_f64 :: proc(v: f64) -> string {
buf: [64]byte
for prec in 1 ..= 17 {
s := strconv.write_float(buf[:], v, 'g', prec, 64)
- if len(s) > 0 && s[0] == '+' do s = s[1:]
+ if len(s) > 0 && s[0] == '+' {
+ s = s[1:]
+ }
parsed, ok := strconv.parse_f64(s)
if ok && parsed == v {
return strings.clone(s, context.temp_allocator)
}
}
s := strconv.write_float(buf[:], v, 'g', -1, 64)
- if len(s) > 0 && s[0] == '+' do s = s[1:]
+ if len(s) > 0 && s[0] == '+' {
+ s = s[1:]
+ }
return strings.clone(s, context.temp_allocator)
}
@@ -189,7 +226,9 @@ format_f64 :: proc(v: f64) -> string {
// optionally HTML-escaped.
write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
s := any_to_string(a)
- if len(s) == 0 do return
+ if len(s) == 0 {
+ return
+ }
if !escape {
strings.write_string(b, s)
@@ -200,7 +239,9 @@ write_value :: proc(b: ^strings.Builder, a: any, escape: bool) {
for i in 0 ..< len(s) {
switch s[i] {
case '&', '<', '>', '"':
- if i > start do strings.write_string(b, s[start:i])
+ if i > start {
+ strings.write_string(b, s[start:i])
+ }
switch s[i] {
case '&':
strings.write_string(b, "&")
diff --git a/mustache/mustache.odin b/mustache/mustache.odin
index a065847..9f4a956 100644
--- a/mustache/mustache.odin
+++ b/mustache/mustache.odin
@@ -50,8 +50,21 @@ Node :: struct {
child_count: int,
}
+// node_span returns the number of flat-array entries a node occupies:
+// 1 for leaf nodes, 1 + child_count for container nodes (whose children
+// are stored contiguously after them in the array).
+node_span :: proc(n: Node) -> int {
+ #partial switch n.kind {
+ case .Section, .Inverted, .Parent, .Block:
+ return 1 + n.child_count
+ case:
+ return 1
+ }
+}
+
Template :: struct {
- nodes: [dynamic]Node,
+ nodes: [dynamic]Node,
+ source: string,
}
Block_Override :: struct {
@@ -66,6 +79,13 @@ template_free :: proc(tmpl: ^Template) {
}
}
+delete_partials :: proc(partials: map[string]Template) {
+ for _, &p in partials {
+ template_free(&p)
+ }
+ delete(partials)
+}
+
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
@@ -86,8 +106,11 @@ parse :: proc(
tmpl.nodes, err = parse_tokens(tokens[:], allocator)
if err != nil {
delete(tmpl.nodes)
+ return {}, err
}
- return tmpl, err
+ tmpl.source = source
+ deindent_blocks(tmpl.nodes[:], 0, len(tmpl.nodes), allocator)
+ return tmpl, nil
}
render :: proc(
@@ -165,8 +188,7 @@ parse_section :: proc(
pos^ += 1
idx := len(nodes)
append(nodes, Node{kind = .Section, key = tok.value, first_child = -1})
- err := parse_section(tokens, pos, nodes, tok.value)
- if err != nil do return err
+ parse_section(tokens, pos, nodes, tok.value) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
@@ -174,8 +196,7 @@ parse_section :: proc(
pos^ += 1
idx := len(nodes)
append(nodes, Node{kind = .Inverted, key = tok.value, first_child = -1})
- err := parse_section(tokens, pos, nodes, tok.value)
- if err != nil do return err
+ parse_section(tokens, pos, nodes, tok.value) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
@@ -202,6 +223,7 @@ parse_section :: proc(
kind = .Partial,
key = tok.value,
is_dynamic = tok.is_dynamic,
+ indent = tok.indent,
first_child = -1,
},
)
@@ -210,18 +232,22 @@ parse_section :: proc(
case .Parent:
pos^ += 1
idx := len(nodes)
- append(nodes, Node{kind = .Parent, key = tok.value, first_child = -1})
- err := parse_section(tokens, pos, nodes, tok.value)
- if err != nil do return err
+ append(
+ nodes,
+ Node{kind = .Parent, key = tok.value, indent = tok.indent, first_child = -1},
+ )
+ parse_section(tokens, pos, nodes, tok.value) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
case .Block_Open:
pos^ += 1
idx := len(nodes)
- append(nodes, Node{kind = .Block, key = tok.value, first_child = -1})
- err := parse_section(tokens, pos, nodes, tok.value)
- if err != nil do return err
+ append(
+ nodes,
+ Node{kind = .Block, key = tok.value, indent = tok.indent, first_child = -1},
+ )
+ parse_section(tokens, pos, nodes, tok.value) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
}
@@ -233,6 +259,172 @@ parse_section :: proc(
return nil
}
+// ---------------------------------------------------------------------------
+// Post-parse: de-indent block content
+// ---------------------------------------------------------------------------
+
+deindent_blocks :: proc(all_nodes: []Node, start: int, end: int, allocator := context.allocator) {
+ i := start
+ for i < end {
+ #partial switch all_nodes[i].kind {
+ case .Block:
+ if all_nodes[i].child_count > 0 {
+ cs := all_nodes[i].first_child
+ ce := cs + all_nodes[i].child_count
+ deindent_blocks(all_nodes, cs, ce, allocator)
+
+ children := all_nodes[cs:ce]
+ common := find_common_indent(children)
+ if len(common) > 0 {
+ if len(all_nodes[i].indent) == 0 {
+ all_nodes[i].indent = common
+ }
+ for j := cs; j < ce; {
+ if all_nodes[j].kind == .Text && len(all_nodes[j].text) > 0 {
+ all_nodes[j].text = remove_line_indent(
+ all_nodes[j].text,
+ common,
+ allocator,
+ )
+ }
+ j += node_span(all_nodes[j])
+ }
+ }
+ }
+
+ case .Section, .Inverted, .Parent:
+ if all_nodes[i].child_count > 0 {
+ cs := all_nodes[i].first_child
+ ce := cs + all_nodes[i].child_count
+ deindent_blocks(all_nodes, cs, ce, allocator)
+ }
+ }
+ i += node_span(all_nodes[i])
+ }
+}
+
+find_common_indent :: proc(children: []Node) -> string {
+ common: string
+ found := false
+
+ i := 0
+ for i < len(children) {
+ if children[i].kind == .Text {
+ text := children[i].text
+ if len(text) > 0 {
+ line_start := 0
+ for j in 0 ..= len(text) {
+ if j == len(text) || text[j] == '\n' {
+ line := text[line_start:j]
+ if len(strings.trim_space(line)) > 0 {
+ ws := leading_whitespace(line)
+ if !found {
+ common = ws
+ found = true
+ } else if len(ws) < len(common) {
+ common = ws
+ }
+ }
+ line_start = j + 1
+ }
+ }
+ }
+ }
+ i += node_span(children[i])
+ }
+
+ if found {
+ return common
+ } else {
+ return ""
+ }
+}
+
+leading_whitespace :: proc(s: string) -> string {
+ for i in 0 ..< len(s) {
+ if s[i] != ' ' && s[i] != '\t' {
+ return s[:i]
+ }
+ }
+ return s
+}
+
+remove_line_indent :: proc(s: string, indent: string, allocator := context.allocator) -> string {
+ if len(indent) == 0 {
+ return s
+ }
+
+ buf := make([dynamic]u8, 0, len(s), allocator)
+ i := 0
+ at_line_start := true
+
+ for i < len(s) {
+ if at_line_start {
+ if i + len(indent) <= len(s) && s[i:i + len(indent)] == indent {
+ i += len(indent)
+ at_line_start = false
+ continue
+ }
+ at_line_start = false
+ }
+ append(&buf, s[i])
+ if s[i] == '\n' {
+ at_line_start = true
+ }
+ i += 1
+ }
+
+ return string(buf[:])
+}
+
+// ---------------------------------------------------------------------------
+// Indentation helpers
+// ---------------------------------------------------------------------------
+
+indent_lines :: proc(source: string, indent: string) -> string {
+ if len(indent) == 0 || len(source) == 0 {
+ return source
+ }
+ b: strings.Builder
+ strings.builder_init(&b, context.temp_allocator)
+ write_indented(&b, indent, source)
+ return strings.to_string(b)
+}
+
+write_indented :: proc(b: ^strings.Builder, indent: string, content: string) {
+ if len(indent) == 0 || len(content) == 0 {
+ strings.write_string(b, content)
+ return
+ }
+ at_line_start := true
+ for i in 0 ..< len(content) {
+ if at_line_start {
+ strings.write_string(b, indent)
+ at_line_start = false
+ }
+ strings.write_byte(b, content[i])
+ if content[i] == '\n' {
+ at_line_start = true
+ }
+ }
+}
+
+render_template :: proc(
+ pt: Template,
+ ctx: ^[dynamic]any,
+ partials: map[string]Template,
+ b: ^strings.Builder,
+ blocks: map[string]Block_Override,
+ indent: string,
+) -> Render_Error {
+ if len(indent) > 0 && len(pt.source) > 0 {
+ indented := indent_lines(pt.source, indent)
+ reparse := parse(indented, context.temp_allocator, context.temp_allocator) or_return
+ return render_nodes(reparse.nodes[:], reparse.nodes[:], ctx, partials, b, blocks)
+ }
+ return render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b, blocks)
+}
+
// ---------------------------------------------------------------------------
// Renderer — walk node array against context stack, write to builder
// ---------------------------------------------------------------------------
@@ -269,18 +461,16 @@ render_nodes :: proc(
children := all_nodes[node.first_child:node.first_child + node.child_count]
elem_info, count, data := list_info(val)
if elem_info != nil {
- for j in 0.. 0 {
+ temp: strings.Builder
+ strings.builder_init(&temp, context.temp_allocator)
+ render_nodes(
+ content_pool,
+ content_nodes,
+ ctx,
+ partials,
+ &temp,
+ content_blocks,
+ ) or_return
+ write_indented(b, node.indent, strings.to_string(temp))
+ } else {
+ render_nodes(
+ content_pool,
+ content_nodes,
+ ctx,
+ partials,
+ b,
+ content_blocks,
+ ) or_return
}
i += 1 + node.child_count
@@ -329,8 +542,7 @@ render_nodes :: proc(
merged := merge_block_overrides(parent_children, all_nodes, blocks)
pt, found := partials[node.key]
if found {
- err := render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b, merged)
- if err != nil do return err
+ render_template(pt, ctx, partials, b, merged, node.indent) or_return
}
i += 1 + node.child_count
}
@@ -354,16 +566,14 @@ merge_block_overrides :: proc(
child := children[i]
if child.kind == .Block {
if _, exists := result[child.key]; !exists {
- result[child.key] = Block_Override{
+ result[child.key] = Block_Override {
all_nodes = all_nodes,
- first = child.first_child,
- count = child.child_count,
+ first = child.first_child,
+ count = child.child_count,
}
}
- i += 1 + child.child_count
- } else {
- i += 1
}
+ i += node_span(child)
}
return result
diff --git a/mustache/mustache_test.odin b/mustache/mustache_test.odin
new file mode 100644
index 0000000..b174495
--- /dev/null
+++ b/mustache/mustache_test.odin
@@ -0,0 +1,121 @@
+#+test
+package mustache
+
+import "core:mem"
+import "core:testing"
+
+@(test)
+leak_parse_free :: proc(t: ^testing.T) {
+ tmpl, err := parse("Hello {{name}}!")
+ testing.expect(t, err == nil)
+ defer template_free(&tmpl)
+}
+
+@(test)
+leak_parse_free_tokens :: proc(t: ^testing.T) {
+ arena: mem.Dynamic_Arena
+ backing: [256]byte
+ mem.dynamic_arena_init(&arena)
+ defer mem.dynamic_arena_destroy(&arena)
+
+ tmpl, err := parse("Hello {{name}}!", context.allocator, mem.dynamic_arena_allocator(&arena))
+ testing.expect(t, err == nil)
+ defer template_free(&tmpl)
+}
+
+@(test)
+leak_parse_error :: proc(t: ^testing.T) {
+ tmpl, err := parse("Hello {{name")
+ testing.expect(t, err != nil)
+}
+
+@(test)
+leak_render :: proc(t: ^testing.T) {
+ Data :: struct {
+ name: string,
+ }
+ tmpl, err := parse("Hello {{name}}!")
+ testing.expect(t, err == nil)
+ defer template_free(&tmpl)
+
+ result, rerr := render(tmpl, Data{name = "World"})
+ testing.expect(t, rerr == nil)
+ defer delete(result)
+}
+
+@(test)
+leak_render_partials :: proc(t: ^testing.T) {
+ partials := make_map(map[string]Template)
+ defer {
+ for _, &p in partials {
+ template_free(&p)
+ }
+ delete(partials)
+ }
+
+ pt, perr := parse("world")
+ testing.expect(t, perr == nil)
+ partials["name"] = pt
+
+ tmpl, err := parse("Hello {{> name}}!")
+ testing.expect(t, err == nil)
+ defer template_free(&tmpl)
+
+ Data :: struct {
+ name: string,
+ }
+ result, rerr := render(tmpl, Data{name = "World"}, partials)
+ testing.expect(t, rerr == nil)
+ defer delete(result)
+}
+
+@(test)
+leak_render_sections :: proc(t: ^testing.T) {
+ tmpl, err := parse("{{#items}}{{.}}{{/items}}")
+ testing.expect(t, err == nil)
+ defer template_free(&tmpl)
+
+ Data :: struct {
+ items: [3]string,
+ }
+ result, rerr := render(tmpl, Data{items = {"a", "b", "c"}})
+ testing.expect(t, rerr == nil)
+ defer delete(result)
+}
+
+@(test)
+leak_render_inheritance :: proc(t: ^testing.T) {
+ partials := make_map(map[string]Template)
+ defer delete_partials(partials)
+
+ layout_src := "{{$title}}default{{/title}}"
+ layout, lerr := parse(layout_src)
+ testing.expect(t, lerr == nil)
+ partials["layout"] = layout
+
+ tmpl_src := "{{= 0 {
- text := tokens[left_idx].value
- nl := strings.last_index_byte(text, '\n')
- tokens[left_idx].value = text[:nl+1] if nl >= 0 else ""
+ for i := 0; i < n; i += 1 {
+ should_trim_whitespace(tokens[i].kind) or_continue
+
+ left_ok, li := check_left(tokens[:], i)
+ right_ok, ri := check_right(tokens[:], i)
+
+ if left_ok && right_ok {
+ li_buf[i] = li
+ ri_buf[i] = ri
+ }
+ }
+
+ // Second pass: apply trims left-to-right
+ // Track which text tokens have been trimmed to avoid double-trimming
+ left_done := make([dynamic]bool, n, context.temp_allocator)
+ defer delete(left_done)
+ right_done := make([dynamic]bool, n, context.temp_allocator)
+ defer delete(right_done)
+
+ for i := 0; i < n; i += 1 {
+ should_trim_whitespace(tokens[i].kind) or_continue
+ li := li_buf[i]
+ ri := ri_buf[i]
+ if li < 0 && ri < 0 {
+ continue
}
- if right_idx >= 0 {
- text := tokens[right_idx].value
- nl := strings.index_byte(text, '\n')
- tokens[right_idx].value = text[nl+1:] if nl >= 0 else ""
+ if li >= 0 && !left_done[li] {
+ left_done[li] = true
+ text := tokens[li].value
+ nl := strings.last_index_byte(text, '\n')
+ if tokens[i].kind == .Partial ||
+ tokens[i].kind == .Parent ||
+ tokens[i].kind == .Block_Open {
+ tokens[i].indent = text[nl + 1:] if nl >= 0 else text
+ }
+ tokens[li].value = text[:nl + 1] if nl >= 0 else ""
+ }
+
+ if ri >= 0 && !right_done[ri] {
+ right_done[ri] = true
+ // When a Block_Open and its Section_Close are adjacent (e.g.
+ // {{$block}}{{/block}}\n), the close tag is the one that
+ // should consume the trailing newline — not the block open.
+ // Skip the right-trim if a Section_Close sits between this
+ // tag and its right text, or if this IS the Section_Close
+ // immediately following a Block_Open (the close handles it).
+ skip := false
+ if tokens[i].kind == .Block_Open {
+ for k := i + 1; k < ri; k += 1 {
+ if tokens[k].kind == .Section_Close {
+ skip = true
+ break
+ }
+ }
+ }
+ if tokens[i].kind == .Section_Close && i > 0 && tokens[i - 1].kind == .Block_Open {
+ skip = true
+ }
+ if !skip {
+ text := tokens[ri].value
+ nl := strings.index_byte(text, '\n')
+ tokens[ri].value = text[nl + 1:] if nl >= 0 else ""
+ }
}
}
}
@@ -174,7 +234,7 @@ check_left :: proc(tokens: []Token, i: int) -> (ok: bool, text_idx: int) {
}
nl := strings.last_index_byte(text, '\n')
if nl >= 0 {
- return strings.trim_space(text[nl+1:]) == "", j
+ return strings.trim_space(text[nl + 1:]) == "", j
}
if j == 0 {
return strings.trim_space(text) == "", j
diff --git a/mustache_test.odin b/mustache_test.odin
deleted file mode 100644
index 49f3453..0000000
--- a/mustache_test.odin
+++ /dev/null
@@ -1,111 +0,0 @@
-#+feature dynamic-literals
-#+test
-package main
-
-import "core:testing"
-import "mustache"
-
-@(test)
-test_simple_substitution :: proc(t: ^testing.T) {
- data := map[string]string {
- "name" = "World",
- }
- tpl, _ := mustache.parse("Hello, {{name}}!")
- result, err := mustache.render(tpl, data)
- testing.expect(t, err == nil)
- testing.expect(t, result == "Hello, World!")
-}
-
-@(test)
-test_bool_section :: proc(t: ^testing.T) {
- data := map[string]bool {
- "show" = true,
- }
- tpl, _ := mustache.parse("{{#show}}visible{{/show}}")
- result, err := mustache.render(tpl, data)
- testing.expect(t, err == nil)
- testing.expect(t, result == "visible")
-}
-
-@(test)
-test_inverted_section :: proc(t: ^testing.T) {
- data := map[string]bool {
- "show" = false,
- }
- tpl, _ := mustache.parse("{{^show}}hidden{{/show}}")
- result, err := mustache.render(tpl, data)
- testing.expect(t, err == nil)
- testing.expect(t, result == "hidden")
-}
-
-@(test)
-test_unescaped :: proc(t: ^testing.T) {
- data := map[string]string {
- "html" = "bold",
- }
- tpl, _ := mustache.parse("{{{html}}}")
- result, err := mustache.render(tpl, data)
- testing.expect(t, err == nil)
- testing.expect(t, result == "bold")
-}
-
-@(test)
-test_array_iteration :: proc(t: ^testing.T) {
- items := make([dynamic]map[string]string)
- defer delete(items)
- append(&items, map[string]string{"name" = "Alice"})
- append(&items, map[string]string{"name" = "Bob"})
-
- data := map[string][dynamic]map[string]string {
- "items" = items,
- }
- tpl, _ := mustache.parse("{{#items}}{{name}} {{/items}}")
- result, err := mustache.render(tpl, data)
- testing.expect(t, err == nil)
- testing.expect(t, result == "Alice Bob ")
-}
-
-@(test)
-test_partial :: proc(t: ^testing.T) {
- data := map[string]string {
- "name" = "Test",
- }
- greeting_tpl, _ := mustache.parse("Hello, {{name}}!")
- partials := map[string]mustache.Template {
- "greeting" = greeting_tpl,
- }
- main_tpl, _ := mustache.parse("{{>greeting}}")
- result, err := mustache.render(main_tpl, data, partials)
- testing.expect(t, err == nil)
- testing.expect(t, result == "Hello, Test!")
-}
-
-@(test)
-test_mixed_types :: proc(t: ^testing.T) {
- data := map[string]any {
- "site_title" = "One Idiot Developer",
- "has_date" = true,
- "date" = "17 Jul 2025",
- }
-
- tpl, _ := mustache.parse("{{site_title}}: {{#has_date}}{{date}}{{/has_date}}")
- result, err := mustache.render(tpl, data)
- testing.expect(t, err == nil)
- testing.expect(t, result == "One Idiot Developer: 17 Jul 2025")
-}
-
-@(test)
-test_nested_context :: proc(t: ^testing.T) {
- page := map[string]string {
- "title" = "My Post",
- }
- data := map[string]any {
- "site_title" = "One Idiot Developer",
- "page" = page,
- }
-
- tpl, _ := mustache.parse("{{site_title}}: {{#page}}{{title}}{{/page}}")
- result, err := mustache.render(tpl, data)
- testing.expect(t, err == nil)
- testing.expect(t, result == "One Idiot Developer: My Post")
-}