diff --git a/README.md b/README.md index 5e8362f6..4027e11a 100644 --- a/README.md +++ b/README.md @@ -361,6 +361,7 @@ For example, this is a JSON version of an emitted RuntimeContainer struct: * *`contains $map $key`*: Returns `true` if `$map` contains `$key`. Takes maps from `string` to any type. * *`dir $path`*: Returns an array of filenames in the specified `$path`. * *`exists $path`*: Returns `true` if `$path` refers to an existing file or directory. Takes a string. +* *`eval $templateName [$data]`*: Evaluates the named template like Go's built-in `template` action, but instead of writing out the result it returns the result as a string so that it can be post-processed. The `$data` argument may be omitted, which is equivalent to passing `nil`. * *`groupBy $containers $fieldPath`*: Groups an array of `RuntimeContainer` instances based on the values of a field path expression `$fieldPath`. A field path expression is a dot-delimited list of map keys or struct member names specifying the path from container to a nested value, which must be a string. Returns a map from the value of the field path expression to an array of containers having that value. Containers that do not have a value for the field path in question are omitted. * *`groupByKeys $containers $fieldPath`*: Returns the same as `groupBy` but only returns the keys of the map. * *`groupByMulti $containers $fieldPath $sep`*: Like `groupBy`, but the string value specified by `$fieldPath` is first split by `$sep` into a list of strings. A container whose `$fieldPath` value contains a list of strings will show up in the map output under each of those strings. diff --git a/internal/template/template.go b/internal/template/template.go index c6711e29..3b8eaf80 100644 --- a/internal/template/template.go +++ b/internal/template/template.go @@ -3,6 +3,7 @@ package template import ( "bufio" "bytes" + "errors" "fmt" "io" "io/ioutil" @@ -43,11 +44,27 @@ func getArrayValues(funcName string, entries interface{}) (*reflect.Value, error } func newTemplate(name string) *template.Template { - tmpl := template.New(name).Funcs(sprig.TxtFuncMap()).Funcs(template.FuncMap{ + tmpl := template.New(name) + // The eval function is defined here because it must be a closure around tmpl. + eval := func(name string, args ...any) (string, error) { + buf := bytes.NewBuffer(nil) + data := any(nil) + if len(args) == 1 { + data = args[0] + } else if len(args) > 1 { + return "", errors.New("too many arguments") + } + if err := tmpl.ExecuteTemplate(buf, name, data); err != nil { + return "", err + } + return buf.String(), nil + } + tmpl.Funcs(sprig.TxtFuncMap()).Funcs(template.FuncMap{ "closest": arrayClosest, "coalesce": coalesce, "contains": contains, "dir": dirList, + "eval": eval, "exists": utils.PathExists, "groupBy": groupBy, "groupByKeys": groupByKeys, diff --git a/internal/template/template_test.go b/internal/template/template_test.go index da55f762..3fc2805c 100644 --- a/internal/template/template_test.go +++ b/internal/template/template_test.go @@ -2,6 +2,7 @@ package template import ( "bytes" + "errors" "reflect" "strconv" "strings" @@ -13,7 +14,7 @@ import ( type templateTestList []struct { tmpl string context interface{} - expected string + expected interface{} } func (tests templateTestList) run(t *testing.T) { @@ -21,6 +22,11 @@ func (tests templateTestList) run(t *testing.T) { test := test t.Run(strconv.Itoa(n), func(t *testing.T) { t.Parallel() + wantErr, _ := test.expected.(error) + want, ok := test.expected.(string) + if !ok && wantErr == nil { + t.Fatalf("test bug: want a string or error for .expected, got %v", test.expected) + } tmpl, err := newTemplate("testTemplate").Parse(test.tmpl) if err != nil { t.Fatalf("Template parse failed: %v", err) @@ -28,13 +34,17 @@ func (tests templateTestList) run(t *testing.T) { var b bytes.Buffer err = tmpl.ExecuteTemplate(&b, "testTemplate", test.context) + got := b.String() if err != nil { + if wantErr != nil { + return + } t.Fatalf("Error executing template: %v", err) + } else if wantErr != nil { + t.Fatalf("Expected error, got %v", got) } - - got := b.String() - if test.expected != got { - t.Fatalf("Incorrect output found; expected %s, got %s", test.expected, got) + if want != got { + t.Fatalf("Incorrect output found; want %#v, got %#v", want, got) } }) } @@ -151,3 +161,35 @@ func TestSprig(t *testing.T) { }) } } + +func TestEval(t *testing.T) { + for _, tc := range []struct { + desc string + tts templateTestList + }{ + {"undefined", templateTestList{ + {`{{eval "missing"}}`, nil, errors.New("")}, + {`{{eval "missing" nil}}`, nil, errors.New("")}, + {`{{eval "missing" "abc"}}`, nil, errors.New("")}, + {`{{eval "missing" "abc" "def"}}`, nil, errors.New("")}, + }}, + // The purpose of the "ctx" context is to assert that $ and . inside the template is the + // eval argument, not the global context. + {"noArg", templateTestList{ + {`{{define "T"}}{{$}}{{.}}{{end}}{{eval "T"}}`, "ctx", ""}, + }}, + {"nilArg", templateTestList{ + {`{{define "T"}}{{$}}{{.}}{{end}}{{eval "T" nil}}`, "ctx", ""}, + }}, + {"oneArg", templateTestList{ + {`{{define "T"}}{{$}}{{.}}{{end}}{{eval "T" "arg"}}`, "ctx", "argarg"}, + }}, + {"moreThanOneArg", templateTestList{ + {`{{define "T"}}{{$}}{{.}}{{end}}{{eval "T" "a" "b"}}`, "ctx", errors.New("")}, + }}, + } { + t.Run(tc.desc, func(t *testing.T) { + tc.tts.run(t) + }) + } +}