Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 65 additions & 1 deletion template.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:"-"`
Expand Down Expand Up @@ -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
Expand All @@ -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

Expand Down
148 changes: 148 additions & 0 deletions template_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading