feat: Added Rust-style error messages for template errors.

This commit is contained in:
Spencer Brower
2026-07-21 12:23:23 -04:00
parent b239bf2d87
commit f04532229b
13 changed files with 1786 additions and 121 deletions
+236 -44
View File
@@ -1,28 +1,27 @@
package mustache
import "core:fmt"
import "core:log"
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,
}
Data_Error :: struct {
msg: string,
}
Partial_Error :: struct {
name: string,
msg: string,
pos: int,
}
Render_Error :: union {
Syntax_Error,
Data_Error,
Partial_Error,
}
// ---------------------------------------------------------------------------
@@ -50,6 +49,7 @@ Node :: struct {
first_child: int,
child_count: int,
content: string,
pos: int,
}
// node_span returns the number of flat-array entries a node occupies:
@@ -67,12 +67,14 @@ node_span :: proc(n: Node) -> int {
Template :: struct {
nodes: [dynamic]Node,
source: string,
path: string,
}
Block_Override :: struct {
all_nodes: []Node,
first: int,
count: int,
source: Template,
}
delete_template :: proc(tmpl: ^Template) {
@@ -94,6 +96,7 @@ delete_partials :: proc(partials: map[string]Template) {
parse :: proc(
source: string,
path := "",
allocator := context.allocator,
tokens_allocator := context.temp_allocator,
) -> (
@@ -111,6 +114,7 @@ parse :: proc(
return {}, err
}
tmpl.source = source
tmpl.path = path
deindent_blocks(tmpl.nodes[:], 0, len(tmpl.nodes), allocator)
return tmpl, nil
}
@@ -133,7 +137,7 @@ render :: proc(
append(&ctx, data)
all_nodes := tmpl.nodes[:]
err = render_nodes(all_nodes, all_nodes, &ctx, partials, &builder)
err = render_nodes(tmpl, all_nodes, all_nodes, &ctx, partials, &builder)
if err != nil {
return result, err
}
@@ -158,7 +162,7 @@ parse_tokens :: proc(
) {
nodes = make([dynamic]Node, 0, len(tokens), allocator)
pos := 0
err = parse_section(tokens, &pos, &nodes, "", source, allocator)
err = parse_section(tokens, &pos, &nodes, "", source, allocator, 0)
return
}
@@ -169,22 +173,23 @@ parse_section :: proc(
end_tag: string,
source: string,
allocator := context.allocator,
open_pos: int = 0,
) -> Render_Error {
for pos^ < len(tokens) {
tok := tokens[pos^]
switch tok.kind {
case .Text:
append(nodes, Node{kind = .Text, text = tok.value, first_child = -1})
append(nodes, Node{kind = .Text, text = tok.value, first_child = -1, pos = tok.pos})
pos^ += 1
case .Variable:
idx := len(nodes)
append(nodes, Node{kind = .Variable, first_child = -1})
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters)
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos)
if perr != nil {
return Syntax_Error {
msg = fmt.tprintf("pipe parse error in '{{%s}}': %v", tok.value, perr),
msg = fmt.tprintf("pipe parse error in '{{{{%s}}}}': %v", tok.value, perr),
pos = tok.pos,
}
}
@@ -194,10 +199,10 @@ parse_section :: proc(
case .Unescaped:
idx := len(nodes)
append(nodes, Node{kind = .Unescaped, first_child = -1})
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters)
pipe_key, perr := parse_pipeline(tok.value, &nodes[idx].filters, tok.pos)
if perr != nil {
return Syntax_Error {
msg = fmt.tprintf("pipe parse error in '{{&%s}}': %v", tok.value, perr),
msg = fmt.tprintf("pipe parse error in '{{{{&%s}}}}': %v", tok.value, perr),
pos = tok.pos,
}
}
@@ -212,16 +217,16 @@ parse_section :: proc(
idx := len(nodes)
content_start := 0
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)
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 {
msg = fmt.tprintf("pipe parse error in '{{#%s}}': %v", tok.value, perr),
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
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].first_child = idx + 1
@@ -233,16 +238,16 @@ parse_section :: proc(
idx := len(nodes)
content_start := 0
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)
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 {
msg = fmt.tprintf("pipe parse error in '{{^%s}}': %v", tok.value, perr),
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
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].first_child = idx + 1
@@ -253,7 +258,7 @@ parse_section :: proc(
if strings.contains(tok.value, "|") {
return Syntax_Error {
msg = fmt.tprintf(
"pipe expression not allowed in close tag '{{/%s}}' — use the bare key",
"pipe expression not allowed in close tag '{{{{/%s}}}}' — use the bare key",
tok.value,
),
pos = tok.pos,
@@ -265,12 +270,12 @@ parse_section :: proc(
}
if end_tag == "" {
return Syntax_Error {
msg = fmt.tprintf("unexpected {{/%s}}", tok.value),
msg = fmt.tprintf("unexpected {{{{/%s}}}}", tok.value),
pos = tok.pos,
}
}
return Syntax_Error {
msg = fmt.tprintf("expected {{/%s}}, got {{/%s}}", end_tag, tok.value),
msg = fmt.tprintf("expected {{{{/%s}}}}, got {{{{/%s}}}}", end_tag, tok.value),
pos = tok.pos,
}
@@ -283,6 +288,7 @@ parse_section :: proc(
is_dynamic = tok.is_dynamic,
indent = tok.indent,
first_child = -1,
pos = tok.pos,
},
)
pos^ += 1
@@ -292,9 +298,15 @@ parse_section :: proc(
idx := len(nodes)
append(
nodes,
Node{kind = .Parent, key = tok.value, indent = tok.indent, first_child = -1},
Node {
kind = .Parent,
key = tok.value,
indent = tok.indent,
first_child = -1,
pos = tok.pos,
},
)
parse_section(tokens, pos, nodes, tok.value, source, allocator) or_return
parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
@@ -303,16 +315,25 @@ parse_section :: proc(
idx := len(nodes)
append(
nodes,
Node{kind = .Block, key = tok.value, indent = tok.indent, first_child = -1},
Node {
kind = .Block,
key = tok.value,
indent = tok.indent,
first_child = -1,
pos = tok.pos,
},
)
parse_section(tokens, pos, nodes, tok.value, source, allocator) or_return
parse_section(tokens, pos, nodes, tok.value, source, allocator, tok.pos) or_return
nodes[idx].first_child = idx + 1
nodes[idx].child_count = len(nodes) - idx - 1
}
}
if end_tag != "" {
return Syntax_Error{msg = fmt.tprintf("unclosed section '{{#%s}}'", end_tag)}
return Syntax_Error {
msg = fmt.tprintf("unclosed section '{{{{#%s}}}}'", end_tag),
pos = open_pos,
}
}
return nil
}
@@ -476,11 +497,20 @@ render_template :: proc(
indent: string,
) -> Render_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
// (e.g. from `{{{content}}}` where content contains `\n`) do NOT pick
// up the indent — only source-level line breaks do.
indented := indent_lines(pt.source, indent)
reparse := parse(indented, context.temp_allocator, context.temp_allocator) or_return
return render_nodes(reparse.nodes[:], reparse.nodes[:], ctx, partials, b, blocks)
reparse := parse(
indented,
pt.path,
context.temp_allocator,
context.temp_allocator,
) or_return
return render_nodes(reparse, reparse.nodes[:], reparse.nodes[:], ctx, partials, b, blocks)
}
return render_nodes(pt.nodes[:], pt.nodes[:], ctx, partials, b, blocks)
return render_nodes(pt, pt.nodes[:], pt.nodes[:], ctx, partials, b, blocks)
}
// ---------------------------------------------------------------------------
@@ -488,6 +518,7 @@ render_template :: proc(
// ---------------------------------------------------------------------------
render_nodes :: proc(
current: Template,
all_nodes: []Node,
nodes: []Node,
ctx: ^[dynamic]any,
@@ -505,19 +536,28 @@ render_nodes :: proc(
case .Variable:
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[:])
transformed, perr := apply_pipeline(val, node.filters[:], node.pos)
if perr != nil {
return perr
}
val = transformed
}
if result_str, ok := call_interp_lambda(val); ok {
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
sub_tpl, perr := parse(
result_str,
fmt.tprintf("<lambda output from '%s'>", 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[:],
sub_tpl.nodes[:],
ctx,
@@ -534,19 +574,28 @@ render_nodes :: proc(
case .Unescaped:
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[:])
transformed, perr := apply_pipeline(val, node.filters[:], node.pos)
if perr != nil {
return perr
}
val = transformed
}
if result_str, ok := call_interp_lambda(val); ok {
sub_tpl, perr := parse(result_str, context.temp_allocator, context.temp_allocator)
sub_tpl, perr := parse(
result_str,
fmt.tprintf("<lambda output from '%s'>", 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[:],
sub_tpl.nodes[:],
ctx,
@@ -563,17 +612,26 @@ render_nodes :: proc(
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[:])
transformed, perr := apply_pipeline(val, node.filters[:], node.pos)
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)
sub_tpl, perr := parse(
result_str,
fmt.tprintf("<lambda output from '%s'>", node.key),
context.temp_allocator,
context.temp_allocator,
)
if perr == nil {
render_nodes(
sub_tpl,
sub_tpl.nodes[:],
sub_tpl.nodes[:],
ctx,
@@ -590,20 +648,31 @@ render_nodes :: proc(
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
render_nodes(
current,
all_nodes,
children,
ctx,
partials,
b,
blocks,
) or_return
}
} else {
append(ctx, val)
defer pop(ctx)
render_nodes(all_nodes, children, ctx, partials, b, blocks) or_return
render_nodes(current, all_nodes, children, ctx, partials, b, blocks) or_return
}
}
i += 1 + node.child_count
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[:])
transformed, perr := apply_pipeline(val, node.filters[:], node.pos)
if perr != nil {
return perr
}
@@ -611,7 +680,7 @@ render_nodes :: proc(
}
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
render_nodes(current, all_nodes, children, ctx, partials, b, blocks) or_return
}
i += 1 + node.child_count
@@ -622,7 +691,9 @@ render_nodes :: proc(
name = any_to_string(val)
}
pt, found := partials[name]
if found {
if !found {
warn_missing_partial(current, partials, node, name)
} else {
render_template(pt, ctx, partials, b, nil, node.indent) or_return
}
i += 1
@@ -631,6 +702,7 @@ render_nodes :: proc(
content_nodes: []Node
content_pool: []Node
content_blocks := blocks
render_current := current
found_override := false
if blocks != nil {
@@ -638,6 +710,7 @@ render_nodes :: proc(
content_nodes = o.all_nodes[o.first:o.first + o.count]
content_pool = o.all_nodes
found_override = true
render_current = o.source
}
}
if !found_override {
@@ -649,6 +722,7 @@ render_nodes :: proc(
temp: strings.Builder
strings.builder_init(&temp, context.temp_allocator)
render_nodes(
render_current,
content_pool,
content_nodes,
ctx,
@@ -659,6 +733,7 @@ render_nodes :: proc(
write_indented(b, node.indent, strings.to_string(temp))
} else {
render_nodes(
render_current,
content_pool,
content_nodes,
ctx,
@@ -671,9 +746,12 @@ render_nodes :: proc(
case .Parent:
parent_children := all_nodes[node.first_child:node.first_child + node.child_count]
merged := merge_block_overrides(parent_children, all_nodes, blocks)
merged := merge_block_overrides(parent_children, all_nodes, blocks, current)
pt, found := partials[node.key]
if found {
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
}
i += 1 + node.child_count
@@ -686,6 +764,7 @@ merge_block_overrides :: proc(
children: []Node,
all_nodes: []Node,
existing: map[string]Block_Override,
source: Template,
) -> map[string]Block_Override {
result := make(map[string]Block_Override, context.temp_allocator)
@@ -702,6 +781,7 @@ merge_block_overrides :: proc(
all_nodes = all_nodes,
first = child.first_child,
count = child.child_count,
source = source,
}
}
}
@@ -711,3 +791,115 @@ merge_block_overrides :: proc(
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 = "<input>"
}
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 `{{<name}}` tag
// references a partial that isn't in the partials map.
warn_missing_partial :: proc(
current: Template,
partials: map[string]Template,
node: Node,
name: string,
) {
hint := ""
available := collect_partial_names(partials)
defer delete(available)
suggestion := suggest_correction(available, name)
if suggestion != "" {
hint = fmt.tprintf("did you mean '%s'?", suggestion)
}
msg := fmt.tprintf("partial '%s' not found", name)
path := current.path
if path == "" {
path = "<input>"
}
diag := format_error(path, current.source, node.pos, msg, hint, colorize = should_colorize())
log.warnf("%s", diag)
}
// warn_unmatched_block_overrides checks each `{{$name}}...{{/name}}` block
// defined inside a `{{<parent}}` tag and warns when the name doesn't match
// any block in the parent template.
warn_unmatched_block_overrides :: proc(
current: Template,
parent: Template,
parent_children: []Node,
) {
if len(parent_children) == 0 {
return
}
available := collect_block_names(parent)
defer delete(available)
parent_path := parent.path
if parent_path == "" {
parent_path = "<input>"
}
for child in parent_children {
if child.kind != .Block {
continue
}
matched := false
for name in available {
if name == child.key {
matched = true
break
}
}
if matched {
continue
}
hint := ""
suggestion := suggest_correction(available, child.key)
if suggestion != "" {
hint = fmt.tprintf("did you mean '%s'?", suggestion)
}
msg := fmt.tprintf(
"block override '%s' has no match in parent template '%s'",
child.key,
parent_path,
)
path := current.path
if path == "" {
path = "<input>"
}
diag := format_error(
path,
current.source,
child.pos,
msg,
hint,
colorize = should_colorize(),
)
log.warnf("%s", diag)
}
}