Skip to content

Commit

Permalink
lang/funcs: add template function
Browse files Browse the repository at this point in the history
  • Loading branch information
Thierno IB. BARRY committed May 17, 2020
1 parent e358d6b commit 615ea78
Show file tree
Hide file tree
Showing 5 changed files with 316 additions and 0 deletions.
122 changes: 122 additions & 0 deletions lang/funcs/string.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package funcs

import (
"fmt"
"regexp"
"strings"

"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
)
Expand Down Expand Up @@ -51,3 +54,122 @@ var ReplaceFunc = function.New(&function.Spec{
func Replace(str, substr, replace cty.Value) (cty.Value, error) {
return ReplaceFunc.Call([]cty.Value{str, substr, replace})
}

// MakeTemplateFunc constructs a function that takes a template as string and
// an arbitrary object of named values and attempts to render the referenced
// string as a template using HCL template syntax.
//
// The template itself may recursively call other functions so a callback
// must be provided to get access to those functions. The template cannot,
// however, access any variables defined in the scope: it is restricted only to
// those variables provided in the second function argument, to ensure that all
// dependencies on other graph nodes can be seen before executing this function.
func MakeTemplateFunc(funcsCb func() map[string]function.Function) function.Function {

params := []function.Parameter{
{
Name: "str",
Type: cty.String,
},
{
Name: "vars",
Type: cty.DynamicPseudoType,
},
}

loadTmpl := func(fn string) (hcl.Expression, error) {
expr, diags := hclsyntax.ParseTemplate([]byte(fn), fn, hcl.Pos{Line: 1, Column: 1})
if diags.HasErrors() {
return nil, diags
}

return expr, nil
}

renderTmpl := func(expr hcl.Expression, varsVal cty.Value) (cty.Value, error) {
if varsTy := varsVal.Type(); !(varsTy.IsMapType() || varsTy.IsObjectType()) {
return cty.DynamicVal, function.NewArgErrorf(1, "invalid vars value: must be a map") // or an object, but we don't strongly distinguish these most of the time
}

ctx := &hcl.EvalContext{
Variables: varsVal.AsValueMap(),
}

// We require all of the variables to be valid HCL identifiers, because
// otherwise there would be no way to refer to them in the template
// anyway. Rejecting this here gives better feedback to the user
// than a syntax error somewhere in the template itself.
for n := range ctx.Variables {
if !hclsyntax.ValidIdentifier(n) {
// This error message intentionally doesn't describe _all_ of
// the different permutations that are technically valid as an
// HCL identifier, but rather focuses on what we might
// consider to be an "idiomatic" variable name.
return cty.DynamicVal, function.NewArgErrorf(1, "invalid template variable name %q: must start with a letter, followed by zero or more letters, digits, and underscores", n)
}
}

// We'll pre-check references in the template here so we can give a
// more specialized error message than HCL would by default, so it's
// clearer that this problem is coming from a template call.
for _, traversal := range expr.Variables() {
root := traversal.RootName()
if _, ok := ctx.Variables[root]; !ok {
return cty.DynamicVal, function.NewArgErrorf(1, "vars map does not contain key %q, referenced at %s", root, traversal[0].SourceRange())
}
}

givenFuncs := funcsCb() // this callback indirection is to avoid chicken/egg problems
funcs := make(map[string]function.Function, len(givenFuncs))
for name, fn := range givenFuncs {
if name == "template" {
// We stub this one out to prevent recursive calls.
funcs[name] = function.New(&function.Spec{
Params: params,
Type: func(args []cty.Value) (cty.Type, error) {
return cty.NilType, fmt.Errorf("cannot recursively call template from inside template call")
},
})
continue
}
funcs[name] = fn
}
ctx.Functions = funcs

val, diags := expr.Value(ctx)
if diags.HasErrors() {
return cty.DynamicVal, diags
}
return val, nil
}

return function.New(&function.Spec{
Params: params,
Type: func(args []cty.Value) (cty.Type, error) {
if !(args[0].IsKnown() && args[1].IsKnown()) {
return cty.DynamicPseudoType, nil
}

// We'll render our template now to see what result type it produces.
// A template consisting only of a single interpolation an potentially
// return any type.
expr, err := loadTmpl(args[0].AsString())
if err != nil {
return cty.DynamicPseudoType, err
}

// This is safe even if args[1] contains unknowns because the HCL
// template renderer itself knows how to short-circuit those.
val, err := renderTmpl(expr, args[1])
return val.Type(), err
},
Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
expr, err := loadTmpl(args[0].AsString())
if err != nil {
return cty.DynamicVal, err
}
return renderTmpl(expr, args[1])
},
})

}
131 changes: 131 additions & 0 deletions lang/funcs/string_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@ import (
"testing"

"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/function"
"github.com/zclconf/go-cty/cty/function/stdlib"
)

func TestReplace(t *testing.T) {
Expand Down Expand Up @@ -71,3 +73,132 @@ func TestReplace(t *testing.T) {
})
}
}

func TestTemplate(t *testing.T) {
tests := []struct {
String cty.Value
Vars cty.Value
Want cty.Value
Err string
}{
{
cty.StringVal("Hello, ${name}!"),
cty.EmptyObjectVal,
cty.NilVal,
`vars map does not contain key "name", referenced at Hello, ${name}!:1,10-14`,
},
{
cty.StringVal(""),
cty.MapVal(map[string]cty.Value{
"name": cty.StringVal("Jodie"),
}),
cty.StringVal(""),
``,
},
{
cty.StringVal("Hello, ${name}!"),
cty.MapVal(map[string]cty.Value{
"name": cty.StringVal("Jodie"),
}),
cty.StringVal("Hello, Jodie!"),
``,
},
{
cty.StringVal("Hello, ${name}!"),
cty.MapVal(map[string]cty.Value{
"name!": cty.StringVal("Jodie"),
}),
cty.NilVal,
`invalid template variable name "name!": must start with a letter, followed by zero or more letters, digits, and underscores`,
},
{
cty.StringVal("Hello, ${name}!"),
cty.ObjectVal(map[string]cty.Value{
"name": cty.StringVal("Jimbo"),
}),
cty.StringVal("Hello, Jimbo!"),
``,
},
{
cty.StringVal("The items are ${join(\", \", list)}"),
cty.ObjectVal(map[string]cty.Value{
"list": cty.ListVal([]cty.Value{
cty.StringVal("a"),
cty.StringVal("b"),
cty.StringVal("c"),
}),
}),
cty.StringVal("The items are a, b, c"),
``,
},
{
cty.StringVal("Hello, ${template(\"\",{})}!"),
cty.MapValEmpty(cty.String),
cty.NilVal,
`Hello, ${template("",{})}!:1,10-19: Error in function call; Call to function "template" failed: cannot recursively call template from inside template call.`,
},
{
cty.StringVal("%{ for x in list ~}\n- ${x}\n%{ endfor ~}"),
cty.ObjectVal(map[string]cty.Value{
"list": cty.ListVal([]cty.Value{
cty.StringVal("a"),
cty.StringVal("b"),
cty.StringVal("c"),
}),
}),
cty.StringVal("- a\n- b\n- c\n"),
``,
},
{
cty.StringVal("%{ for x in list ~}\n- ${x}\n%{ endfor ~}"),
cty.ObjectVal(map[string]cty.Value{
"list": cty.True,
}),
cty.NilVal,
"%{ for x in list ~}\n- ${x}\n%{ endfor ~}:1,13-17: Iteration over non-iterable value; A value of type bool cannot be used as the collection in a 'for' expression.",
},
{
cty.StringVal("${val}"),
cty.ObjectVal(map[string]cty.Value{
"val": cty.True,
}),
cty.True, // since this template contains only an interpolation, its true value shines through
``,
},
}

templateFn := MakeTemplateFunc(func() map[string]function.Function {
return map[string]function.Function{
"join": stdlib.JoinFunc,
"template": stdlib.JoinFunc, // just a placeholder, since template itself overrides this
}
})

for _, test := range tests {
t.Run(fmt.Sprintf("Template(%#v, %#v)", test.String, test.Vars), func(t *testing.T) {
got, err := templateFn.Call([]cty.Value{test.String, test.Vars})

if argErr, ok := err.(function.ArgError); ok {
if argErr.Index < 0 || argErr.Index > 1 {
t.Errorf("ArgError index %d is out of range for template (must be 0 or 1)", argErr.Index)
}
}

if test.Err != "" {
if err == nil {
t.Fatal("succeeded; want error")
}
if got, want := err.Error(), test.Err; got != want {
t.Errorf("wrong error\ngot: %s\nwant: %s", got, want)
}
return
} else if err != nil {
t.Fatalf("unexpected error: %s", err)
}

if !got.RawEquals(test.Want) {
t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.Want)
}
})
}
}
6 changes: 6 additions & 0 deletions lang/functions.go
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,12 @@ func (s *Scope) Functions() map[string]function.Function {
return s.funcs
})

s.funcs["template"] = funcs.MakeTemplateFunc(func() map[string]function.Function {
// The template function prevents recursive calls to itself
// by copying this map and overwriting the "template" entry.
return s.funcs
})

if s.PureOnly {
// Force our few impure functions to return unknown so that we
// can defer evaluating them until a later pass.
Expand Down
7 changes: 7 additions & 0 deletions lang/functions_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -793,6 +793,13 @@ func TestFunctions(t *testing.T) {
},
},

"template": {
{
`template("Hello, $${name}!", {name = "Jodie"})`,
cty.StringVal("Hello, Jodie!"),
},
},

"templatefile": {
{
`templatefile("hello.tmpl", {name = "Jodie"})`,
Expand Down
50 changes: 50 additions & 0 deletions website/docs/configuration/functions/template.html.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
---
layout: "functions"
page_title: "template - Functions - Configuration Language"
sidebar_current: "docs-funcs-string-replace"
description: |-
The template function read a string and renders it as template.
---

# `template` Function

-> **Note:** This page is about Terraform 0.12 and later. For Terraform 0.11 and
earlier, see
[0.11 Configuration Language: Interpolation Syntax](../../configuration-0-11/interpolation.html).

`template` reads a string and renders it as a template using a supplied set of template variables.

```hcl
template(str, vars)
```

The template syntax is the same as for
[string templates](../expressions.html#string-templates) in the main Terraform
language, including interpolation sequences delimited with `${` ... `}`.

The "vars" argument must be a map. Within the template string, each of the keys
in the map is available as a variable for interpolation. The template may
also use any other function available in the Terraform language. Variable names must
each start with a letter, followed by zero or more letters, digits, or
underscores.

In both quoted and heredoc string expressions, Terraform supports template sequences that begin with `${` and `%{`. These are described in more detail in the following section. To include these sequences literally without beginning a template sequence, double the leading character: `$${` or `%%{`.

Strings in the Terraform language are sequences of Unicode characters, so if the string contains invalid UTF-8 sequences then this function will produce an error.

## Examples

The `template` function renders the template:

```
> template("Hello, $${name}!", {name = "Jane"})
Hello, Jane!
```

The `template` function can be used with the `file` function to read a template from a file. Witch behavior is similar to the `templatefile`.

## Related Functions

* [`file`](./file.html) reads a file from disk and returns its literal contents
without any template interpretation.

0 comments on commit 615ea78

Please sign in to comment.