diff --git a/CHANGELOG.md b/CHANGELOG.md index b687606..bb0e97f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## Unreleased + +### Performance + +- **Execution context**: Child contexts (created by `{% for %}`, `{% with %}`, `{% macro %}`, and `block.Super`) no longer copy the parent's private data — they layer over it via a scope chain. The user-supplied context and set globals are no longer merged into a fresh map on each `Execute`; they are resolved through a read-only view. This removes the per-nesting-level copy and the per-`Execute` merge. Rendering output is unchanged. +- **Macro-clash check**: The per-`Execute` scan that compares context keys against exported macro names is now skipped when the template exports no macros (the common case). +- **`TemplateSet.SkipContextValidation`** (new, default `false`): when set, skips the per-`Execute` check that every context key is a valid identifier. The check scales with context size and only guards against keys that cannot be referenced in a template anyway. Enable it in hot paths that render with large, trusted contexts. With a 1000-key context this drops per-`Execute` context handling from ~19µs to ~0.65µs. + +### Backwards-Incompatible Changes + +- **`ExecutionContext.Private`** changed from `Context` (a map) to `Scope`. Custom tags must use methods instead of indexing: + - `ctx.Private[k]` → `ctx.Private.Get(k)` (returns `(any, bool)`) + - `ctx.Private[k] = v` → `ctx.Private.Set(k, v)` + - `delete(ctx.Private, k)` → `ctx.Private.Delete(k)` + - `for k, v := range ctx.Private` → `ctx.Private.Range(func(k string, v any) bool { ... })` + - `ctx.Private.Update(m)` → call `ctx.Private.Set` per entry +- **`ExecutionContext.Public`** changed from `Context` (a map) to `PublicContext`, a read-only view over the user context and set globals: + - `ctx.Public[k]` → `ctx.Public.Get(k)` (returns `(any, bool)`; includes globals) + - Globals are no longer present as plain entries in `Public`; reach them via `Get`/`Range`. + - `Public` has no setter (template data is read-only by contract). + ## v7.0.0-alpha.2 This release brings pongo2 significantly closer to Django template behavior. diff --git a/context.go b/context.go index 4892be4..2a797a0 100644 --- a/context.go +++ b/context.go @@ -63,8 +63,8 @@ func (c Context) Update(other Context) Context { // to create scoped child contexts within tags. // // Context hierarchy: -// - Public: User data (READ-ONLY) -// - Private: Scoped engine data (copied per child context) +// - Public: User data + set globals (READ-ONLY view) +// - Private: Scoped engine data (layered per child context, not copied) // - Shared: Global state (same instance across all contexts) type ExecutionContext struct { // The template being executed (provides config, inheritance, and TemplateSet access). @@ -77,13 +77,24 @@ type ExecutionContext struct { // The |safe filter bypasses escaping. Autoescape bool - // User-provided data from Execute(). Treat as READ-ONLY to avoid side effects. - Public Context + // User-provided data from Execute() overlaying the set's globals. Read-only: + // access via Public.Get/Has/Range, never by indexing. The caller's map is + // used directly (not copied), so it must not be mutated during rendering. + Public PublicContext + + // parent links a child execution context to the one it was derived from, + // forming the scope chain walked by Private. nil for a root context. + parent *ExecutionContext + + // privateVars holds this context's own private layer. Allocated lazily on + // first write. Parent layers are reached through parent, never copied. + privateVars Context // Engine-managed scoped data (e.g., "forloop" from {% for %}, variables - // from {% set %}, or macros). Child contexts receive a copy, enabling - // isolated modifications. - Private Context + // from {% set %}, or macros). A child context layers its own data over the + // parent's without copying; writes touch only the child's layer. Access via + // Private.Get/Set/Delete/Range, never by indexing. + Private Scope // Data shared across all contexts during a single render. Use for cross-scope // tag communication. @@ -102,41 +113,37 @@ var pongo2MetaContext = Context{ } func newExecutionContext(tpl *Template, ctx Context) *ExecutionContext { - privateCtx := make(Context) - - // Make the pongo2-related funcs/vars available to the context - privateCtx["pongo2"] = pongo2MetaContext - - return &ExecutionContext{ + newctx := &ExecutionContext{ template: tpl, - Public: ctx, - Private: privateCtx, - Shared: make(Context), - Autoescape: tpl.set.autoescape, - tagState: make(map[any]any), + // Make the pongo2-related funcs/vars available to the context + privateVars: Context{"pongo2": pongo2MetaContext}, + Public: PublicContext{vars: ctx, globals: tpl.set.Globals}, + Shared: make(Context), + Autoescape: tpl.set.autoescape, + tagState: make(map[any]any), } + newctx.Private = Scope{ec: newctx} + return newctx } // NewChildExecutionContext creates a new execution context that inherits from -// a parent context. The child context shares the same Public context and Shared -// context as the parent, but gets its own Private context (pre-populated with -// copies of the parent's private data). This is useful for custom tags that need -// to create isolated scopes while maintaining access to the template's data. +// a parent context. The child shares the same Public and Shared context as the +// parent, and layers a fresh, empty Private scope over the parent's (parent data +// is reached by walking the scope chain, never copied). Writes to the child's +// Private touch only the child's layer, leaving the parent untouched. Useful for +// custom tags that need an isolated scope while keeping access to outer data. func NewChildExecutionContext(parent *ExecutionContext) *ExecutionContext { newctx := &ExecutionContext{ template: parent.template, + parent: parent, Public: parent.Public, - Private: make(Context), + Shared: parent.Shared, Autoescape: parent.Autoescape, tagState: parent.tagState, } - newctx.Shared = parent.Shared - - // Copy all existing private items - newctx.Private.Update(parent.Private) - + newctx.Private = Scope{ec: newctx} return newctx } diff --git a/context_test.go b/context_test.go index 50163db..cf4790a 100644 --- a/context_test.go +++ b/context_test.go @@ -68,3 +68,31 @@ func TestCheckForValidIdentifiersWithEmptyKey(t *testing.T) { t.Error("expected error for empty key, got nil") } } + +func TestSkipContextValidation(t *testing.T) { + ctx := Context{ + "valid_key": "rendered", + "in-valid-key": "ignored", // not a valid identifier + } + + set := NewSet("skip-validation", &DummyLoader{}) + tpl, err := set.FromString("{{ valid_key }}") + if err != nil { + t.Fatalf("FromString: %v", err) + } + + // Default: validation enabled -> the invalid key triggers an error. + if _, err := tpl.Execute(ctx); err == nil { + t.Error("expected validation error for invalid context key, got nil") + } + + // Opt-out: validation skipped -> renders without error. + set.SkipContextValidation = true + out, err := tpl.Execute(ctx) + if err != nil { + t.Fatalf("unexpected error with SkipContextValidation: %v", err) + } + if out != "rendered" { + t.Errorf("got %q, want %q", out, "rendered") + } +} diff --git a/docs/custom-extensions.md b/docs/custom-extensions.md index ed3b1fe..8b6852c 100644 --- a/docs/custom-extensions.md +++ b/docs/custom-extensions.md @@ -369,11 +369,11 @@ Access template context during execution: ```go func (node *myNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.TemplateWriter) error { - // Read from public context (user-provided) - user := ctx.Public["user"] + // Read from public context (user-provided + set globals; read-only) + user, _ := ctx.Public.Get("user") // Read/write private context (internal use) - ctx.Private["my_counter"] = 0 + ctx.Private.Set("my_counter", 0) // Check autoescape setting if ctx.Autoescape { @@ -398,7 +398,7 @@ func (node *myNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.Template childCtx := pongo2.NewChildExecutionContext(ctx) // Add scoped variables - childCtx.Private["loop_var"] = someValue + childCtx.Private.Set("loop_var", someValue) // Execute wrapped content with child context return node.wrapper.Execute(childCtx, writer) diff --git a/docs/write_tags.md b/docs/write_tags.md index 8179d77..e2365fe 100644 --- a/docs/write_tags.md +++ b/docs/write_tags.md @@ -300,8 +300,8 @@ type ExecutionContext struct { macroDepth int // Recursion depth for macros Autoescape bool // HTML auto-escaping enabled - Public Context // User-provided context (read-only by convention) - Private Context // Internal variables (forloop, macro args, etc.) + Public PublicContext // User context + set globals (read-only view) + Private Scope // Internal variables (forloop, macro args, etc.) Shared Context // Shared across all templates in execution } ``` @@ -310,13 +310,13 @@ type ExecutionContext struct { ```go func (node *myNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error { - // Read from public context (user-provided data) - if user, ok := ctx.Public["user"]; ok { + // Read from public context (user-provided data + set globals) + if user, ok := ctx.Public.Get("user"); ok { // Use user data } // Read from private context (internal variables) - if counter, ok := ctx.Private["forloop"]; ok { + if counter, ok := ctx.Private.Get("forloop"); ok { // Inside a for loop } @@ -334,16 +334,14 @@ func (node *myNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error ```go func (node *myNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error { // Set private variable (for internal use) - ctx.Private["my_counter"] = 0 + ctx.Private.Set("my_counter", 0) // Set shared variable (available in included templates) ctx.Shared["breadcrumbs"] = breadcrumbList - // Update context with map - ctx.Private.Update(pongo2.Context{ - "item": currentItem, - "index": currentIndex, - }) + // Set multiple private variables + ctx.Private.Set("item", currentItem) + ctx.Private.Set("index", currentIndex) return nil } @@ -359,7 +357,7 @@ func (node *myNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error childCtx := pongo2.NewChildExecutionContext(ctx) // Add scoped variables (only visible in child) - childCtx.Private["scoped_var"] = someValue + childCtx.Private.Set("scoped_var", someValue) // Execute wrapped content with child context err := node.wrapper.Execute(childCtx, writer) @@ -719,17 +717,17 @@ func (node *tagEachNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.Tem childCtx := pongo2.NewChildExecutionContext(ctx) // Set item variable - childCtx.Private[node.itemName] = listVal.Index(i).Interface() + childCtx.Private.Set(node.itemName, listVal.Index(i).Interface()) // Set loop metadata - childCtx.Private["eachloop"] = &eachLoop{ + childCtx.Private.Set("eachloop", &eachLoop{ Counter: i + 1, Counter0: i, First: i == 0, Last: i == length-1, Revcounter: length - i, Revcounter0: length - i - 1, - } + }) // Execute body err := node.wrapper.Execute(childCtx, writer) @@ -1256,7 +1254,7 @@ Prevent variable leakage: ```go func (node *myNode) Execute(ctx *pongo2.ExecutionContext, writer pongo2.TemplateWriter) error { childCtx := pongo2.NewChildExecutionContext(ctx) - childCtx.Private["local_var"] = value + childCtx.Private.Set("local_var", value) return node.wrapper.Execute(childCtx, writer) // local_var is not visible in parent context diff --git a/pongo2_template_test.go b/pongo2_template_test.go index b8335e8..a9909bd 100644 --- a/pongo2_template_test.go +++ b/pongo2_template_test.go @@ -667,6 +667,57 @@ func BenchmarkExecuteComplex(b *testing.B) { } } +// BenchmarkExecuteLargeContext renders a trivial template against a large input +// context. The template references only a handful of the keys, so any time or +// allocations that scale with the map size come from per-Execute context +// handling rather than from rendering. This isolates the cost that the old code +// paid by merging/copying the entire input map on every Execute. +func BenchmarkExecuteLargeContext(b *testing.B) { + const numKeys = 1000 + + ctx := make(pongo2.Context, numKeys) + for i := 0; i < numKeys; i++ { + ctx["key_"+strconv.Itoa(i)] = i + } + + set := pongo2.NewSet("bench", pongo2.MustNewLocalFileSystemLoader("")) + tpl, err := set.FromString("{{ key_0 }}-{{ key_1 }}-{{ key_999 }}") + if err != nil { + b.Fatal(err) + } + + for b.Loop() { + if err := tpl.ExecuteWriterUnbuffered(ctx, io.Discard); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkExecuteLargeContextSkipValidation is BenchmarkExecuteLargeContext with +// per-Execute context validation disabled, showing the cost of validating every +// key on every render against a large context. +func BenchmarkExecuteLargeContextSkipValidation(b *testing.B) { + const numKeys = 1000 + + ctx := make(pongo2.Context, numKeys) + for i := 0; i < numKeys; i++ { + ctx["key_"+strconv.Itoa(i)] = i + } + + set := pongo2.NewSet("bench", pongo2.MustNewLocalFileSystemLoader("")) + set.SkipContextValidation = true + tpl, err := set.FromString("{{ key_0 }}-{{ key_1 }}-{{ key_999 }}") + if err != nil { + b.Fatal(err) + } + + for b.Loop() { + if err := tpl.ExecuteWriterUnbuffered(ctx, io.Discard); err != nil { + b.Fatal(err) + } + } +} + func BenchmarkCompileAndExecuteComplex(b *testing.B) { set := pongo2.NewSet("bench", pongo2.MustNewLocalFileSystemLoader("")) buf, err := os.ReadFile("template_tests/complex.tpl") diff --git a/pongo2_test.go b/pongo2_test.go index 35eb624..2bed194 100644 --- a/pongo2_test.go +++ b/pongo2_test.go @@ -94,7 +94,8 @@ func TestImplicitExecCtx(t *testing.T) { res, err := tpl.Execute(pongo2.Context{ "Value": val, "ImplicitExec": func(ctx *pongo2.ExecutionContext) string { - return ctx.Public["Value"].(string) + v, _ := ctx.Public.Get("Value") + return v.(string) }, }) if err != nil { diff --git a/scope.go b/scope.go new file mode 100644 index 0000000..64309ea --- /dev/null +++ b/scope.go @@ -0,0 +1,145 @@ +package pongo2 + +// tombstone marks a key as deleted within a child Scope. It masks a value +// inherited from a parent Scope without mutating the parent, so the deletion is +// only visible to the child and its descendants. +var tombstone = &struct{}{} + +// Scope is a chain of variable layers representing nested template scopes (for +// example the body of a {% for %} or {% with %} block). Each child layers its +// own variables over its parent without copying, so lookups walk outward from +// the innermost scope to the outermost. Writes only ever touch the innermost +// (own) layer; a parent scope is never modified by its children. +// +// Scope is the type of ExecutionContext.Private. It is a lightweight handle onto +// its owning ExecutionContext and the chain of parents — there is no separate +// per-scope allocation. Access it through the Get/Set/Delete/Has/Range methods +// rather than indexing a map. +type Scope struct { + ec *ExecutionContext +} + +// Get returns the value bound to key, searching this scope and then its parents. +// The second return value reports whether the key was found. A key masked by +// Delete in a closer scope is reported as not found. +func (s Scope) Get(key string) (any, bool) { + for c := s.ec; c != nil; c = c.parent { + if v, ok := c.privateVars[key]; ok { + if v == tombstone { + return nil, false + } + return v, true + } + } + return nil, false +} + +// Has reports whether key is bound in this scope or any parent. +func (s Scope) Has(key string) bool { + _, ok := s.Get(key) + return ok +} + +// Set binds key to value in this scope's own layer, shadowing any binding +// inherited from a parent. Parent scopes are never modified. The own layer is +// allocated lazily on first write. +func (s Scope) Set(key string, value any) { + if s.ec.privateVars == nil { + s.ec.privateVars = make(Context) + } + s.ec.privateVars[key] = value +} + +// Delete removes key from this scope's view. If the key lives only in this +// scope's own layer it is removed outright; if it is inherited from a parent it +// is masked with a tombstone so the parent remains untouched. +func (s Scope) Delete(key string) { + delete(s.ec.privateVars, key) + if s.ec.parent != nil { + if _, ok := (Scope{ec: s.ec.parent}).Get(key); ok { + if s.ec.privateVars == nil { + s.ec.privateVars = make(Context) + } + s.ec.privateVars[key] = tombstone + } + } +} + +// Range calls fn for each key visible in this scope, with closer scopes +// shadowing parents and tombstoned keys skipped. Iteration stops early if fn +// returns false. +func (s Scope) Range(fn func(key string, value any) bool) { + for key, value := range s.flatten() { + if !fn(key, value) { + return + } + } +} + +// flatten collapses the scope chain into a single map, with closer scopes +// overriding parents and tombstoned keys removed. Used by tags that need a +// full snapshot of the private context (for example {% include %}). +func (s Scope) flatten() Context { + return flattenScope(s.ec) +} + +func flattenScope(ec *ExecutionContext) Context { + if ec == nil { + return make(Context) + } + out := flattenScope(ec.parent) + for key, value := range ec.privateVars { + if value == tombstone { + delete(out, key) + continue + } + out[key] = value + } + return out +} + +// PublicContext is a read-only view of the data available to a template: the +// user-supplied context overlaying the template set's globals. It is read-only +// by design — a template must not mutate caller-provided data — so it exposes +// no setter. Globals are reached only through Get/Has/Range, so callers cannot +// bypass them by indexing a map directly. +type PublicContext struct { + vars Context // user-supplied context (used directly, never copied) + globals Context // template set globals +} + +// Get returns the value bound to key, checking the user context first and then +// the set globals. The second return value reports whether key was found. +func (p PublicContext) Get(key string) (any, bool) { + if v, ok := p.vars[key]; ok { + return v, true + } + if v, ok := p.globals[key]; ok { + return v, true + } + return nil, false +} + +// Has reports whether key is available in the user context or globals. +func (p PublicContext) Has(key string) bool { + _, ok := p.Get(key) + return ok +} + +// Range calls fn for each key available, with user-context entries shadowing +// globals of the same name. Iteration stops early if fn returns false. +func (p PublicContext) Range(fn func(key string, value any) bool) { + for key, value := range p.globals { + if _, shadowed := p.vars[key]; shadowed { + continue + } + if !fn(key, value) { + return + } + } + for key, value := range p.vars { + if !fn(key, value) { + return + } + } +} diff --git a/tags_block.go b/tags_block.go index 0bf66b9..83eb426 100644 --- a/tags_block.go +++ b/tags_block.go @@ -79,10 +79,10 @@ func (node *tagBlockNode) Execute(ctx *ExecutionContext, writer TemplateWriter) } blockWrapper := blockWrappers[lenBlockWrappers-1] - ctx.Private["block"] = tagBlockInformation{ + ctx.Private.Set("block", tagBlockInformation{ ctx: ctx, wrappers: blockWrappers[0 : lenBlockWrappers-1], - } + }) err := blockWrapper.Execute(ctx, writer) if err != nil { return err @@ -109,10 +109,10 @@ func (t tagBlockInformation) Super() (*Value, error) { } superCtx := NewChildExecutionContext(t.ctx) - superCtx.Private["block"] = tagBlockInformation{ + superCtx.Private.Set("block", tagBlockInformation{ ctx: t.ctx, wrappers: t.wrappers[0 : lenWrappers-1], - } + }) blockWrapper := t.wrappers[lenWrappers-1] buf := bytes.NewBufferString("") diff --git a/tags_cycle.go b/tags_cycle.go index 6e82862..a045180 100644 --- a/tags_cycle.go +++ b/tags_cycle.go @@ -106,7 +106,7 @@ func (node *tagCycleNode) Execute(ctx *ExecutionContext, writer TemplateWriter) } if node.asName != "" { - ctx.Private[node.asName] = cycleValue + ctx.Private.Set(node.asName, cycleValue) } if !node.silent { // Apply autoescape like Django's render_value_in_context diff --git a/tags_for.go b/tags_for.go index b082d3e..1d2d54c 100644 --- a/tags_for.go +++ b/tags_for.go @@ -80,7 +80,7 @@ type tagForLoopInformation struct { func (node *tagForNode) Execute(ctx *ExecutionContext, writer TemplateWriter) (forError error) { // Backup forloop (as parentloop in public context), key-name and value-name forCtx := NewChildExecutionContext(ctx) - parentloop := forCtx.Private["forloop"] + parentloop, _ := forCtx.Private.Get("forloop") // Create loop struct loopInfo := &tagForLoopInformation{ @@ -93,7 +93,7 @@ func (node *tagForNode) Execute(ctx *ExecutionContext, writer TemplateWriter) (f } // Register loopInfo in public context - forCtx.Private["forloop"] = loopInfo + forCtx.Private.Set("forloop", loopInfo) obj, err := node.objectEvaluator.Evaluate(forCtx) if err != nil { @@ -104,9 +104,9 @@ func (node *tagForNode) Execute(ctx *ExecutionContext, writer TemplateWriter) (f // There's something to iterate over (correct type and at least 1 item) // Update loop infos and public context - forCtx.Private[node.key] = key + forCtx.Private.Set(node.key, key) if value != nil && node.value != "" { - forCtx.Private[node.value] = value + forCtx.Private.Set(node.value, value) } loopInfo.Counter = idx + 1 loopInfo.Counter0 = idx diff --git a/tags_import.go b/tags_import.go index 97fc92f..577a6cd 100644 --- a/tags_import.go +++ b/tags_import.go @@ -46,9 +46,9 @@ type tagImportNode struct { func (node *tagImportNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error { for name, macro := range node.macros { func(name string, macro *tagMacroNode) { - ctx.Private[name] = func(args ...*Value) (*Value, error) { + ctx.Private.Set(name, func(args ...*Value) (*Value, error) { return macro.call(ctx, args...) - } + }) }(name, macro) } return nil diff --git a/tags_include.go b/tags_include.go index 57a540a..d817632 100644 --- a/tags_include.go +++ b/tags_include.go @@ -53,8 +53,11 @@ func (node *tagIncludeNode) Execute(ctx *ExecutionContext, writer TemplateWriter // Fill the context with all data from the parent if !node.only { - includeCtx.Update(ctx.Public) - includeCtx.Update(ctx.Private) + ctx.Public.Range(func(key string, value any) bool { + includeCtx[key] = value + return true + }) + includeCtx.Update(ctx.Private.flatten()) } // Put all custom with-pairs into the context diff --git a/tags_macro.go b/tags_macro.go index 59c75ea..a148e77 100644 --- a/tags_macro.go +++ b/tags_macro.go @@ -69,7 +69,7 @@ type tagMacroNode struct { // Execute registers the macro as a callable function in the private context. // The macro can then be called like {{ macro_name(args) }}. func (node *tagMacroNode) Execute(ctx *ExecutionContext, writer TemplateWriter) error { - ctx.Private[node.name] = func(args ...*Value) (*Value, error) { + ctx.Private.Set(node.name, func(args ...*Value) (*Value, error) { ctx.macroDepth++ defer func() { ctx.macroDepth-- @@ -80,7 +80,7 @@ func (node *tagMacroNode) Execute(ctx *ExecutionContext, writer TemplateWriter) } return node.call(ctx, args...) - } + }) return nil } @@ -88,22 +88,24 @@ func (node *tagMacroNode) Execute(ctx *ExecutionContext, writer TemplateWriter) // call executes the macro body with the provided arguments and returns the // rendered output as a safe value. It creates an isolated context for execution. func (node *tagMacroNode) call(ctx *ExecutionContext, args ...*Value) (*Value, error) { - argsCtx := make(Context) + // Make a context for the macro execution. Declared arguments are written + // directly into its private scope (no intermediate map). Default values are + // evaluated against the caller's context. + macroCtx := NewChildExecutionContext(ctx) for k, v := range node.args { if v == nil { - // User did not provided a default value - argsCtx[k] = nil - } else { - // Evaluate the default value - valueExpr, err := v.Evaluate(ctx) - if err != nil { - ctx.Logf(err.Error()) - return AsSafeValue(""), err - } - - argsCtx[k] = valueExpr.Interface() + // User did not provide a default value + macroCtx.Private.Set(k, nil) + continue + } + // Evaluate the default value + valueExpr, err := v.Evaluate(ctx) + if err != nil { + ctx.Logf(err.Error()) + return AsSafeValue(""), err } + macroCtx.Private.Set(k, valueExpr.Interface()) } if len(args) > len(node.argsOrder) { @@ -114,14 +116,9 @@ func (node *tagMacroNode) call(ctx *ExecutionContext, args ...*Value) (*Value, e return AsSafeValue(""), err } - // Make a context for the macro execution - macroCtx := NewChildExecutionContext(ctx) - - // Register all arguments in the private context - macroCtx.Private.Update(argsCtx) - + // Override with the positional arguments passed by the caller. for idx, argValue := range args { - macroCtx.Private[node.argsOrder[idx]] = argValue.Interface() + macroCtx.Private.Set(node.argsOrder[idx], argValue.Interface()) } var b bytes.Buffer diff --git a/tags_set.go b/tags_set.go index 2b7bd0e..3f4c077 100644 --- a/tags_set.go +++ b/tags_set.go @@ -44,7 +44,7 @@ func (node *tagSetNode) Execute(ctx *ExecutionContext, writer TemplateWriter) er return err } - ctx.Private[node.name] = value + ctx.Private.Set(node.name, value) return nil } diff --git a/tags_ssi.go b/tags_ssi.go index 287fc68..3c337cc 100644 --- a/tags_ssi.go +++ b/tags_ssi.go @@ -38,8 +38,11 @@ func (node *tagSSINode) Execute(ctx *ExecutionContext, writer TemplateWriter) er if node.template != nil { // Execute the template within the current context includeCtx := make(Context) - includeCtx.Update(ctx.Public) - includeCtx.Update(ctx.Private) + ctx.Public.Range(func(key string, value any) bool { + includeCtx[key] = value + return true + }) + includeCtx.Update(ctx.Private.flatten()) err := node.template.execute(includeCtx, writer) if err != nil { diff --git a/tags_widthratio.go b/tags_widthratio.go index 4410788..ce59d0b 100644 --- a/tags_widthratio.go +++ b/tags_widthratio.go @@ -80,7 +80,7 @@ func (node *tagWidthratioNode) Execute(ctx *ExecutionContext, writer TemplateWri return err } } else { - ctx.Private[node.ctxName] = value + ctx.Private.Set(node.ctxName, value) } return nil diff --git a/tags_with.go b/tags_with.go index 17a5b59..a375632 100644 --- a/tags_with.go +++ b/tags_with.go @@ -57,7 +57,7 @@ func (node *tagWithNode) Execute(ctx *ExecutionContext, writer TemplateWriter) e if err != nil { return err } - withctx.Private[key] = val + withctx.Private.Set(key, val) } return node.wrapper.Execute(withctx, writer) diff --git a/template.go b/template.go index e92cd9e..bbcea1f 100644 --- a/template.go +++ b/template.go @@ -230,22 +230,22 @@ func (tpl *Template) newContextForExecution(context Context) (*Template, *Execut parent = parent.parent } - // Create context if none is given - newContext := make(Context) - newContext.Update(tpl.set.Globals) - - if context != nil { - newContext.Update(context) - - if len(newContext) > 0 { - // Check for context name syntax - err := newContext.checkForValidIdentifiers() + // The user-supplied context is used directly (no copy); the set's globals + // are layered behind it by the execution context's Public view rather than + // merged into a fresh map. + if len(context) > 0 { + // Check for context name syntax (unless the set opts out for performance). + if !tpl.set.SkipContextValidation { + err := context.checkForValidIdentifiers() if err != nil { return parent, nil, err } + } - // Check for clashes with macro names - for k := range newContext { + // Check for clashes with macro names. Skip the scan entirely when the + // template exports no macros — there is nothing to clash with. + if len(tpl.exportedMacros) > 0 { + for k := range context { _, has := tpl.exportedMacros[k] if has { return parent, nil, &Error{ @@ -259,7 +259,7 @@ func (tpl *Template) newContextForExecution(context Context) (*Template, *Execut } // Create operational context - ctx := newExecutionContext(parent, newContext) + ctx := newExecutionContext(parent, context) return parent, ctx, nil } diff --git a/template_sets.go b/template_sets.go index 016d976..a130984 100644 --- a/template_sets.go +++ b/template_sets.go @@ -42,6 +42,13 @@ type TemplateSet struct { // When true (default), string output will be escaped for safety. autoescape bool + // SkipContextValidation disables the per-Execute check that every key in the + // user-supplied context is a valid identifier. The check is a usability guard + // (an invalid key cannot be referenced in a template anyway) and scales with + // the size of the context map. Set this to true in hot paths that render with + // large contexts and trust their keys. Default false (validation enabled). + SkipContextValidation bool + // Options allow you to change the behavior of template-engine. // You can change the options before calling the Execute method. Options *Options diff --git a/variable.go b/variable.go index ed83be2..4325271 100644 --- a/variable.go +++ b/variable.go @@ -295,11 +295,14 @@ func (vr *variableResolver) resolveArrayDefinition(ctx *ExecutionContext) (*Valu } // lookupInitialValue looks up the first part of the variable in the context. +// Private (scoped engine data) takes precedence over Public (user data and the +// set's globals). func (vr *variableResolver) lookupInitialValue(ctx *ExecutionContext) reflect.Value { - val, inPrivate := ctx.Private[vr.parts[0].s] - if !inPrivate { - val = ctx.Public[vr.parts[0].s] + key := vr.parts[0].s + if val, ok := ctx.Private.Get(key); ok { + return reflect.ValueOf(val) } + val, _ := ctx.Public.Get(key) return reflect.ValueOf(val) }