From d6f9411513d14af2f11fb787d5c43f7f7bd1c9d4 Mon Sep 17 00:00:00 2001 From: Yash Mehrotra Date: Wed, 27 May 2026 13:10:30 +0530 Subject: [PATCH] feat(gotemplate): add delimset and value function support --- template.go | 66 ++++++++++++++++++++- template_test.go | 148 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 213 insertions(+), 1 deletion(-) diff --git a/template.go b/template.go index 41300c7b0..9f54937c7 100644 --- a/template.go +++ b/template.go @@ -47,6 +47,19 @@ type Template struct { RightDelim string `yaml:"-" json:"-"` LeftDelim string `yaml:"-" json:"-"` + // DelimSets, when non-empty, runs the gotemplate over the input once per + // delimiter pair, feeding the output of each pass into the next. Useful for + // inputs that mix delimiter styles (e.g. {{ }} and $( )). + // A header (# gotemplate: left-delim=… right-delim=…) overrides DelimSets to + // a single pass. Falls back to LeftDelim/RightDelim if both are set, otherwise + // to the default {{ }}. + DelimSets []Delims `yaml:"-" json:"-"` + + // ValueFunctions, when true, exposes each key in the environment as a + // zero-arg function in addition to dot-access. Enables {{ foo }} alongside + // the standard {{ .foo }}. + ValueFunctions bool `yaml:"-" json:"-"` + // Pass in additional cel-env options like functions // that aren't simple enough to be included in Functions CelEnvs []cel.EnvOption `yaml:"-" json:"-"` @@ -285,7 +298,7 @@ func RunTemplateContext(ctx commonsContext.Context, environment map[string]any, // gotemplate if template.Template != "" { - return goTemplate(ctx, template, environment) + return runGoTemplate(ctx, template, environment) } // cel-go @@ -303,6 +316,57 @@ func RunTemplateContext(ctx commonsContext.Context, environment map[string]any, return "", nil } +func runGoTemplate(ctx commonsContext.Context, template Template, environment map[string]any) (string, error) { + // Parse the gotemplate header once up-front so we can detect whether the + // header set delimiters (which overrides DelimSets to a single pass). + origLeft, origRight := template.LeftDelim, template.RightDelim + parsed, err := parseAndStripTemplateHeader(template) + if err != nil { + return "", err + } + template = parsed + headerSetDelims := template.LeftDelim != origLeft || template.RightDelim != origRight + + if template.ValueFunctions { + funcs := make(map[string]any, len(template.Functions)+len(environment)) + for k, v := range template.Functions { + funcs[k] = v + } + for k, v := range environment { + _v := v + funcs[k] = func() any { return _v } + } + template.Functions = funcs + } + + var delimSets []Delims + switch { + case headerSetDelims: + delimSets = []Delims{{Left: template.LeftDelim, Right: template.RightDelim}} + case len(template.DelimSets) > 0: + delimSets = template.DelimSets + case template.LeftDelim != "" && template.RightDelim != "": + delimSets = []Delims{{Left: template.LeftDelim, Right: template.RightDelim}} + default: + delimSets = []Delims{{Left: "{{", Right: "}}"}} + } + + val := template.Template + for _, d := range delimSets { + pass := template + pass.Template = val + pass.LeftDelim = d.Left + pass.RightDelim = d.Right + pass.DelimSets = nil + out, err := goTemplate(ctx, pass, environment) + if err != nil { + return out, err + } + val = out + } + return val, nil +} + func goTemplate(ctx commonsContext.Context, template Template, environment map[string]any) (string, error) { var tpl *gotemplate.Template diff --git a/template_test.go b/template_test.go index 4b775899d..04d4382eb 100644 --- a/template_test.go +++ b/template_test.go @@ -154,6 +154,154 @@ func TestCacheTime(t *testing.T) { } +func TestRunTemplate_ValueFunctions(t *testing.T) { + out, err := RunTemplate(map[string]any{"msg": "world"}, Template{ + Template: "hello {{ msg }}", + ValueFunctions: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "hello world" { + t.Errorf("got %q, want %q", out, "hello world") + } +} + +func TestRunTemplate_ValueFunctionsCoexistWithDotAccess(t *testing.T) { + out, err := RunTemplate(map[string]any{"msg": "world"}, Template{ + Template: "{{ msg }}-{{ .msg }}", + ValueFunctions: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "world-world" { + t.Errorf("got %q, want %q", out, "world-world") + } +} + +func TestRunTemplate_ValueFunctionsOffKeepsDotOnly(t *testing.T) { + out, err := RunTemplate(map[string]any{"msg": "world"}, Template{ + Template: "{{ .msg }}", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "world" { + t.Errorf("got %q, want %q", out, "world") + } + + if _, err := RunTemplate(map[string]any{"msg": "world"}, Template{ + Template: "{{ msg }}", + }); err == nil { + t.Error("expected error calling bare {{ msg }} without ValueFunctions, got nil") + } +} + +func TestRunTemplate_ValueFunctionsDoNotMutateCallerFuncs(t *testing.T) { + callerFuncs := map[string]any{} + _, err := RunTemplate(map[string]any{"msg": "world"}, Template{ + Template: "{{ msg }}", + Functions: callerFuncs, + ValueFunctions: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(callerFuncs) != 0 { + t.Errorf("caller Functions map was mutated: %v", callerFuncs) + } +} + +func TestRunTemplate_DelimSetsMultiPass(t *testing.T) { + out, err := RunTemplate(map[string]any{"msg": "world"}, Template{ + Template: "hello $(msg)", + DelimSets: []Delims{ + {Left: "{{", Right: "}}"}, + {Left: "$(", Right: ")"}, + }, + ValueFunctions: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "hello world" { + t.Errorf("got %q, want %q", out, "hello world") + } +} + +func TestRunTemplate_DelimSetsFeedsOutputForward(t *testing.T) { + // First pass replaces $(inner) with "msg", producing "{{ msg }}"; + // second pass then resolves the {{ msg }}. + out, err := RunTemplate( + map[string]any{"inner": "msg", "msg": "world"}, + Template{ + Template: "{{ $(inner) }}", + DelimSets: []Delims{ + {Left: "$(", Right: ")"}, + {Left: "{{", Right: "}}"}, + }, + ValueFunctions: true, + }, + ) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "world" { + t.Errorf("got %q, want %q", out, "world") + } +} + +func TestRunTemplate_HeaderOverridesDelimSets(t *testing.T) { + // Header sets [[ ]] so DelimSets should be ignored and $(...) left literal. + out, err := RunTemplate(map[string]any{"msg": "world"}, Template{ + Template: "# gotemplate: left-delim=[[ right-delim=]]\n[[ .msg ]] $(msg)", + DelimSets: []Delims{ + {Left: "{{", Right: "}}"}, + {Left: "$(", Right: ")"}, + }, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "world $(msg)" { + t.Errorf("got %q, want %q", out, "world $(msg)") + } +} + +func TestRunTemplate_DelimSetsPreferredOverSingleDelimPair(t *testing.T) { + // Both DelimSets and LeftDelim/RightDelim set — DelimSets wins. + out, err := RunTemplate(map[string]any{"msg": "world"}, Template{ + Template: "$(msg)", + LeftDelim: "[[", + RightDelim: "]]", + DelimSets: []Delims{ + {Left: "$(", Right: ")"}, + }, + ValueFunctions: true, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "world" { + t.Errorf("got %q, want %q", out, "world") + } +} + +func TestRunTemplate_SingleDelimPairStillWorks(t *testing.T) { + out, err := RunTemplate(map[string]any{"msg": "world"}, Template{ + Template: "[[ .msg ]]", + LeftDelim: "[[", + RightDelim: "]]", + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if out != "world" { + t.Errorf("got %q, want %q", out, "world") + } +} + func TestRunExpressionReusesProgramAcrossDifferentData(t *testing.T) { tpl := Template{ Expression: "name + age",