diff --git a/TODOS.md b/TODOS.md index 7428321..eaf0d73 100644 --- a/TODOS.md +++ b/TODOS.md @@ -18,8 +18,10 @@ Remember to polish existing features before moving on to new ones. - alt1: `layouts`, `partials` - alt2: `templates/layouts`, `templates/partials` - [ ] pipes - - [ ] first - - [ ] last + - [ ] first/last + - [ ] warn when using negative numbers + - [ ] warn when using default arg on string + - [ ] add the above warnings to DIAGNOSTIC_TODOS.md - [ ] sort_by ## Documentation diff --git a/mustache/pipes.odin b/mustache/pipes.odin index 93843d5..5b37179 100644 --- a/mustache/pipes.odin +++ b/mustache/pipes.odin @@ -4,8 +4,8 @@ import "base:runtime" import "core:fmt" import "core:log" import "core:reflect" +import "core:strconv" import "core:strings" -import "core:time" import diags "../diagnostics" import "core:time/datetime" @@ -22,6 +22,8 @@ DEFAULT_DATE_FORMAT :: "2 Jan 2006" Pipe_Op :: enum { Format, Group_By, + First, + Last, } // Pipe op names are derived from the enum via reflection (lowercased). @@ -133,11 +135,7 @@ parse_pipeline :: proc( pipe_count := strings.count(content, "|") if pipe_count > MAX_PIPES { return "", Error_Body { - msg = fmt.tprintf( - "pipe expression has %d filters, max is %d", - pipe_count, - MAX_PIPES, - ), + msg = fmt.tprintf("pipe expression has %d filters, max is %d", pipe_count, MAX_PIPES), pos = pos, kind = .Syntax, } @@ -267,8 +265,8 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> hint = fmt.tprintf("did you mean '%s'?", suggestion) } return nil, Error_Body { - msg = fmt.tprintf("unknown pipe op '%s'", filter.op), - pos = filter.op_pos, + msg = fmt.tprintf("unknown pipe op '%s'", filter.op), + pos = filter.op_pos, span = len(filter.op), kind = .Data, hint = hint, @@ -316,6 +314,10 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int, ctx: []any) -> } else { return any{new_clone(str2, context.temp_allocator), typeid_of(string)}, nil } + case .First: + return take(value, filter.args[:], pos, from_start = true) + case .Last: + return take(value, filter.args[:], pos, from_start = false) } return {}, nil } @@ -411,3 +413,61 @@ apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, er return groups, nil } + +take :: proc(value: any, args: []string, pos: int, from_start: bool) -> (any, Error) { + elem_info, count, data := list_info(value) + is_list := elem_info != nil + from_start := from_start + + str: string + if !is_list { + s, is_str := reflect.as_string(value) + if !is_str { + return nil, Error_Body{msg = "requires a list or string", pos = pos, kind = .Data} + } + str = s + count = len(str) + } + + n := 1 + if !is_list && len(args) == 0 { + return nil, Error_Body{msg = "requires an argument for strings", pos = pos, kind = .Data} + } + if len(args) > 0 { + parsed, ok := strconv.parse_int(args[0]) + if !ok { + return nil, Error_Body { + msg = fmt.tprintf("invalid argument '%s'", args[0]), + pos = pos, + kind = .Data, + } + } + n = parsed + } + + if n < 0 { + from_start = !from_start + n = 0 - n // abs(n) + } + + start: int + end: int + if from_start { + start = 0 + end = min(count, n) + } else { + start = max(0, count - n) + end = count + } + + if is_list { + result := make([dynamic]any, 0, max(0, end - start), context.temp_allocator) + for j in start ..< end { + append(&result, extract_list_element(elem_info, data, j)) + } + return result, nil + } else { + return str[start:end], nil + } +} + diff --git a/mustache/pipes_test.odin b/mustache/pipes_test.odin index 50d0f5b..133c7d5 100644 --- a/mustache/pipes_test.odin +++ b/mustache/pipes_test.odin @@ -1,3 +1,4 @@ +#+feature dynamic-literals #+test package mustache @@ -567,7 +568,10 @@ test_format_mst_no_offset_no_timezone :: proc(t: ^testing.T) { @(test) test_unknown_pipe_op_suggestion :: proc(t: ^testing.T) { - filter := Pipe_Filter{op = "formats", op_pos = 0} + filter := Pipe_Filter { + op = "formats", + op_pos = 0, + } _, err := apply_filter("2026-01-15", &filter, 0, nil) testing.expect(t, err != nil, "should error on unknown op") b := body(err) @@ -577,9 +581,146 @@ test_unknown_pipe_op_suggestion :: proc(t: ^testing.T) { @(test) test_unknown_pipe_op_no_suggestion :: proc(t: ^testing.T) { - filter := Pipe_Filter{op = "xyz", op_pos = 0} + filter := Pipe_Filter { + op = "xyz", + op_pos = 0, + } _, err := apply_filter("2026-01-15", &filter, 0, nil) testing.expect(t, err != nil, "should error on unknown op") b := body(err) testing.expect(t, b.hint == "", "no suggestion expected for 'xyz'") } + +// Group 1: Lists + +First_Last_List_Data: map[string][5]string = { + "items" = [5]string{"foo", "bar", "bat", "qux", "quz"}, +} + +@(test) +test_first_list_no_args_equals_first_one :: proc(t: ^testing.T) { + tpl_a, _ := parse( + "{{#items | first}}
  • {{.}}
  • {{/items}}", + "", + allocator = context.temp_allocator, + ) + result_a, _ := render(tpl_a, First_Last_List_Data, {}, context.temp_allocator) + + tpl_b, _ := parse( + "{{#items | first 1}}
  • {{.}}
  • {{/items}}", + "", + allocator = context.temp_allocator, + ) + result_b, _ := render(tpl_b, First_Last_List_Data, {}, context.temp_allocator) + + testing.expect_value(t, result_a, "
  • foo
  • ") + testing.expect_value(t, result_a, result_b) +} + +@(test) +test_first_list_n :: proc(t: ^testing.T) { + data := First_Last_List_Data + tpl, _ := parse( + "{{#items | first 3}}
  • {{.}}
  • {{/items}}", + "", + allocator = context.temp_allocator, + ) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "
  • foo
  • bar
  • bat
  • ") +} + +@(test) +test_first_list_negative_equals_last :: proc(t: ^testing.T) { + data := First_Last_List_Data + tpl, _ := parse( + "{{#items | first -2}}
  • {{.}}
  • {{/items}}", + "", + allocator = context.temp_allocator, + ) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "
  • qux
  • quz
  • ") +} + +@(test) +test_last_list_no_args_equals_last_one :: proc(t: ^testing.T) { + data := First_Last_List_Data + tpl_a, _ := parse( + "{{#items | last}}
  • {{.}}
  • {{/items}}", + "", + allocator = context.temp_allocator, + ) + result_a, _ := render(tpl_a, data, {}, context.temp_allocator) + + tpl_b, _ := parse( + "{{#items | last 1}}
  • {{.}}
  • {{/items}}", + "", + allocator = context.temp_allocator, + ) + result_b, _ := render(tpl_b, data, {}, context.temp_allocator) + + testing.expect_value(t, result_a, "
  • quz
  • ") + testing.expect_value(t, result_b, "
  • quz
  • ") +} + +@(test) +test_last_list_n :: proc(t: ^testing.T) { + data := First_Last_List_Data + tpl, _ := parse( + "{{#items | last 3}}
  • {{.}}
  • {{/items}}", + "", + allocator = context.temp_allocator, + ) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "
  • bat
  • qux
  • quz
  • ") +} + +@(test) +test_last_list_negative_equals_first :: proc(t: ^testing.T) { + data := First_Last_List_Data + tpl, _ := parse( + "{{#items | last -2}}
  • {{.}}
  • {{/items}}", + "", + allocator = context.temp_allocator, + ) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "
  • foo
  • bar
  • ") +} + +// Group 2: Strings + +First_Last_String_Data := map[string]string { + "word" = "Hello, World!", +} + +@(test) +test_first_string_n :: proc(t: ^testing.T) { + data := First_Last_String_Data + tpl, _ := parse("{{word | first 5}}", "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "Hello") +} + +@(test) +test_last_string_n :: proc(t: ^testing.T) { + data := First_Last_String_Data + tpl, _ := parse("{{word | last 6}}", "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "World!") +} + +@(test) +test_first_string_negative_equals_last :: proc(t: ^testing.T) { + data := First_Last_String_Data + tpl, _ := parse("{{word | first -6}}", "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "World!") +} + +@(test) +test_last_string_negative_equals_first :: proc(t: ^testing.T) { + data := First_Last_String_Data + tpl, _ := parse("{{word | last -5}}", "", allocator = context.temp_allocator) + result, _ := render(tpl, data, {}, context.temp_allocator) + testing.expect_value(t, result, "Hello") +} +