diff --git a/mustache/diagnostic.odin b/mustache/diagnostic.odin index fba81d0..0fc0fa1 100644 --- a/mustache/diagnostic.odin +++ b/mustache/diagnostic.odin @@ -307,26 +307,17 @@ write_gutter :: proc(sb: ^strings.Builder, width: int, faint: string, reset: str strings.write_byte(sb, '\n') } -// format_render_error dispatches on Render_Error variant and produces a -// diagnostic for it. Returns "" for nil errors. -format_render_error :: proc(err: Render_Error, tmpl: Template, colorize: bool = false) -> string { +// format_render_error produces a diagnostic for an Error value using the +// template's path and source for context. Returns "" for nil errors. +format_render_error :: proc(err: Error, tmpl: Template, colorize: bool = false) -> string { if err == nil { return "" } - switch e in err { - case Syntax_Error: - path := tmpl.path - if path == "" { - path = "" - } - return format_error(path, tmpl.source, e.pos, e.msg, colorize = colorize) - case Data_Error: - path := tmpl.path - if path == "" { - path = "" - } - return format_error(path, tmpl.source, e.pos, e.msg, colorize = colorize) + path := tmpl.path + if path == "" { + path = "" } - return "" + b := body(err) + return format_error(path, tmpl.source, b.pos, b.msg, colorize = colorize) } diff --git a/mustache/diagnostic_test.odin b/mustache/diagnostic_test.odin index 7d9ae39..1bc0e60 100644 --- a/mustache/diagnostic_test.odin +++ b/mustache/diagnostic_test.odin @@ -425,12 +425,10 @@ test_format_render_error_dispatch :: proc(t: ^testing.T) { return } - #partial switch e in parse_err { - case Syntax_Error: - out := format_error("test.html", src, e.pos, e.msg, colorize = false) - testing.expect(t, strings.contains(out, "unclosed section"), out) - testing.expect(t, strings.contains(out, "test.html:"), out) - } + b := body(parse_err) + out := format_error("test.html", src, b.pos, b.msg, colorize = false) + testing.expect(t, strings.contains(out, "unclosed section"), out) + testing.expect(t, strings.contains(out, "test.html:"), out) } @(test) @@ -469,19 +467,17 @@ test_parse_error_expected_got_keeps_double_braces :: proc(t: ^testing.T) { if err == nil { return } - #partial switch e in err { - case Syntax_Error: - testing.expect( - t, - strings.contains(e.msg, "{{/content}}"), - fmt.tprintf("msg should contain literal {{/content}}, got %q", e.msg), - ) - testing.expect( - t, - strings.contains(e.msg, "{{/cotent}}"), - fmt.tprintf("msg should contain literal {{/cotent}}, got %q", e.msg), - ) - } + b := body(err) + testing.expect( + t, + strings.contains(b.msg, "{{/content}}"), + fmt.tprintf("msg should contain literal {{/content}}, got %q", b.msg), + ) + testing.expect( + t, + strings.contains(b.msg, "{{/cotent}}"), + fmt.tprintf("msg should contain literal {{/cotent}}, got %q", b.msg), + ) } @(test) @@ -492,14 +488,12 @@ test_parse_error_unclosed_section_keeps_double_braces :: proc(t: ^testing.T) { if err == nil { return } - #partial switch e in err { - case Syntax_Error: - testing.expect( - t, - strings.contains(e.msg, "{{#content}}"), - fmt.tprintf("msg should contain literal {{#content}}, got %q", e.msg), - ) - } + b := body(err) + testing.expect( + t, + strings.contains(b.msg, "{{#content}}"), + fmt.tprintf("msg should contain literal {{#content}}, got %q", b.msg), + ) } @(test) @@ -510,14 +504,12 @@ test_parse_error_unexpected_close_keeps_double_braces :: proc(t: ^testing.T) { if err == nil { return } - #partial switch e in err { - case Syntax_Error: - testing.expect( - t, - strings.contains(e.msg, "{{/content}}"), - fmt.tprintf("msg should contain literal {{/content}}, got %q", e.msg), - ) - } + b := body(err) + testing.expect( + t, + strings.contains(b.msg, "{{/content}}"), + fmt.tprintf("msg should contain literal {{/content}}, got %q", b.msg), + ) } @(test) @@ -528,14 +520,12 @@ test_parse_error_pipe_in_close_tag_keeps_double_braces :: proc(t: ^testing.T) { if err == nil { return } - #partial switch e in err { - case Syntax_Error: - testing.expect( - t, - strings.contains(e.msg, "{{/"), - fmt.tprintf("msg should contain literal '{{/', got %q", e.msg), - ) - } + b := body(err) + testing.expect( + t, + strings.contains(b.msg, "{{/"), + fmt.tprintf("msg should contain literal '{{/', got %q", b.msg), + ) } @(test) @@ -546,14 +536,12 @@ test_parse_error_pipe_parse_in_section_keeps_double_braces :: proc(t: ^testing.T if err == nil { return } - #partial switch e in err { - case Syntax_Error: - testing.expect( - t, - strings.contains(e.msg, "{{#"), - fmt.tprintf("msg should contain literal '{{#', got %q", e.msg), - ) - } + b := body(err) + testing.expect( + t, + strings.contains(b.msg, "{{#"), + fmt.tprintf("msg should contain literal '{{#', got %q", b.msg), + ) } @(test) @@ -564,13 +552,11 @@ test_parse_error_pipe_parse_in_inverted_keeps_double_braces :: proc(t: ^testing. if err == nil { return } - #partial switch e in err { - case Syntax_Error: - testing.expect( - t, - strings.contains(e.msg, "{{^"), - fmt.tprintf("msg should contain literal '{{^', got %q", e.msg), - ) - } + b := body(err) + testing.expect( + t, + strings.contains(b.msg, "{{^"), + fmt.tprintf("msg should contain literal '{{^', got %q", b.msg), + ) } diff --git a/mustache/mustache.odin b/mustache/mustache.odin index b1963aa..98da7a3 100644 --- a/mustache/mustache.odin +++ b/mustache/mustache.odin @@ -8,20 +8,27 @@ import "core:strings" // Error types // --------------------------------------------------------------------------- -// TODO: I don't think this is the best way to do our errors. -Syntax_Error :: struct { - msg: string, - pos: int, +Error_Kind :: enum { + Syntax, // parse-time: malformed template + Data, // render-time: template fine, data wrong (e.g. filter misuse) } -Data_Error :: struct { - msg: string, - pos: int, +Error_Body :: struct { + msg: string, + pos: int, + kind: Error_Kind, } -Render_Error :: union { - Syntax_Error, - Data_Error, +// Error is nil when no error occurred. +Error :: union { Error_Body } + +// body unwraps the Error_Body from a non-nil Error. +// Precondition: err != nil. +body :: proc(err: Error) -> Error_Body { + switch e in err { + case Error_Body: return e + case: return {} + } } // --------------------------------------------------------------------------- @@ -101,7 +108,7 @@ parse :: proc( tokens_allocator := context.temp_allocator, ) -> ( tmpl: Template, - err: Render_Error, + err: Error, ) { tokens, terr := tokenize(source, tokens_allocator) if terr != nil { @@ -126,7 +133,7 @@ render :: proc( allocator := context.allocator, ) -> ( result: string, - err: Render_Error, + err: Error, ) { builder: strings.Builder strings.builder_init(&builder, allocator) @@ -158,7 +165,7 @@ parse_tokens :: proc( allocator := context.allocator, ) -> ( nodes: [dynamic]Node, - err: Render_Error, + err: Error, ) { nodes = make([dynamic]Node, 0, len(tokens), allocator) pos := 0 @@ -174,7 +181,7 @@ parse_section :: proc( source: string, allocator := context.allocator, open_pos: int = 0, -) -> Render_Error { +) -> Error { for pos^ < len(tokens) { tok := tokens[pos^] @@ -188,9 +195,10 @@ parse_section :: proc( append(nodes, Node{kind = .Variable, first_child = -1}) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) if perr != nil { - return Syntax_Error { + return Error_Body { msg = fmt.tprintf("pipe parse error in '{{{{%s}}}}': %v", tok.value, perr), pos = tok.pos, + kind = .Syntax, } } nodes[idx].key = pipe_key @@ -201,9 +209,10 @@ parse_section :: proc( append(nodes, Node{kind = .Unescaped, first_child = -1}) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) if perr != nil { - return Syntax_Error { + return Error_Body { msg = fmt.tprintf("pipe parse error in '{{{{&%s}}}}': %v", tok.value, perr), pos = tok.pos, + kind = .Syntax, } } nodes[idx].key = pipe_key @@ -220,9 +229,10 @@ parse_section :: proc( append(nodes, Node{kind = .Section, first_child = -1, pos = tok.pos}) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) if perr != nil { - return Syntax_Error { + return Error_Body { msg = fmt.tprintf("pipe parse error in '{{{{#%s}}}}': %v", tok.value, perr), pos = tok.pos, + kind = .Syntax, } } nodes[idx].key = pipe_key @@ -241,9 +251,10 @@ parse_section :: proc( append(nodes, Node{kind = .Inverted, first_child = -1, pos = tok.pos}) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) if perr != nil { - return Syntax_Error { + return Error_Body { msg = fmt.tprintf("pipe parse error in '{{{{^%s}}}}': %v", tok.value, perr), pos = tok.pos, + kind = .Syntax, } } nodes[idx].key = pipe_key @@ -256,27 +267,30 @@ parse_section :: proc( 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, - } + return Error_Body { + msg = fmt.tprintf( + "pipe expression not allowed in close tag '{{{{/%s}}}}' — use the bare key", + tok.value, + ), + pos = tok.pos, + kind = .Syntax, + } } if end_tag != "" && tok.value == end_tag { pos^ += 1 return nil } if end_tag == "" { - return Syntax_Error { + return Error_Body { msg = fmt.tprintf("unexpected {{{{/%s}}}}", tok.value), pos = tok.pos, + kind = .Syntax, } } - return Syntax_Error { + return Error_Body { msg = fmt.tprintf("expected {{{{/%s}}}}, got {{{{/%s}}}}", end_tag, tok.value), pos = tok.pos, + kind = .Syntax, } case .Partial: @@ -330,9 +344,10 @@ parse_section :: proc( } if end_tag != "" { - return Syntax_Error { + return Error_Body { msg = fmt.tprintf("unclosed section '{{{{#%s}}}}'", end_tag), pos = open_pos, + kind = .Syntax, } } return nil @@ -495,7 +510,7 @@ render_template :: proc( b: ^strings.Builder, blocks: map[string]Block_Override, indent: string, -) -> Render_Error { +) -> Error { if len(indent) > 0 && len(pt.source) > 0 { // Per Mustache spec: the partial's source is indented before rendering, // not its output. This is necessary so that data-injected newlines @@ -525,7 +540,7 @@ render_nodes :: proc( partials: map[string]Template, b: ^strings.Builder, blocks: map[string]Block_Override = nil, -) -> Render_Error { +) -> Error { i := 0 for i < len(nodes) { node := nodes[i] diff --git a/mustache/pipes.odin b/mustache/pipes.odin index 1f794fe..592532c 100644 --- a/mustache/pipes.odin +++ b/mustache/pipes.odin @@ -30,7 +30,7 @@ parse_pipeline :: proc( pos: int, ) -> ( key: string, - err: Render_Error, + err: Error, ) { if !strings.contains(content, "|") { key = strings.trim_space(content) @@ -41,19 +41,20 @@ parse_pipeline :: proc( filter_count := len(segments) - 1 if filter_count > MAX_PIPES { - return "", Syntax_Error { + return "", Error_Body { msg = fmt.tprintf( "pipe expression has %d filters, max is %d", filter_count, MAX_PIPES, ), pos = pos, + kind = .Syntax, } } key = strings.trim_space(segments[0]) if len(key) == 0 { - return "", Syntax_Error{msg = "pipe expression missing key", pos = pos} + return "", Error_Body{msg = "pipe expression missing key", pos = pos, kind = .Syntax} } if filter_count == 0 { @@ -63,17 +64,17 @@ parse_pipeline :: proc( for i in 0 ..< filter_count { seg := strings.trim_space(segments[i + 1]) if len(seg) == 0 { - return "", Syntax_Error{msg = "empty filter", pos = pos} + return "", Error_Body{msg = "empty filter", pos = pos, kind = .Syntax} } tokens := strings.fields(seg) if len(tokens) == 0 { - return "", Syntax_Error{msg = "filter missing op name", pos = pos} + return "", Error_Body{msg = "filter missing op name", pos = pos, kind = .Syntax} } arg_count := len(tokens) - 1 if arg_count > MAX_PIPE_ARGS { - return "", Syntax_Error { + return "", Error_Body { msg = fmt.tprintf( "filter '%s' has %d args, max is %d", tokens[0], @@ -81,6 +82,7 @@ parse_pipeline :: proc( MAX_PIPE_ARGS, ), pos = pos, + kind = .Syntax, } } @@ -97,7 +99,7 @@ parse_pipeline :: proc( return key, nil } -apply_pipeline :: proc(value: any, filters: []Pipe_Filter, pos: int) -> (any, Render_Error) { +apply_pipeline :: proc(value: any, filters: []Pipe_Filter, pos: int) -> (any, Error) { current := value for &filter in filters { result, err := apply_filter(current, &filter, pos) @@ -109,28 +111,33 @@ apply_pipeline :: proc(value: any, filters: []Pipe_Filter, pos: int) -> (any, Re return current, nil } -apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int) -> (any, Render_Error) { +apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int) -> (any, Error) { switch filter.op { case "group_by": return apply_group_by(value, filter.args[:], pos) case "format": str, ok := reflect.as_string(value) if !ok { - return value, Data_Error{msg = "format may only be used on dates", pos = pos} + return value, Error_Body{ + msg = "format may only be used on dates", + pos = pos, + kind = .Data, + } } else { return apply_format(str, filter.args[:], pos) } case: - return nil, Data_Error{ + return nil, Error_Body{ msg = fmt.tprintf("unknown pipe op '%s'", filter.op), pos = pos, + kind = .Data, } } } // apply_format formats an ISO 8601 date string as a display string // (e.g. "2026-03-15T08:49:54-04:00" → "15 Mar 2026"). Invalid input -// (empty, too-short, or unparseable) returns a `Data_Error`. Templates +// (empty, too-short, or unparseable) returns a Data-kind `Error`. Templates // that need to skip dateless pages should gate with a section: // {{#date}}{{/date}} // The section's truthiness check catches empty before the filter runs. @@ -144,9 +151,13 @@ apply_filter :: proc(value: any, filter: ^Pipe_Filter, pos: int) -> (any, Render // 2023-10-15T13:18:50Z // 2023-10-15T13:18:50 // 2023-10-15 -apply_format :: proc(iso: string, args: []string, pos: int) -> (result: any, err: Render_Error) { +apply_format :: proc(iso: string, args: []string, pos: int) -> (result: any, err: Error) { if len(iso) < 10 { - return nil, Data_Error{msg = "format may only be used on dates", pos = pos} + return nil, Error_Body{ + msg = "format may only be used on dates", + pos = pos, + kind = .Data, + } } year := iso[:4] @@ -154,7 +165,11 @@ apply_format :: proc(iso: string, args: []string, pos: int) -> (result: any, err day_num := (int(iso[8]) - 0x30) * 10 + (int(iso[9]) - 0x30) if month_num < 1 || month_num > 12 { - return nil, Data_Error{msg = fmt.tprintf("invalid date: \"%s\"", iso), pos = pos} + return nil, Error_Body{ + msg = fmt.tprintf("invalid date: \"%s\"", iso), + pos = pos, + kind = .Data, + } } month := fmt.tprintf("%s", time.Month(month_num))[:3] @@ -162,20 +177,22 @@ apply_format :: proc(iso: string, args: []string, pos: int) -> (result: any, err } // Groups preserve first-appearance order from the input list. -apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, err: Render_Error) { +apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, err: Error) { if len(args) != 1 { - return nil, Data_Error{ + return nil, Error_Body{ msg = fmt.tprintf("group_by expects 1 argument, got %d", len(args)), pos = pos, + kind = .Data, } } field := args[0] elem_info, count, data := list_info(value) if elem_info == nil { - return nil, Data_Error{ + return nil, Error_Body{ msg = "group_by expects a list", pos = pos, + kind = .Data, } } @@ -189,16 +206,18 @@ apply_group_by :: proc(value: any, args: []string, pos: int) -> (result: any, er key_val, found := lookup_in(elem, field) if !found { - return nil, Data_Error { + return nil, Error_Body { msg = fmt.tprintf("group_by: element missing field '%s'", field), pos = pos, + kind = .Data, } } key_str := any_to_string(key_val) if len(key_str) == 0 { - return nil, Data_Error{ + return nil, Error_Body{ msg = fmt.tprintf("group_by: field '%s' is empty", field), pos = pos, + kind = .Data, } } diff --git a/mustache/pipes_test.odin b/mustache/pipes_test.odin index 31d284f..c952a5f 100644 --- a/mustache/pipes_test.odin +++ b/mustache/pipes_test.odin @@ -66,13 +66,9 @@ test_pipe_group_by_missing_field_fails :: proc(t: ^testing.T) { 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") + b := body(err) + testing.expect(t, b.kind == .Data, "error should be Data kind") + testing.expect(t, len(b.msg) > 0, "error should have non-empty msg") } @(test) diff --git a/mustache/tokenizer.odin b/mustache/tokenizer.odin index d76afa7..500ed7f 100644 --- a/mustache/tokenizer.odin +++ b/mustache/tokenizer.odin @@ -28,7 +28,7 @@ tokenize :: proc( allocator := context.allocator, ) -> ( tokens: [dynamic]Token, - err: Render_Error, + err: Error, ) { tokens = make([dynamic]Token, 0, 8, allocator) @@ -47,9 +47,10 @@ tokenize :: proc( content_start := i + 3 idx := strings.index(src[content_start:], "}}}") if idx < 0 { - return tokens, Syntax_Error { + return tokens, Error_Body { msg = "unclosed triple mustache '{{{'", pos = tag_pos, + kind = .Syntax, } } close := content_start + idx @@ -89,9 +90,13 @@ tokenize :: proc( } close_idx := strings.index(src[key_start:], "}}") - if close_idx < 0 { - return tokens, Syntax_Error{msg = "unclosed tag '{{'", pos = tag_pos} + if close_idx < 0 { + return tokens, Error_Body { + msg = "unclosed tag '{{'", + pos = tag_pos, + kind = .Syntax, } + } close := key_start + close_idx content := src[key_start:close] diff --git a/render.odin b/render.odin index 103cf6e..a2bb3e9 100644 --- a/render.odin +++ b/render.odin @@ -63,31 +63,17 @@ load_template :: proc(vfs: ^VFS, virtual_path: string) -> mustache.Template { source := string(data) tpl, err := mustache.parse(source, entry.fs_path) if err != nil { - switch e in err { - // TODO: Find a way to merge these branches. - case mustache.Syntax_Error: - log.errorf( - "%s", - mustache.format_error( - entry.fs_path, - source, - e.pos, - e.msg, - colorize = mustache.should_colorize(), - ), - ) - case mustache.Data_Error: - log.errorf( - "%s", - mustache.format_error( - entry.fs_path, - source, - e.pos, - e.msg, - colorize = mustache.should_colorize(), - ), - ) - } + b := mustache.body(err) + log.errorf( + "%s", + mustache.format_error( + entry.fs_path, + source, + b.pos, + b.msg, + colorize = mustache.should_colorize(), + ), + ) os.exit(1) } return tpl @@ -375,31 +361,17 @@ load_partials :: proc(vfs: ^VFS) -> map[string]mustache.Template { source := string(data) tpl, err := mustache.parse(source, entry.fs_path) if err != nil { - // TODO: Find a way to merge these branches. - switch e in err { - case mustache.Syntax_Error: - log.errorf( - "%s", - mustache.format_error( - entry.fs_path, - source, - e.pos, - e.msg, - colorize = mustache.should_colorize(), - ), - ) - case mustache.Data_Error: - log.errorf( - "%s", - mustache.format_error( - entry.fs_path, - source, - e.pos, - e.msg, - colorize = mustache.should_colorize(), - ), - ) - } + b := mustache.body(err) + log.errorf( + "%s", + mustache.format_error( + entry.fs_path, + source, + b.pos, + b.msg, + colorize = mustache.should_colorize(), + ), + ) os.exit(1) } partials[key] = tpl