package mustache import "base:runtime" import "core:fmt" import "core:log" import "core:strings" // A hypothetical maximum context depth. Trying to pass more than this many items // to render(tpl, data), or nesting templates further than this depth would be // an error. // May be enforced in a later version (for performance) MAX_CONTEXT_DEPTH :: #config(MAX_CONTEXT_DEPTH, 16) // Context_Stack is the growable stack of data frames walked top-to-bottom by // resolve_name. Frames are pushed on section descent and popped on exit; the // root data (or each element of a root []any) forms the base frames. Context_Stack :: [dynamic]any // --------------------------------------------------------------------------- // Error types // --------------------------------------------------------------------------- Error_Kind :: enum { Syntax, // parse-time: malformed template Data, // render-time: template fine, data wrong (e.g. filter misuse) } Error_Body :: struct { msg: string, pos: int, kind: Error_Kind, } // 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 {} } } // --------------------------------------------------------------------------- // Node tree // --------------------------------------------------------------------------- Node_Kind :: enum { Text, Variable, Unescaped, Section, Inverted, Partial, Parent, Block, } Node :: struct { kind: Node_Kind, text: string, key: string, filters: [dynamic; MAX_PIPES]Pipe_Filter, is_dynamic: bool, indent: string, children: []Node, content: string, pos: int, } // node_span returns the number of flat-array entries a node occupies: // 1 for leaf nodes, 1 + len(children) for container nodes (whose children // are stored contiguously after them in the array). node_span :: proc(n: Node) -> int { switch n.kind { case .Section, .Inverted, .Parent, .Block: return 1 + len(n.children) case .Text, .Variable, .Unescaped, .Partial: fallthrough case: return 1 } } Template :: struct { nodes: [dynamic]Node, source: string, path: string, } Block_Override :: struct { nodes: []Node, source: Template, } // Indent_State threads partial-indent tracking through render_nodes so // the renderer can apply indentation at render time instead of reparsing // the partial's source with indentation baked in. Indent_State :: struct { indent: string, at_line_start: bool, } delete_template :: proc(tmpl: ^Template) { if tmpl != nil && len(tmpl.nodes) > 0 { delete(tmpl.nodes) } } delete_partials :: proc(partials: map[string]Template) { for _, &p in partials { delete_template(&p) } delete(partials) } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- parse :: proc( source: string, path := "", allocator := context.allocator, tokens_allocator := context.temp_allocator, ) -> ( tmpl: Template, err: Error, ) { tokens, terr := tokenize(source, tokens_allocator) if terr != nil { return {}, terr } tmpl.nodes, err = parse_tokens(tokens[:], source, allocator) if err != nil { delete(tmpl.nodes) return {}, err } tmpl.source = source tmpl.path = path deindent_blocks(tmpl.nodes[:], allocator) return tmpl, nil } render :: proc( tmpl: Template, data: any, partials: map[string]Template = nil, allocator := context.allocator, ) -> ( result: string, err: Error, ) { builder: strings.Builder strings.builder_init(&builder, context.temp_allocator) ctx := make(Context_Stack, 0, 4, context.temp_allocator) // If data is a []any, expand into individual context frames. // Otherwise, push as a single frame. elem_info, count, slice_data := list_info(data) if elem_info != nil { if _, is_any := elem_info.variant.(runtime.Type_Info_Any); is_any { for j in 0 ..< count { append(&ctx, extract_list_element(elem_info, slice_data, j)) } } else { append(&ctx, data) } } else { append(&ctx, data) } all_nodes := tmpl.nodes[:] err = render_nodes(tmpl, all_nodes, &ctx, partials, &builder) if err != nil { return result, err } temp := strings.to_string(builder) result = strings.clone(temp, allocator) return result, err } // --------------------------------------------------------------------------- // Parser — flat token list → flat node array with child indices // --------------------------------------------------------------------------- parse_tokens :: proc( tokens: []Token, source: string, allocator := context.allocator, ) -> ( nodes: [dynamic]Node, err: Error, ) { nodes = make([dynamic]Node, 0, len(tokens), allocator) pos := 0 err = parse_section(tokens, &pos, &nodes, "", source, allocator, 0) assert(len(nodes) <= cap(nodes)) return } parse_section :: proc( tokens: []Token, pos: ^int, nodes: ^[dynamic]Node, end_tag: string, source: string, allocator := context.allocator, open_pos: int = 0, ) -> Error { for pos^ < len(tokens) { tok := tokens[pos^] switch tok.kind { case .Text: append(nodes, Node{kind = .Text, text = tok.value, pos = tok.pos}) pos^ += 1 case .Variable: idx := len(nodes) append(nodes, Node{kind = .Variable}) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) if perr != nil { 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 pos^ += 1 case .Unescaped: idx := len(nodes) append(nodes, Node{kind = .Unescaped}) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) if perr != nil { 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 pos^ += 1 case .Comment: pos^ += 1 case .Section_Open: pos^ += 1 idx := len(nodes) content_start := 0 if pos^ < len(tokens) {content_start = tokens[pos^].pos} append(nodes, Node{kind = .Section, pos = tok.pos}) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) if perr != nil { 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 parse_section(tokens, pos, nodes, pipe_key, source, allocator, tok.pos) or_return close_pos := 0 if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) {close_pos = tokens[pos^ - 1].pos} nodes[idx].children = nodes[idx + 1:len(nodes)] nodes[idx].content = source[content_start:close_pos] case .Inverted_Open: pos^ += 1 idx := len(nodes) content_start := 0 if pos^ < len(tokens) {content_start = tokens[pos^].pos} append(nodes, Node{kind = .Inverted, pos = tok.pos}) pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos) if perr != nil { 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 parse_section(tokens, pos, nodes, pipe_key, source, allocator, tok.pos) or_return close_pos := 0 if pos^ - 1 >= 0 && pos^ - 1 < len(tokens) {close_pos = tokens[pos^ - 1].pos} nodes[idx].children = nodes[idx + 1:len(nodes)] nodes[idx].content = source[content_start:close_pos] case .Section_Close: if strings.contains(tok.value, "|") { 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 Error_Body { msg = fmt.tprintf("unexpected {{{{/%s}}}}", tok.value), pos = tok.pos, kind = .Syntax, } } return Error_Body { msg = fmt.tprintf("expected {{{{/%s}}}}, got {{{{/%s}}}}", end_tag, tok.value), pos = tok.pos, kind = .Syntax, } case .Partial: append( nodes, Node { kind = .Partial, key = tok.value, is_dynamic = tok.is_dynamic, indent = tok.indent, pos = tok.pos, }, ) pos^ += 1 case .Parent: pos^ += 1 idx := len(nodes) append( nodes, Node{kind = .Parent, key = tok.value, indent = tok.indent, pos = tok.pos}, ) parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return nodes[idx].children = nodes[idx + 1:len(nodes)] case .Block_Open: pos^ += 1 idx := len(nodes) append(nodes, Node{kind = .Block, key = tok.value, indent = tok.indent, pos = tok.pos}) parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return nodes[idx].children = nodes[idx + 1:len(nodes)] } } if end_tag != "" { return Error_Body { msg = fmt.tprintf("unclosed section '{{{{#%s}}}}'", end_tag), pos = open_pos, kind = .Syntax, } } return nil } // --------------------------------------------------------------------------- // Post-parse: de-indent block content // --------------------------------------------------------------------------- deindent_blocks :: proc(nodes: []Node, allocator := context.allocator) { i := 0 for i < len(nodes) { switch nodes[i].kind { case .Block: if len(nodes[i].children) > 0 { children := nodes[i].children deindent_blocks(children, allocator) common := find_common_indent(children) if len(common) > 0 { if len(nodes[i].indent) == 0 { nodes[i].indent = common } for j := 0; j < len(children); { if children[j].kind == .Text && len(children[j].text) > 0 { children[j].text = remove_line_indent( children[j].text, common, allocator, ) } j += node_span(children[j]) } } } case .Section, .Inverted, .Parent: if len(nodes[i].children) > 0 { deindent_blocks(nodes[i].children, allocator) } case .Text, .Variable, .Unescaped, .Partial: // Do nothing } i += node_span(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 { // Bulk-scan to next newline (AVX2-backed) instead of byte-by-byte. rel := strings.index_byte(text[line_start:], '\n') line_end: int if rel < 0 { line_end = len(text) } else { line_end = line_start + rel } line := text[line_start:line_end] 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 } } if rel < 0 { break } line_start = line_end + 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 } // Bulk-append up to and including the next newline (AVX2-backed). rel := strings.index_byte(s[i:], '\n') if rel < 0 { append(&buf, s[i:]) break } next := i + rel append(&buf, s[i:next + 1]) i = next + 1 at_line_start = true } return string(buf[:]) } // --------------------------------------------------------------------------- // Indentation helpers // --------------------------------------------------------------------------- render_template :: proc( pt: Template, ctx: ^Context_Stack, partials: map[string]Template, b: ^strings.Builder, blocks: map[string]Block_Override, indent: string, ) -> Error { if len(indent) > 0 { state := Indent_State { indent = indent, at_line_start = false, } strings.write_string(b, indent) // first line always gets indent return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, &state) } return render_nodes(pt, pt.nodes[:], ctx, partials, b, blocks, nil) } write_indented :: proc( b: ^strings.Builder, indent: string, content: string, at_line_start: ^bool, ) { if len(indent) == 0 || len(content) == 0 { strings.write_string(b, content) return } i := 0 for i < len(content) { if at_line_start^ { strings.write_string(b, indent) at_line_start^ = false } // Bulk-write up to and including the next newline (AVX2-backed). rel := strings.index_byte(content[i:], '\n') if rel < 0 { strings.write_string(b, content[i:]) return } next := i + rel strings.write_string(b, content[i:next + 1]) i = next + 1 at_line_start^ = true } } // --------------------------------------------------------------------------- // Renderer — walk node array against context stack, write to builder // --------------------------------------------------------------------------- render_nodes :: proc( current: Template, nodes: []Node, ctx: ^Context_Stack, partials: map[string]Template, b: ^strings.Builder, blocks: map[string]Block_Override = nil, indent_state: ^Indent_State = nil, ) -> Error { i := 0 for i < len(nodes) { node := nodes[i] switch node.kind { case .Text: if indent_state != nil { write_indented(b, indent_state.indent, node.text, &indent_state.at_line_start) } else { strings.write_string(b, node.text) } i += 1 case .Variable: if indent_state != nil && indent_state.at_line_start { strings.write_string(b, indent_state.indent) indent_state.at_line_start = false } val := resolve_name(node.key, ctx[:]) if val == nil { warn_unknown_key(current, ctx[:], node) } if len(node.filters) > 0 { transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:]) if perr != nil { return perr } val = transformed } if result_str, ok := call_interp_lambda(val); ok { sub_tpl, perr := parse( result_str, fmt.tprintf("", node.key), context.temp_allocator, context.temp_allocator, ) if perr == nil { temp: strings.Builder strings.builder_init(&temp, context.temp_allocator) render_nodes( sub_tpl, sub_tpl.nodes[:], ctx, partials, &temp, blocks, nil, ) or_return write_value(b, strings.to_string(temp), escape = true) } } else { write_value(b, val, escape = true) } i += 1 case .Unescaped: if indent_state != nil && indent_state.at_line_start { strings.write_string(b, indent_state.indent) indent_state.at_line_start = false } val := resolve_name(node.key, ctx[:]) if val == nil { warn_unknown_key(current, ctx[:], node) } if len(node.filters) > 0 { transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:]) if perr != nil { return perr } val = transformed } if result_str, ok := call_interp_lambda(val); ok { sub_tpl, perr := parse( result_str, fmt.tprintf("", node.key), context.temp_allocator, context.temp_allocator, ) if perr == nil { temp: strings.Builder strings.builder_init(&temp, context.temp_allocator) render_nodes( sub_tpl, sub_tpl.nodes[:], ctx, partials, &temp, blocks, nil, ) or_return write_value(b, strings.to_string(temp), escape = false) } } else { write_value(b, val, escape = false) } i += 1 case .Section: val := resolve_name(node.key, ctx[:]) if val == nil { warn_unknown_key(current, ctx[:], node) } if len(node.filters) > 0 { transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:]) if perr != nil { return perr } val = transformed } if result_str, ok := call_section_lambda(val, node.content); ok { sub_tpl, perr := parse( result_str, fmt.tprintf("", node.key), context.temp_allocator, context.temp_allocator, ) if perr == nil { render_nodes( sub_tpl, sub_tpl.nodes[:], ctx, partials, b, blocks, nil, ) or_return } } else if is_truthy(val) { children := node.children elem_info, count, data := list_info(val) if elem_info != nil { for j in 0 ..< count { elem := extract_list_element(elem_info, data, j) context_push(ctx, elem, current, node) defer pop(ctx) render_nodes( current, children, ctx, partials, b, blocks, indent_state, ) or_return } } else { context_push(ctx, val, current, node) defer pop(ctx) render_nodes( current, children, ctx, partials, b, blocks, indent_state, ) or_return } } i += 1 + len(node.children) case .Inverted: val := resolve_name(node.key, ctx[:]) if val == nil { warn_unknown_key(current, ctx[:], node) } if len(node.filters) > 0 { transformed, perr := apply_pipeline(val, node.filters[:], node.pos, ctx[:]) if perr != nil { return perr } val = transformed } if !is_truthy(val) { render_nodes( current, node.children, ctx, partials, b, blocks, indent_state, ) or_return } i += 1 + len(node.children) case .Partial: name := node.key if node.is_dynamic { val := resolve_name(node.key, ctx[:]) name = any_to_string(val) } pt, found := partials[name] if !found { warn_missing_partial(current, partials, node, name) } else { render_template(pt, ctx, partials, b, nil, node.indent) or_return if indent_state != nil { indent_state.at_line_start = false } } i += 1 case .Block: content_nodes: []Node content_blocks := blocks render_current := current found_override := false if blocks != nil { if o, ok := blocks[node.key]; ok { content_nodes = o.nodes found_override = true render_current = o.source } } if !found_override { content_nodes = node.children } if len(node.indent) > 0 { temp: strings.Builder strings.builder_init(&temp, context.temp_allocator) render_nodes( render_current, content_nodes, ctx, partials, &temp, content_blocks, nil, ) or_return at_ls := true write_indented(b, node.indent, strings.to_string(temp), &at_ls) } else { render_nodes( render_current, content_nodes, ctx, partials, b, content_blocks, indent_state, ) or_return } i += 1 + len(node.children) case .Parent: parent_children := node.children merged := merge_block_overrides(parent_children, blocks, current) pt, found := partials[node.key] if !found { warn_missing_partial(current, partials, node, node.key) } else { warn_unmatched_block_overrides(current, pt, parent_children) render_template(pt, ctx, partials, b, merged, node.indent) or_return if indent_state != nil { indent_state.at_line_start = false } } i += 1 + len(node.children) } } return nil } merge_block_overrides :: proc( children: []Node, existing: map[string]Block_Override, source: Template, ) -> map[string]Block_Override { result := make(map[string]Block_Override, context.temp_allocator) for name, override in existing { result[name] = override } i := 0 for i < len(children) { child := children[i] if child.kind == .Block { if _, exists := result[child.key]; !exists { result[child.key] = Block_Override { nodes = child.children, source = source, } } } i += node_span(child) } return result } // warn_unknown_key checks whether the missing key is a genuine typo (vs. a // legitimate path through a user-defined map) and, if so, emits a diagnostic // warning with the closest field-name suggestion via Levenshtein. warn_unknown_key :: proc(current: Template, ctx: []any, node: Node) { // `{{.}}` and dot-prefixed names refer to the current context — always valid. if node.key == "." || (len(node.key) > 0 && node.key[0] == '.') { return } path_ok, missing, available := validate_key_path(ctx, node.key) if path_ok { return } hint := "" if len(available) > 0 { suggestion := suggest_correction(available, missing) if suggestion != "" { hint = fmt.tprintf("did you mean '%s'?", suggestion) } } msg := fmt.tprintf("unknown key '%s'", node.key) path := current.path if path == "" { path = "" } diag := format_error(path, current.source, node.pos, msg, hint, colorize = should_colorize()) log.warnf("%s", diag) } // warn_missing_partial emits a warning when a `{{> name}}` or `{{