fix(mustache): Got whitespace management to pass the tests.

This commit is contained in:
Spencer Brower
2026-07-16 11:06:17 -04:00
parent 1f44adcf33
commit 7e93c3468f
9 changed files with 559 additions and 346 deletions
+32 -9
View File
@@ -28,7 +28,7 @@ public/ ← build output (generated)
| `sectionate.odin` | `wrap_sections` proc — splits HTML at `<h2` into `<section>` wrappers |
| `render.odin` | Mustache template rendering, all page types, RSS, sitemap, robots.txt, `load_partials` recursive scan |
| `feed.odin` | RSS feed + sitemap XML generation |
| `mustache/` | Vendored [odin-mustache](https://github.com/benjamindblock/odin-mustache) library |
| `mustache/` | Mustache template engine (spec-compliant, replaces vendored odin-mustache) |
Icon SVGs live as HTML partials in `layouts/partials/icons/` (home, github, rss, chevron_up, star).
@@ -131,19 +131,39 @@ odin test . # site + mustache smoke tests
odin test mustache # mustache spec + targeted tests (37 total)
```
## Vendored mustache patches
## Mustache engine
Four modifications to `mustache/mustache.odin`:
Spec-compliant Mustache implementation at `mustache/`. Passes all 170 tests across 7 official spec files (interpolation, sections, inverted, comments, partials, dynamic-names, inheritance).
1. **`any` unwrapping** in `map_get` and `data_type` — when values come from `map[string]any`, the inner `any` wrapper is unwrapped so type detection works correctly for nested maps and lists.
### Files
2. **Layout partials**`layout_template.partials = tmpl.partials` added so partials (`{{> nav}}`, `{{> footer}}`) work inside the base layout template.
| File | Responsibility |
|---|---|
| `mustache.odin` | Public API (`parse`, `render`, `Template`), parser (`parse_section`), post-parse de-indent (`deindent_blocks`), renderer (`render_nodes`), indent helpers |
| `tokenizer.odin` | Tokenizer (template string → `[]Token`), two-pass standalone whitespace detection (`trim_standalone_whitespace`) |
| `data.odin` | Reflection-based data model: `effective` (union/distinct peeling), `lookup_in`, `resolve_name`, `is_truthy`, `any_to_string`, `list_info`, `write_value`, `format_f64` |
| `spec_test.odin` | JSON spec test runner — loads `spec/specs/*.json`, runs each test case |
3. **Inline partial rendering** — replaced `template_insert_partial` (which injected tokens into the main token list, breaking section iteration) with `template_render_partial` (which lexes the partial, temporarily swaps `tmpl.lexer`, and recursively processes via `template_process_tokens`). Fixes partials inside sections. Standalone indentation applied to partial source before lexing.
### Architecture
4. **Dynamic Names**`{{>*key}}` support. When a partial token value starts with `*`, the remaining key is resolved from the data context stack, and the resolved string is used as the partial name. Enables per-item partial selection inside sections (e.g., `{{>* icon}}` resolves `icon` from each social link).
```
parse(source) → tokenize → trim_standalone_whitespace → parse_section → deindent_blocks → Template
render(tmpl, data, partials) → render_nodes (walks flat node array against context stack) → string
```
Extracted `template_process_tokens` from `template_eat_tokens` to separate ROOT initialization + skip pass from the core token loop, allowing partials to reuse the loop.
- **Flat `[dynamic]Node` array** with `first_child`/`child_count` indices — one allocation, one `delete`. Pre-order layout: children stored contiguously after their parent.
- **`render_nodes`** takes `all_nodes` (full array, for absolute child access) + `nodes` (current slice). Index-based loop, skips children after sections/blocks via `i += 1 + child_count`.
- **Context stack**: `^[dynamic]any` with `append`/`pop` for section push/pop.
- **`effective(a)`** peels Named/Distinct/Union layers (including `json.Value`) so all downstream operations can switch on base `Type_Info` variant directly.
- **Two-pass standalone detection**: detect using original token values, then trim left-to-right with `left_done`/`right_done` tracking to prevent double-trims. Cascading `check_left`/`check_right` skip adjacent standalone-eligible tags.
- **Block indent**: `deindent_blocks` runs post-parse — finds common indent of direct text children, sets block's intrinsic indent if empty, removes common indent. Renderer applies block indent at output level via `write_indented`.
- **Partial/parent indent**: `Template` stores `source` for indent re-parse. `render_template` calls `indent_lines(source, indent)` then re-parses with temp allocator when indent is non-empty.
- **Block overrides** (`Block_Override` struct): carries `all_nodes` + child range so override content from different templates renders correctly. `merge_block_overrides` propagates overrides through multi-level inheritance chains.
### Not implemented
- Lambdas (`~lambdas.json`)
- Set delimiters (`{{= =}}`, `delimiters.json`)
## Known limitations
@@ -152,12 +172,15 @@ Extracted `template_process_tokens` from `template_eat_tokens` to separate ROOT
- `json.Value` params require 64-byte aligned arena (workaround for `dynamic_arena_allocator_proc` ignoring per-allocation alignment).
- Tree-sitter grammar/query paths hardcoded in `tree_sitter.odin` (Nix store hashes, Helix-version-dependent).
- `TSQueryCapture` needs explicit `_padding: u32` field for C ABI compatibility (40-byte sizeof).
- `{{&content}}` in layout template must have no leading whitespace — `template_insert_content_into_layout` indents all lines by preceding whitespace, which corrupts `<pre>` 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
-137
View File
@@ -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 `{{<include}}{{/include}}\n`, neither tag is individually detected as standalone. The current check looks at the immediately adjacent token — if it's another tag (not text), the check fails. So the trailing `\n` isn't consumed.
**Affected spec tests:**
- `~inheritance.json`: "Inherit", "Override parent with newlines"
- Potentially any `{{<name}}{{/name}}` or `{{#name}}{{/name}}` on its own line
**Solution:** When scanning left/right for the line boundary, skip over adjacent standalone-eligible tags (they're transparent). Two helper procs replace the current inline checks:
- `check_left(tokens, i)` — scans backwards through adjacent eligible tags until finding text or start of template
- `check_right(tokens, i)` — scans forwards through adjacent eligible tags until finding text or end of template
Both return `(ok: bool, text_idx: int)` where `text_idx` is the text token to trim (or -1 if none).
+5 -2
View File
@@ -1,3 +1,6 @@
- [ ] Initialize string builder size based on filesize?
- [ ] Review [whitespace handling code](./tokenizer.odin)
- [ ] Add leak checks for public procs.
- [x] Review [whitespace handling code](./tokenizer.odin)
- [x] Add leak checks for public procs.
- [ ] Simplify `trim_standalone_whitespace` — four temp arrays (`li_buf`, `ri_buf`, `left_done`, `right_done`) could potentially be reduced to two by merging the done-tracking into the index buffers using sentinel values (e.g. `-2` = already trimmed).
- [ ] Adjacent standalone tags don't capture indent for the second tag (e.g. ` {{>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)
+70 -29
View File
@@ -9,20 +9,28 @@ 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
if variant == nil {
return {}, nil
} else {
return effective(variant)
}
}
info = base
return
@@ -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
if value_ptr != nil {
result = any{value_ptr, mi.value.id}
found = true
return
case:
return nil, false
}
}
return
}
@@ -68,9 +77,12 @@ 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]
if len(ctx) > 0 {
return ctx[len(ctx) - 1]
} else {
return nil
}
}
parts: [16]string
part_count := 0
@@ -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, "&amp;")
+247 -37
View File
@@ -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,
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..<count {
for j in 0 ..< count {
elem_ptr := rawptr(uintptr(data) + uintptr(j) * uintptr(elem_info.size))
append(ctx, any{elem_ptr, elem_info.id})
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
pop(ctx)
if err != nil do return err
defer pop(ctx)
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
}
} else {
append(ctx, val)
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
pop(ctx)
if err != nil do return err
defer pop(ctx)
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
}
}
i += 1 + node.child_count
@@ -289,8 +479,7 @@ render_nodes :: proc(
val := resolve_name(node.key, ctx[:])
if !is_truthy(val) {
children := all_nodes[node.first_child:node.first_child + node.child_count]
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
if err != nil do return err
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
}
i += 1 + node.child_count
@@ -302,25 +491,49 @@ render_nodes :: proc(
}
pt, found := partials[name]
if found {
err := render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b)
if err != nil do return err
render_template(pt, ctx, partials, b, nil, node.indent) or_return
}
i += 1
case .Block:
rendered_override := false
content_nodes: []Node
content_pool: []Node
content_blocks := blocks
found_override := false
if blocks != nil {
if o, ok := blocks[node.key]; ok {
children := o.all_nodes[o.first:o.first + o.count]
err := render_nodes(o.all_nodes, children, ctx, partials, b)
if err != nil do return err
rendered_override = true
content_nodes = o.all_nodes[o.first:o.first + o.count]
content_pool = o.all_nodes
found_override = true
}
}
if !rendered_override {
children := all_nodes[node.first_child:node.first_child + node.child_count]
err := render_nodes(all_nodes, children, ctx, partials, b, blocks)
if err != nil do return err
if !found_override {
content_nodes = all_nodes[node.first_child:node.first_child + node.child_count]
content_pool = all_nodes
}
if len(node.indent) > 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,
}
}
i += 1 + child.child_count
} else {
i += 1
}
i += node_span(child)
}
return result
+121
View File
@@ -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 := "{{<layout}}{{$title}}custom{{/title}}{{/layout}}"
tmpl, err := parse(tmpl_src)
testing.expect(t, err == nil)
defer template_free(&tmpl)
result, rerr := render(tmpl, {}, partials)
testing.expect(t, rerr == nil)
defer delete(result)
}
@(test)
leak_repeated_render :: proc(t: ^testing.T) {
tmpl, err := parse("Hello {{name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
Data :: struct {
name: string,
}
for _ in 0 ..< 3 {
result, rerr := render(tmpl, Data{name = "World"})
testing.expect(t, rerr == nil)
defer delete(result)
}
}
+3
View File
@@ -4,9 +4,12 @@ package mustache
import "core:encoding/json"
import "core:fmt"
import "core:os"
import "core:path/filepath"
import "core:testing"
run_spec_file :: proc(t: ^testing.T, path: string) {
dirs := [?]string{#directory, path}
path, _ := filepath.join(dirs[:], context.temp_allocator)
raw_data, err := os.read_entire_file(path, context.allocator)
if err != nil {
testing.expectf(t, false, "Failed to read %s", path)
+74 -14
View File
@@ -19,6 +19,7 @@ Token :: struct {
kind: Token_Kind,
value: string,
is_dynamic: bool,
indent: string,
pos: int,
}
@@ -141,24 +142,83 @@ tokenize :: proc(
// ---------------------------------------------------------------------------
trim_standalone_whitespace :: proc(tokens: ^[dynamic]Token) {
for i := 0; i < len(tokens); i += 1 {
if !should_trim_whitespace(tokens[i].kind) do continue
n := len(tokens)
left_ok, left_idx := check_left(tokens[:], i)
right_ok, right_idx := check_right(tokens[:], i)
// First pass: detect standalone status using original token values
li_buf := make([dynamic]int, n, context.temp_allocator)
defer delete(li_buf)
ri_buf := make([dynamic]int, n, context.temp_allocator)
defer delete(ri_buf)
if !left_ok || !right_ok do continue
if left_idx >= 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 {
li_buf[i] = -1
ri_buf[i] = -1
}
if right_idx >= 0 {
text := tokens[right_idx].value
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 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[right_idx].value = text[nl+1:] if nl >= 0 else ""
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
-111
View File
@@ -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" = "<b>bold</b>",
}
tpl, _ := mustache.parse("{{{html}}}")
result, err := mustache.render(tpl, data)
testing.expect(t, err == nil)
testing.expect(t, result == "<b>bold</b>")
}
@(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")
}