feat: Warns the user if they exceed 16 context frames.

This commit is contained in:
Spencer Brower
2026-07-28 15:41:44 -04:00
parent f1c7d02bc1
commit 6d7b88a8d6
5 changed files with 240 additions and 152 deletions
+11 -7
View File
@@ -2,18 +2,25 @@
- Polish existing features before moving on to new ones.
- [ ] Improve diagnostics
- [ ] show "stack traces" in template error diagnostics
- [ ] better diagnostics for syntax errors in treesitter.
- [ ] Ensure diagnostics for MAX_CONTEXT_DEPTH are good.
- [ ] show a proper diagnostic for timezones
- currently "unable to load timezone 'America/New_Yorkskie'"
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
- [x] Simplify / unify template context stack. Come up with a name for it.
- [x] `render_template` should accept `Template_Context`, not `any`
- [ ] Load grammars dynamically
- [ ] consider adding a limit to the context stack in mustache.
- [ ] better diagnostics for syntax errors in treesitter.
- [x] consider adding a limit to the context stack in mustache.
- [x] Add heading ids as a default on extension.
- [ ] show "stack traces" in template error diagnostics
- [ ] starred must be a param.
- [ ] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`.
- [x] Add a `#config(MAX_CONTEXT_DEPTH, 16?)` to `mustache`.
- [ ] Documentation
- [ ] talk about the context stack (and its limit).
- [ ] menu system
- [ ] like Hugo's, but warn(/fail?) if menus are defined in the config *and* pages.
- i.e. force the user to choose one or the other.
- [ ] Don't show annoying log output in tests.
## Performance
@@ -69,9 +76,6 @@
- [ ] display an error when no part of the date appears in the output.
- [ ] Handle 0 and whitespace padding i.e. "_2" -> " 2"
- [ ] Do we *need* mustache.Date_Components, or can we use core:time/datetime.DateTime?
- [ ] show a proper diagnostic for timezones
- currently "unable to load timezone 'America/New_Yorkskie'"
- want rust style diagnostic and better message, maybe "unknown timezone 'America/New_Yorkskie'"
## General
- [ ] get rid of the global variables in the `treesitter` package.
+76 -31
View File
@@ -3,9 +3,19 @@ package mustache
import "base:runtime"
import "core:fmt"
import "core:log"
import "core:reflect"
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
// ---------------------------------------------------------------------------
@@ -22,14 +32,18 @@ Error_Body :: struct {
}
// Error is nil when no error occurred.
Error :: union { Error_Body }
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 {}
case Error_Body:
return e
case:
return {}
}
}
@@ -143,11 +157,9 @@ render :: proc(
err: Error,
) {
builder: strings.Builder
strings.builder_init(&builder, allocator)
defer strings.builder_destroy(&builder)
strings.builder_init(&builder, context.temp_allocator)
ctx := make([dynamic]any, 0, 4, allocator)
defer delete(ctx)
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.
@@ -331,12 +343,7 @@ parse_section :: proc(
idx := len(nodes)
append(
nodes,
Node {
kind = .Parent,
key = tok.value,
indent = tok.indent,
pos = tok.pos,
},
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)]
@@ -344,15 +351,7 @@ parse_section :: proc(
case .Block_Open:
pos^ += 1
idx := len(nodes)
append(
nodes,
Node {
kind = .Block,
key = tok.value,
indent = tok.indent,
pos = tok.pos,
},
)
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)]
}
@@ -499,21 +498,29 @@ remove_line_indent :: proc(s: string, indent: string, allocator := context.alloc
render_template :: proc(
pt: Template,
ctx: ^[dynamic]any,
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}
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) {
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
@@ -544,7 +551,7 @@ write_indented :: proc(b: ^strings.Builder, indent: string, content: string, at_
render_nodes :: proc(
current: Template,
nodes: []Node,
ctx: ^[dynamic]any,
ctx: ^Context_Stack,
partials: map[string]Template,
b: ^strings.Builder,
blocks: map[string]Block_Override = nil,
@@ -682,7 +689,7 @@ render_nodes :: proc(
if elem_info != nil {
for j in 0 ..< count {
elem := extract_list_element(elem_info, data, j)
append(ctx, elem)
context_push(ctx, elem, current, node)
defer pop(ctx)
render_nodes(
current,
@@ -695,9 +702,17 @@ render_nodes :: proc(
) or_return
}
} else {
append(ctx, val)
context_push(ctx, val, current, node)
defer pop(ctx)
render_nodes(current, children, ctx, partials, b, blocks, indent_state) or_return
render_nodes(
current,
children,
ctx,
partials,
b,
blocks,
indent_state,
) or_return
}
}
i += 1 + len(node.children)
@@ -715,7 +730,15 @@ render_nodes :: proc(
val = transformed
}
if !is_truthy(val) {
render_nodes(current, node.children, ctx, partials, b, blocks, indent_state) or_return
render_nodes(
current,
node.children,
ctx,
partials,
b,
blocks,
indent_state,
) or_return
}
i += 1 + len(node.children)
@@ -937,3 +960,25 @@ warn_unmatched_block_overrides :: proc(
}
}
context_push :: proc(ctx: ^Context_Stack, val: any, current: Template, node: Node) {
append(ctx, val)
if len(ctx^) == MAX_CONTEXT_DEPTH + 1 {
warn_context_depth(current, node)
}
}
// warn_context_depth emits a diagnostic warning pointing at the section tag
// whose push carried the context stack past MAX_CONTEXT_DEPTH.
warn_context_depth :: proc(current: Template, node: Node) {
msg := fmt.tprintf(
"context stack depth exceeded %d (possible recursive section/partial)",
MAX_CONTEXT_DEPTH,
)
path := current.path
if path == "" {
path = "<input>"
}
diag := format_error(path, current.source, node.pos, msg, "", colorize = should_colorize())
log.warnf("%s", diag)
}
+38
View File
@@ -1,7 +1,9 @@
#+test
package mustache
import "core:log"
import "core:mem"
import "core:strings"
import "core:testing"
@(test)
@@ -124,3 +126,39 @@ leak_repeated_render :: proc(t: ^testing.T) {
}
}
// A deeply nested map pushes the context stack past MAX_CONTEXT_DEPTH (16).
// The depth warning must be non-fatal: rendering still succeeds.
@(test)
test_context_depth_warns :: proc(t: ^testing.T) {
context.logger = log.nil_logger()
AMT :: MAX_CONTEXT_DEPTH + 2
// Build AMT nested {x: {...}} levels; the innermost holds `leaf`.
data := make(map[string]any, context.temp_allocator)
data["leaf"] = "found"
for _ in 0 ..< AMT {
outer := make(map[string]any, context.temp_allocator)
outer["x"] = data
data = outer
}
// Template: AMT nested {{#x}} sections around {{leaf}}.
src: strings.Builder
strings.builder_init(&src, context.temp_allocator)
for _ in 0 ..< AMT do strings.write_string(&src, "{{#x}}")
strings.write_string(&src, "{{leaf}}")
for _ in 0 ..< AMT do strings.write_string(&src, "{{/x}}")
template := strings.to_string(src)
tmpl, perr := parse(template, "<depth-test>", context.temp_allocator)
testing.expect(t, perr == nil, "should parse")
if perr != nil {
return
}
result, rerr := render(tmpl, data, allocator = context.temp_allocator)
testing.expect(t, rerr == nil, "depth warning must be non-fatal")
testing.expect_value(t, result, "found")
}
+1 -4
View File
@@ -153,10 +153,7 @@ suggest_correction :: proc(available: []string, missing: string) -> string {
if len(available) == 0 || len(missing) == 0 {
return ""
}
threshold := 2
if len(missing) > 8 {
threshold = len(missing) / 4
}
threshold := max(2, len(missing) / 3)
best: string
best_dist := threshold + 1
+5 -1
View File
@@ -10,9 +10,13 @@ Inner :: struct {
bar: int,
}
Page :: struct {
title: string,
}
Outer :: struct {
title: string,
page.title: string,
page: Page,
inner: Inner,
numbers: [3]int,
}