mirror of
https://github.com/sbrow/thor.git
synced 2026-08-26 11:23:32 -04:00
feat(mustache): Added group_by filter to templates
This commit is contained in:
@@ -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
@@ -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
@@ -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,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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user