feat(mustache): Added group_by filter to templates

This commit is contained in:
Spencer Brower
2026-07-20 11:05:48 -04:00
parent 29d244c1a6
commit 770a04481d
11 changed files with 556 additions and 193 deletions
+73
View File
@@ -0,0 +1,73 @@
# Mustache Extensions
Thor's mustache engine implements the [Mustache spec](https://github.com/mustache/spec) plus the following non-standard extensions. Extensions are opt-in via template syntax — vanilla mustache templates render identically to the spec.
## Pipes
A pipe expression appears inside a section tag and transforms the resolved value before iteration. Pipes let templates request different views of the same data without privileged Go-side support.
### Syntax
```
{{#<key> | <op> <arg> <arg>... | <op> <arg>... }} … {{/<key>}}
```
- The first whitespace-separated token after `|` is the op name; remaining tokens are its arguments.
- Whitespace around `|` is optional: `posts|group_by year` and `posts | group_by year` are equivalent.
- Multiple filters compose left-to-right.
- **At most 8 filters** may appear in a single tag (compile-time constant `MAX_PIPES` in `pipes.odin`). Exceeding it is a parse error.
- **At most 2 args** per filter (compile-time constant `MAX_PIPE_ARGS`). Exceeding it is a parse error.
- The close tag is the **bare key only**. Pipe expressions are not allowed in close tags — `{{/posts | group_by year}}` is a parse error.
- Pipes are only supported in section tags (`{{#…}}` and `{{^…}}`) today. Variable interpolation (`{{x | op}}`) is a parse error.
### Example
```handlebars
{{#posts | group_by year}}
<section>
<h2>{{key}}</h2>
<ul>{{#items}}<li>{{title}}</li>{{/items}}</ul>
</section>
{{/posts}}
```
### Available ops
#### `group_by <field>`
Buckets each element of a list by the value of `<field>`. Returns a list of `Group` values, each shaped as:
```
Group :: struct {
key: string, // the distinct field value
items: [dynamic]any, // elements sharing that value
}
```
Group order preserves first-appearance order in the input list (not key-sorted).
Errors (returned as `Data_Error` at render time):
- Argument count is not 1.
- Input is not a list.
- Any element is missing the named field.
- Any element has an empty value for the named field.
### Memory ownership
- **Parsed pipe filters** (`Pipe_Filter` values) are stored inline on each `Node` via `[dynamic; MAX_PIPES]Pipe_Filter`, and `args` is inline on each `Pipe_Filter` via `[dynamic; MAX_PIPE_ARGS]string`. Both use Odin's fixed-capacity dynamic array type, so no per-tag heap allocations occur at parse time. The storage dies with the `Template` when `delete_template` is called.
- **Filter results** (e.g. the `[dynamic]Group` returned by `group_by`) are render-scoped allocations in `context.temp_allocator`. They die with the render call. No caller-side cleanup is needed.
- The string data inside `Pipe_Filter` (op names, args) is borrowed from the template source — no cloning.
### Future ops (not yet implemented)
The pipe framework is general; additional ops are straightforward to add to `apply_filter` in `pipes.odin`:
- `sort`, `sort_by <field>` — ordering
- `filter <field> <value>`, `where <field>` — selection
- `take <n>`, `take_last <n>`, `skip <n>` — slicing
- `reverse` — order flip
To add a new op:
1. Implement `apply_<op>(value: any, args: []string) -> (any, Render_Error)` in `pipes.odin`.
2. Add a `case` to `apply_filter`.
3. Add tests to `pipes_test.odin`.
+16 -3
View File
@@ -174,11 +174,11 @@ call_interp_lambda :: proc(val: any) -> (result: string, ok: bool) {
call_section_lambda :: proc(val: any, text: string) -> (result: string, ok: bool) {
switch v in val {
case proc(string) -> string:
case proc(_: string) -> string:
return v(text), true
case proc(string) -> int:
case proc(_: string) -> int:
return fmt.tprintf("%d", v(text)), true
case proc(string) -> bool:
case proc(_: string) -> bool:
return "true" if v(text) else "false", true
case:
return "", false
@@ -207,6 +207,19 @@ list_info :: proc(a: any) -> (elem_info: ^runtime.Type_Info, count: int, data: r
}
}
// extract_list_element returns the n-th element of a list (described by
// elem_info/data from list_info) as a ready-to-use any.
//
// Unwraps [dynamic]any element types so downstream lookup_in sees the inner
// value's real type, not a double-wrapped any-of-any.
extract_list_element :: proc(elem_info: ^runtime.Type_Info, data: rawptr, n: int) -> any {
elem_ptr := rawptr(uintptr(data) + uintptr(n) * uintptr(elem_info.size))
if _, is_any := elem_info.variant.(runtime.Type_Info_Any); is_any {
return (^any)(elem_ptr)^
}
return any{elem_ptr, elem_info.id}
}
// any_to_string converts a scalar value to a string using the temp allocator.
any_to_string :: proc(a: any) -> string {
if a == nil {
+80 -18
View File
@@ -44,6 +44,7 @@ Node :: struct {
kind: Node_Kind,
text: string,
key: string,
filters: [dynamic; MAX_PIPES]Pipe_Filter,
is_dynamic: bool,
indent: string,
first_child: int,
@@ -74,7 +75,7 @@ Block_Override :: struct {
count: int,
}
template_free :: proc(tmpl: ^Template) {
delete_template :: proc(tmpl: ^Template) {
if tmpl != nil && len(tmpl.nodes) > 0 {
delete(tmpl.nodes)
}
@@ -82,7 +83,7 @@ template_free :: proc(tmpl: ^Template) {
delete_partials :: proc(partials: map[string]Template) {
for _, &p in partials {
template_free(&p)
delete_template(&p)
}
delete(partials)
}
@@ -157,7 +158,7 @@ parse_tokens :: proc(
) {
nodes = make([dynamic]Node, 0, len(tokens), allocator)
pos := 0
err = parse_section(tokens, &pos, &nodes, "", source)
err = parse_section(tokens, &pos, &nodes, "", source, allocator)
return
}
@@ -167,6 +168,7 @@ parse_section :: proc(
nodes: ^[dynamic]Node,
end_tag: string,
source: string,
allocator := context.allocator,
) -> Render_Error {
for pos^ < len(tokens) {
tok := tokens[pos^]
@@ -191,11 +193,19 @@ parse_section :: proc(
pos^ += 1
idx := len(nodes)
content_start := 0
if pos^ < len(tokens) { content_start = tokens[pos^].pos }
append(nodes, Node{kind = .Section, key = tok.value, first_child = -1})
parse_section(tokens, pos, nodes, tok.value, source) or_return
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
append(nodes, Node{kind = .Section, first_child = -1})
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters)
if perr != nil {
return Syntax_Error {
msg = fmt.tprintf("pipe parse error in '{{#%s}}': %v", tok.value, perr),
pos = tok.pos,
}
}
nodes[idx].key = pipe_key
parse_section(tokens, pos, nodes, pipe_key, source, allocator) or_return
close_pos := 0
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) { close_pos = tokens[pos^ - 1].pos }
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) {close_pos = tokens[pos^ - 1].pos}
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
nodes[idx].content = source[content_start:close_pos]
@@ -204,16 +214,33 @@ parse_section :: proc(
pos^ += 1
idx := len(nodes)
content_start := 0
if pos^ < len(tokens) { content_start = tokens[pos^].pos }
append(nodes, Node{kind = .Inverted, key = tok.value, first_child = -1})
parse_section(tokens, pos, nodes, tok.value, source) or_return
if pos^ < len(tokens) {content_start = tokens[pos^].pos}
append(nodes, Node{kind = .Inverted, first_child = -1})
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters)
if perr != nil {
return Syntax_Error {
msg = fmt.tprintf("pipe parse error in '{{^%s}}': %v", tok.value, perr),
pos = tok.pos,
}
}
nodes[idx].key = pipe_key
parse_section(tokens, pos, nodes, pipe_key, source, allocator) or_return
close_pos := 0
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) { close_pos = tokens[pos^ - 1].pos }
if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) {close_pos = tokens[pos^ - 1].pos}
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
nodes[idx].content = source[content_start:close_pos]
case .Section_Close:
if strings.contains(tok.value, "|") {
return Syntax_Error {
msg = fmt.tprintf(
"pipe expression not allowed in close tag '{{/%s}}' — use the bare key",
tok.value,
),
pos = tok.pos,
}
}
if end_tag != "" && tok.value == end_tag {
pos^ += 1
return nil
@@ -249,7 +276,7 @@ parse_section :: proc(
nodes,
Node{kind = .Parent, key = tok.value, indent = tok.indent, first_child = -1},
)
parse_section(tokens, pos, nodes, tok.value, source) or_return
parse_section(tokens, pos, nodes, tok.value, source, allocator) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
@@ -260,7 +287,7 @@ parse_section :: proc(
nodes,
Node{kind = .Block, key = tok.value, indent = tok.indent, first_child = -1},
)
parse_section(tokens, pos, nodes, tok.value, source) or_return
parse_section(tokens, pos, nodes, tok.value, source, allocator) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
}
@@ -465,7 +492,14 @@ render_nodes :: proc(
if perr == nil {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(sub_tpl.nodes[:], sub_tpl.nodes[:], ctx, partials, &temp, blocks) or_return
render_nodes(
sub_tpl.nodes[:],
sub_tpl.nodes[:],
ctx,
partials,
&temp,
blocks,
) or_return
write_value(b, strings.to_string(temp), escape = true)
}
} else {
@@ -480,7 +514,14 @@ render_nodes :: proc(
if perr == nil {
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(sub_tpl.nodes[:], sub_tpl.nodes[:], ctx, partials, &temp, blocks) or_return
render_nodes(
sub_tpl.nodes[:],
sub_tpl.nodes[:],
ctx,
partials,
&temp,
blocks,
) or_return
write_value(b, strings.to_string(temp), escape = false)
}
} else {
@@ -490,18 +531,32 @@ render_nodes :: proc(
case .Section:
val := resolve_name(node.key, ctx[:])
if len(node.filters) > 0 {
transformed, perr := apply_pipeline(val, node.filters[:])
if perr != nil {
return perr
}
val = transformed
}
if result_str, ok := call_section_lambda(val, node.content); ok {
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
if perr == nil {
render_nodes(sub_tpl.nodes[:], sub_tpl.nodes[:], ctx, partials, b, blocks) or_return
render_nodes(
sub_tpl.nodes[:],
sub_tpl.nodes[:],
ctx,
partials,
b,
blocks,
) or_return
}
} else if is_truthy(val) {
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 {
elem_ptr := rawptr(uintptr(data) + uintptr(j) * uintptr(elem_info.size))
append(ctx, any{elem_ptr, elem_info.id})
elem := extract_list_element(elem_info, data, j)
append(ctx, elem)
defer pop(ctx)
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
}
@@ -515,6 +570,13 @@ render_nodes :: proc(
case .Inverted:
val := resolve_name(node.key, ctx[:])
if len(node.filters) > 0 {
transformed, perr := apply_pipeline(val, node.filters[:])
if perr != nil {
return perr
}
val = transformed
}
if !is_truthy(val) {
children := all_nodes[node.first_child:node.first_child + node.child_count]
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
+8 -8
View File
@@ -8,7 +8,7 @@ import "core:testing"
leak_parse_free :: proc(t: ^testing.T) {
tmpl, err := parse("Hello {{name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
}
@(test)
@@ -20,7 +20,7 @@ leak_parse_free_tokens :: proc(t: ^testing.T) {
tmpl, err := parse("Hello {{name}}!", context.allocator, mem.dynamic_arena_allocator(&arena))
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
}
@(test)
@@ -36,7 +36,7 @@ leak_render :: proc(t: ^testing.T) {
}
tmpl, err := parse("Hello {{name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
result, rerr := render(tmpl, Data{name = "World"})
testing.expect(t, rerr == nil)
@@ -48,7 +48,7 @@ leak_render_partials :: proc(t: ^testing.T) {
partials := make_map(map[string]Template)
defer {
for _, &p in partials {
template_free(&p)
delete_template(&p)
}
delete(partials)
}
@@ -59,7 +59,7 @@ leak_render_partials :: proc(t: ^testing.T) {
tmpl, err := parse("Hello {{> name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
Data :: struct {
name: string,
@@ -73,7 +73,7 @@ leak_render_partials :: proc(t: ^testing.T) {
leak_render_sections :: proc(t: ^testing.T) {
tmpl, err := parse("{{#items}}{{.}}{{/items}}")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
Data :: struct {
items: [3]string,
@@ -96,7 +96,7 @@ leak_render_inheritance :: proc(t: ^testing.T) {
tmpl_src := "{{<layout}}{{$title}}custom{{/title}}{{/layout}}"
tmpl, err := parse(tmpl_src)
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
result, rerr := render(tmpl, {}, partials)
testing.expect(t, rerr == nil)
@@ -107,7 +107,7 @@ leak_render_inheritance :: proc(t: ^testing.T) {
leak_repeated_render :: proc(t: ^testing.T) {
tmpl, err := parse("Hello {{name}}!")
testing.expect(t, err == nil)
defer template_free(&tmpl)
defer delete_template(&tmpl)
Data :: struct {
name: string,
+156
View File
@@ -0,0 +1,156 @@
package mustache
import "core:fmt"
import "core:strings"
// MAX_PIPES was chosen arbitrarily. It holds no performance or logical
// significance.
MAX_PIPES :: 8
// No filter accepts more than 2 args.
MAX_PIPE_ARGS :: 2
Pipe_Filter :: struct {
op: string,
args: [dynamic; MAX_PIPE_ARGS]string,
}
Group :: struct {
key: string,
items: [dynamic]any,
}
// Returned strings are slices into content — no cloning, lifetime bound to
// the caller's source.
parse_pipeline :: proc(
content: string,
filters_out: ^[dynamic; MAX_PIPES]Pipe_Filter,
) -> (
key: string,
err: Render_Error,
) {
if !strings.contains(content, "|") {
key = strings.trim_space(content)
return key, nil
}
segments := strings.split(content, "|", allocator = context.temp_allocator)
filter_count := len(segments) - 1
if filter_count > MAX_PIPES {
return "", Syntax_Error {
msg = fmt.tprintf("pipe expression has %d filters, max is %d", filter_count, MAX_PIPES),
}
}
key = strings.trim_space(segments[0])
if len(key) == 0 {
return "", Syntax_Error{msg = "pipe expression missing key"}
}
if filter_count == 0 {
return key, nil
}
for i in 0 ..< filter_count {
seg := strings.trim_space(segments[i + 1])
if len(seg) == 0 {
return "", Syntax_Error{msg = "empty filter"}
}
tokens := strings.fields(seg)
if len(tokens) == 0 {
return "", Syntax_Error{msg = "filter missing op name"}
}
arg_count := len(tokens) - 1
if arg_count > MAX_PIPE_ARGS {
return "", Syntax_Error {
msg = fmt.tprintf(
"filter '%s' has %d args, max is %d",
tokens[0],
arg_count,
MAX_PIPE_ARGS,
),
}
}
filter := Pipe_Filter {
op = tokens[0],
}
for j in 1 ..< len(tokens) {
append(&filter.args, tokens[j])
}
append(filters_out, filter)
delete(tokens)
}
return key, nil
}
apply_pipeline :: proc(value: any, filters: []Pipe_Filter) -> (any, Render_Error) {
current := value
for &filter in filters {
result, err := apply_filter(current, &filter)
if err != nil {
return nil, err
}
current = result
}
return current, nil
}
apply_filter :: proc(value: any, filter: ^Pipe_Filter) -> (any, Render_Error) {
switch filter.op {
case "group_by":
return apply_group_by(value, filter.args[:])
case:
return nil, Data_Error{msg = fmt.tprintf("unknown pipe op '%s'", filter.op)}
}
}
// Groups preserve first-appearance order from the input list.
apply_group_by :: proc(value: any, args: []string) -> (result: any, err: Render_Error) {
if len(args) != 1 {
return nil, Data_Error{msg = fmt.tprintf("group_by expects 1 argument, got %d", len(args))}
}
field := args[0]
elem_info, count, data := list_info(value)
if elem_info == nil {
return nil, Data_Error{msg = "group_by expects a list"}
}
groups := make([dynamic]Group, 0, 8, context.temp_allocator)
key_to_idx := make(map[string]int, context.temp_allocator)
defer delete(key_to_idx)
for j in 0 ..< count {
elem_ptr := rawptr(uintptr(data) + uintptr(j) * uintptr(elem_info.size))
elem := any{elem_ptr, elem_info.id}
key_val, found := lookup_in(elem, field)
if !found {
return nil, Data_Error {
msg = fmt.tprintf("group_by: element missing field '%s'", field),
}
}
key_str := any_to_string(key_val)
if len(key_str) == 0 {
return nil, Data_Error{msg = fmt.tprintf("group_by: field '%s' is empty", field)}
}
idx, exists := key_to_idx[key_str]
if !exists {
idx = len(groups)
key_to_idx[key_str] = idx
append(
&groups,
Group{key = key_str, items = make([dynamic]any, 0, 4, context.temp_allocator)},
)
}
append(&groups[idx].items, elem)
}
return groups, nil
}
+206
View File
@@ -0,0 +1,206 @@
#+test
package mustache
import "core:fmt"
import "core:testing"
Pipe_Post :: struct {
title: string,
year: string,
}
Pipe_Data :: struct {
posts: []Pipe_Post,
}
make_data :: proc(posts: ..Pipe_Post) -> Pipe_Data {
d: Pipe_Data
d.posts = posts
return d
}
@(test)
test_pipe_group_by_basic :: proc(t: ^testing.T) {
data := make_data(
{title = "A", year = "2024"},
{title = "B", year = "2024"},
{title = "C", year = "2023"},
)
tpl, _ := parse("{{#posts | group_by year}}{{key}}:{{#items}}{{title}},{{/items}};{{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "2024:A,B,;2023:C,;")
}
@(test)
test_pipe_group_by_doesnt_sort :: proc(t: ^testing.T) {
data := make_data(
{title = "old", year = "2020"},
{title = "new", year = "2024"},
{title = "mid", year = "2022"},
)
tpl, _ := parse("{{#posts | group_by year}}{{key}} {{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "2020 2024 2022 ")
}
@(test)
test_pipe_group_by_empty_list_skips :: proc(t: ^testing.T) {
data := Pipe_Data {
posts = nil,
}
tpl, _ := parse("[{{#posts | group_by year}}{{key}}{{/posts}}]")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "[]")
}
@(test)
test_pipe_group_by_missing_field_fails :: proc(t: ^testing.T) {
data := make_data({title = "A", year = "2024"})
tpl, _ := parse("{{#posts | group_by missing}}x{{/posts}}")
defer delete_template(&tpl)
_, err := render(tpl, data)
testing.expect(t, err != nil, "missing field should error")
is_data_err := false
#partial switch e in err {
case Data_Error:
is_data_err = len(e.msg) > 0
}
testing.expect(t, is_data_err, "error should be Data_Error")
}
@(test)
test_pipe_group_by_requires_grouped_field :: proc(t: ^testing.T) {
data := make_data({title = "A", year = ""})
tpl, _ := parse("{{#posts | group_by year}}x{{/posts}}")
defer delete_template(&tpl)
_, err := render(tpl, data)
testing.expect(t, err != nil, "empty field value should error")
}
@(test)
test_group_by_requires_arg :: proc(t: ^testing.T) {
data := make_data({title = "A", year = "2024"})
tpl, _ := parse("{{#posts | group_by}}x{{/posts}}")
defer delete_template(&tpl)
_, err := render(tpl, data)
testing.expect(t, err != nil, "group_by with no args should error")
}
@(test)
test_pipe_group_by_non_list_returns_error :: proc(t: ^testing.T) {
Scalar_Data :: struct {
name: string,
}
data := Scalar_Data {
name = "hello",
}
tpl, _ := parse("{{#name | group_by x}}y{{/name}}")
defer delete_template(&tpl)
_, err := render(tpl, data)
testing.expect(t, err != nil, "group_by on a scalar should error")
}
@(test)
test_pipe_accepts_8_filters :: proc(t: ^testing.T) {
src := "{{#posts | group_by year | group_by year | group_by year | group_by year | group_by year | group_by year | group_by year | group_by year}}x{{/posts}}"
tpl, err := parse(src)
testing.expect(t, err == nil, "8 filters should parse OK")
if err == nil {
defer delete_template(&tpl)
} else {
fmt.printfln("parse error: %v", err)
}
}
@(test)
test_pipe_rejects_nine_filters :: proc(t: ^testing.T) {
src := "{{#posts | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y | group_by y}}x{{/posts}}"
_, err := parse(src)
testing.expect(t, err != nil, "9 filters should fail to parse")
}
@(test)
test_pipe_forbidden_in_close_tag :: proc(t: ^testing.T) {
src := "{{#posts | group_by year}}x{{/posts | group_by year}}"
_, err := parse(src)
testing.expect(t, err != nil, "pipe in close tag should fail to parse")
}
@(test)
test_pipe_empty_middle_filter_required :: proc(t: ^testing.T) {
src := "{{#posts | | group_by year}}x{{/posts}}"
_, err := parse(src)
testing.expect(t, err != nil, "empty pipe filter should fail to parse")
}
@(test)
test_pipe_filter_required :: proc(t: ^testing.T) {
src := "{{#posts |}}x{{/posts}}"
_, err := parse(src)
testing.expect(t, err != nil, "trailing pipe with no filter should fail")
}
@(test)
test_pipe_data_required :: proc(t: ^testing.T) {
src := "{{#| group_by year}}x{{/}}"
_, err := parse(src)
testing.expect(t, err != nil, "missing key should fail to parse")
}
@(test)
test_extra_whitespace_allowed :: proc(t: ^testing.T) {
data := make_data({title = "A", year = "2024"}, {title = "B", year = "2023"})
tpl, _ := parse("{{#posts|group_by year}}{{key}};{{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "2024;2023;")
}
@(test)
test_pipe_no_pipe_still_works :: proc(t: ^testing.T) {
data := make_data({title = "A", year = "2024"}, {title = "B", year = "2023"})
tpl, _ := parse("{{#posts}}{{title}};{{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "A;B;")
}
@(test)
test_pipe_inverted_section_empty :: proc(t: ^testing.T) {
data := Pipe_Data {
posts = nil,
}
tpl, _ := parse("{{^posts | group_by year}}none{{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "none")
}
@(test)
test_pipe_single_group_works :: proc(t: ^testing.T) {
data := make_data({title = "A", year = "2024"}, {title = "B", year = "2024"})
tpl, _ := parse("{{#posts | group_by year}}{{key}}({{#items}}{{title}}{{/items}}){{/posts}}")
defer delete_template(&tpl)
result, _ := render(tpl, data)
defer delete(result)
testing.expect_value(t, result, "2024(AB)")
}
@(test)
test_delete_template_doesnt_leak :: proc(t: ^testing.T) {
tmpl, err := parse("{{#posts | group_by year}}x{{/posts}}")
testing.expect(t, err == nil)
defer delete_template(&tmpl)
}