diff --git a/lang/funcs/collection.go b/lang/funcs/collection.go new file mode 100644 index 0000000000..b7b00db350 --- /dev/null +++ b/lang/funcs/collection.go @@ -0,0 +1,120 @@ +package funcs + +import ( + "fmt" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" + "github.com/zclconf/go-cty/cty/function/stdlib" + "github.com/zclconf/go-cty/cty/gocty" +) + +var ElementFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "list", + Type: cty.DynamicPseudoType, + }, + { + Name: "index", + Type: cty.Number, + }, + }, + Type: func(args []cty.Value) (cty.Type, error) { + list := args[0] + listTy := list.Type() + switch { + case listTy.IsListType(): + return listTy.ElementType(), nil + case listTy.IsTupleType(): + etys := listTy.TupleElementTypes() + var index int + err := gocty.FromCtyValue(args[1], &index) + if err != nil { + // e.g. fractional number where whole number is required + return cty.DynamicPseudoType, fmt.Errorf("invalid index: %s", err) + } + if len(etys) == 0 { + return cty.DynamicPseudoType, fmt.Errorf("cannot use element function with an empty list") + } + index = index % len(etys) + return etys[index], nil + default: + return cty.DynamicPseudoType, fmt.Errorf("cannot read elements from %s", listTy.FriendlyName()) + } + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + var index int + err := gocty.FromCtyValue(args[1], &index) + if err != nil { + // can't happen because we checked this in the Type function above + return cty.DynamicVal, fmt.Errorf("invalid index: %s", err) + } + l := args[0].LengthInt() + if l == 0 { + return cty.DynamicVal, fmt.Errorf("cannot use element function with an empty list") + } + index = index % l + + // We did all the necessary type checks in the type function above, + // so this is guaranteed not to fail. + return args[0].Index(cty.NumberIntVal(int64(index))), nil + }, +}) + +var LengthFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "value", + Type: cty.DynamicPseudoType, + AllowDynamicType: true, + AllowUnknown: true, + }, + }, + Type: func(args []cty.Value) (cty.Type, error) { + collTy := args[0].Type() + switch { + case collTy == cty.String || collTy.IsTupleType() || collTy.IsListType() || collTy.IsMapType() || collTy.IsSetType() || collTy == cty.DynamicPseudoType: + return cty.Number, nil + default: + return cty.Number, fmt.Errorf("argument must be a string, a collection type, or a structural type") + } + }, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + coll := args[0] + collTy := args[0].Type() + switch { + case collTy == cty.DynamicPseudoType: + return cty.UnknownVal(cty.Number), nil + case collTy.IsTupleType(): + l := len(collTy.TupleElementTypes()) + return cty.NumberIntVal(int64(l)), nil + case collTy.IsObjectType(): + l := len(collTy.AttributeTypes()) + return cty.NumberIntVal(int64(l)), nil + case collTy == cty.String: + // We'll delegate to the cty stdlib strlen function here, because + // it deals with all of the complexities of tokenizing unicode + // grapheme clusters. + return stdlib.Strlen(coll) + case collTy.IsListType() || collTy.IsSetType() || collTy.IsMapType(): + return coll.Length(), nil + default: + // Should never happen, because of the checks in our Type func above + return cty.UnknownVal(cty.Number), fmt.Errorf("impossible value type for length(...)") + } + }, +}) + +// Element returns a single element from a given list at the given index. If +// index is greater than the length of the list then it is wrapped modulo +// the list length. +func Element(list, index cty.Value) (cty.Value, error) { + return ElementFunc.Call([]cty.Value{list, index}) +} + +// Length returns the number of elements in the given collection or number of +// Unicode characters in the given string. +func Length(collection cty.Value) (cty.Value, error) { + return LengthFunc.Call([]cty.Value{collection}) +} diff --git a/lang/funcs/collection_test.go b/lang/funcs/collection_test.go new file mode 100644 index 0000000000..62e3c93768 --- /dev/null +++ b/lang/funcs/collection_test.go @@ -0,0 +1,224 @@ +package funcs + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestElement(t *testing.T) { + tests := []struct { + List cty.Value + Index cty.Value + Want cty.Value + }{ + { + cty.ListVal([]cty.Value{ + cty.StringVal("hello"), + }), + cty.NumberIntVal(0), + cty.StringVal("hello"), + }, + { + cty.ListVal([]cty.Value{ + cty.StringVal("hello"), + }), + cty.NumberIntVal(1), + cty.StringVal("hello"), + }, + { + cty.ListVal([]cty.Value{ + cty.StringVal("hello"), + cty.StringVal("bonjour"), + }), + cty.NumberIntVal(0), + cty.StringVal("hello"), + }, + { + cty.ListVal([]cty.Value{ + cty.StringVal("hello"), + cty.StringVal("bonjour"), + }), + cty.NumberIntVal(1), + cty.StringVal("bonjour"), + }, + { + cty.ListVal([]cty.Value{ + cty.StringVal("hello"), + cty.StringVal("bonjour"), + }), + cty.NumberIntVal(2), + cty.StringVal("hello"), + }, + + { + cty.TupleVal([]cty.Value{ + cty.StringVal("hello"), + }), + cty.NumberIntVal(0), + cty.StringVal("hello"), + }, + { + cty.TupleVal([]cty.Value{ + cty.StringVal("hello"), + }), + cty.NumberIntVal(1), + cty.StringVal("hello"), + }, + { + cty.TupleVal([]cty.Value{ + cty.StringVal("hello"), + cty.StringVal("bonjour"), + }), + cty.NumberIntVal(0), + cty.StringVal("hello"), + }, + { + cty.TupleVal([]cty.Value{ + cty.StringVal("hello"), + cty.StringVal("bonjour"), + }), + cty.NumberIntVal(1), + cty.StringVal("bonjour"), + }, + { + cty.TupleVal([]cty.Value{ + cty.StringVal("hello"), + cty.StringVal("bonjour"), + }), + cty.NumberIntVal(2), + cty.StringVal("hello"), + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("Element(%#v, %#v)", test.List, test.Index), func(t *testing.T) { + got, err := Element(test.List, test.Index) + + 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) + } + }) + } + +} + +func TestLength(t *testing.T) { + tests := []struct { + Value cty.Value + Want cty.Value + }{ + { + cty.ListValEmpty(cty.Number), + cty.NumberIntVal(0), + }, + { + cty.ListVal([]cty.Value{cty.True}), + cty.NumberIntVal(1), + }, + { + cty.ListVal([]cty.Value{cty.UnknownVal(cty.Bool)}), + cty.NumberIntVal(1), + }, + { + cty.SetValEmpty(cty.Number), + cty.NumberIntVal(0), + }, + { + cty.SetVal([]cty.Value{cty.True}), + cty.NumberIntVal(1), + }, + { + cty.MapValEmpty(cty.Bool), + cty.NumberIntVal(0), + }, + { + cty.MapVal(map[string]cty.Value{"hello": cty.True}), + cty.NumberIntVal(1), + }, + { + cty.EmptyTupleVal, + cty.NumberIntVal(0), + }, + { + cty.TupleVal([]cty.Value{cty.True}), + cty.NumberIntVal(1), + }, + { + cty.UnknownVal(cty.List(cty.Bool)), + cty.UnknownVal(cty.Number), + }, + { + cty.DynamicVal, + cty.UnknownVal(cty.Number), + }, + { + cty.StringVal("hello"), + cty.NumberIntVal(5), + }, + { + cty.StringVal(""), + cty.NumberIntVal(0), + }, + { + cty.StringVal("1"), + cty.NumberIntVal(1), + }, + { + cty.StringVal("Живой Журнал"), + cty.NumberIntVal(12), + }, + { + // note that the dieresis here is intentionally a combining + // ligature. + cty.StringVal("noël"), + cty.NumberIntVal(4), + }, + { + // The Es in this string has three combining acute accents. + // This tests something that NFC-normalization cannot collapse + // into a single precombined codepoint, since otherwise we might + // be cheating and relying on the single-codepoint forms. + cty.StringVal("wé́́é́́é́́!"), + cty.NumberIntVal(5), + }, + { + // Go's normalization forms don't handle this ligature, so we + // will produce the wrong result but this is now a compatibility + // constraint and so we'll test it. + cty.StringVal("baffle"), + cty.NumberIntVal(4), + }, + { + cty.StringVal("😸😾"), + cty.NumberIntVal(2), + }, + { + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.Number), + }, + { + cty.DynamicVal, + cty.UnknownVal(cty.Number), + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("Length(%#v)", test.Value), func(t *testing.T) { + got, err := Length(test.Value) + + 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) + } + }) + } +} diff --git a/lang/funcs/crypto.go b/lang/funcs/crypto.go new file mode 100644 index 0000000000..bfa8543d9e --- /dev/null +++ b/lang/funcs/crypto.go @@ -0,0 +1,29 @@ +package funcs + +import ( + uuid "github.com/hashicorp/go-uuid" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +var UUIDFunc = function.New(&function.Spec{ + Params: []function.Parameter{}, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + result, err := uuid.GenerateUUID() + if err != nil { + return cty.UnknownVal(cty.String), err + } + return cty.StringVal(result), nil + }, +}) + +// UUID generates and returns a Type-4 UUID in the standard hexadecimal string +// format. +// +// This is not a pure function: it will generate a different result for each +// call. It must therefore be registered as an impure function in the function +// table in the "lang" package. +func UUID() (cty.Value, error) { + return UUIDFunc.Call(nil) +} diff --git a/lang/funcs/crypto_test.go b/lang/funcs/crypto_test.go new file mode 100644 index 0000000000..29affc6da6 --- /dev/null +++ b/lang/funcs/crypto_test.go @@ -0,0 +1,17 @@ +package funcs + +import ( + "testing" +) + +func TestUUID(t *testing.T) { + result, err := UUID() + if err != nil { + t.Fatal(err) + } + + resultStr := result.AsString() + if got, want := len(resultStr), 36; got != want { + t.Errorf("wrong result length %d; want %d", got, want) + } +} diff --git a/lang/funcs/filesystem.go b/lang/funcs/filesystem.go new file mode 100644 index 0000000000..785dac9ea6 --- /dev/null +++ b/lang/funcs/filesystem.go @@ -0,0 +1,88 @@ +package funcs + +import ( + "encoding/base64" + "fmt" + "io/ioutil" + "os" + "path/filepath" + "unicode/utf8" + + homedir "github.com/mitchellh/go-homedir" + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +// MakeFileFunc constructs a function that takes a file path and returns the +// contents of that file, either directly as a string (where valid UTF-8 is +// required) or as a string containing base64 bytes. +func MakeFileFunc(baseDir string, encBase64 bool) function.Function { + return function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "path", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + path := args[0].AsString() + path, err := homedir.Expand(path) + if err != nil { + return cty.UnknownVal(cty.String), fmt.Errorf("failed to expand ~: %s", err) + } + + if !filepath.IsAbs(path) { + path = filepath.Join(baseDir, path) + } + + // Ensure that the path is canonical for the host OS + path = filepath.Clean(path) + + src, err := ioutil.ReadFile(path) + if err != nil { + // ReadFile does not return Terraform-user-friendly error + // messages, so we'll provide our own. + if os.IsNotExist(err) { + return cty.UnknownVal(cty.String), fmt.Errorf("no file exists at %s", path) + } + return cty.UnknownVal(cty.String), fmt.Errorf("failed to read %s", path) + } + + switch { + case encBase64: + enc := base64.StdEncoding.EncodeToString(src) + return cty.StringVal(enc), nil + default: + if !utf8.Valid(src) { + return cty.UnknownVal(cty.String), fmt.Errorf("contents of %s are not valid UTF-8; to read arbitrary bytes, use the filebase64 function instead", path) + } + return cty.StringVal(string(src)), nil + } + }, + }) +} + +// File reads the contents of the file at the given path. +// +// The file must contain valid UTF-8 bytes, or this function will return an error. +// +// The underlying function implementation works relative to a particular base +// directory, so this wrapper takes a base directory string and uses it to +// construct the underlying function before calling it. +func File(baseDir string, path cty.Value) (cty.Value, error) { + fn := MakeFileFunc(baseDir, false) + return fn.Call([]cty.Value{path}) +} + +// FileBase64 reads the contents of the file at the given path. +// +// The bytes from the file are encoded as base64 before returning. +// +// The underlying function implementation works relative to a particular base +// directory, so this wrapper takes a base directory string and uses it to +// construct the underlying function before calling it. +func FileBase64(baseDir string, path cty.Value) (cty.Value, error) { + fn := MakeFileFunc(baseDir, true) + return fn.Call([]cty.Value{path}) +} diff --git a/lang/funcs/filesystem_test.go b/lang/funcs/filesystem_test.go new file mode 100644 index 0000000000..2e7eaad504 --- /dev/null +++ b/lang/funcs/filesystem_test.go @@ -0,0 +1,98 @@ +package funcs + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestFile(t *testing.T) { + tests := []struct { + Path cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("testdata/hello.txt"), + cty.StringVal("Hello World"), + false, + }, + { + cty.StringVal("testdata/icon.png"), + cty.NilVal, + true, // Not valid UTF-8 + }, + { + cty.StringVal("testdata/missing"), + cty.NilVal, + true, // no file exists + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("File(\".\", %#v)", test.Path), func(t *testing.T) { + got, err := File(".", test.Path) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + 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) + } + }) + } +} + +func TestFileBase64(t *testing.T) { + tests := []struct { + Path cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("testdata/hello.txt"), + cty.StringVal("SGVsbG8gV29ybGQ="), + false, + }, + { + cty.StringVal("testdata/icon.png"), + cty.StringVal("iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAABGdBTUEAALGPC/xhBQAAACBjSFJNAAB6JgAAgIQAAPoAAACA6AAAdTAAAOpgAAA6mAAAF3CculE8AAAAq1BMVEX///9cTuVeUeRcTuZcTuZcT+VbSe1cTuVdT+MAAP9JSbZcT+VcTuZAQLFAQLJcTuVcTuZcUuBBQbA/P7JAQLJaTuRcT+RcTuVGQ7xAQLJVVf9cTuVcTuVGRMFeUeRbTeJcTuU/P7JeTeZbTOVcTeZAQLJBQbNAQLNaUORcTeZbT+VcTuRAQLNAQLRdTuRHR8xgUOdgUN9cTuVdTeRdT+VZTulcTuVAQLL///8+GmETAAAANnRSTlMApibw+osO6DcBB3fIX87+oRk3yehB0/Nj/gNs7nsTRv3dHmu//JYUMLVr3bssjxkgEK5CaxeK03nIAAAAAWJLR0QAiAUdSAAAAAlwSFlzAAADoQAAA6EBvJf9gwAAAAd0SU1FB+EEBRIQDxZNTKsAAACCSURBVBjTfc7JFsFQEATQQpCYxyBEzJ55rvf/f0ZHcyQLvelTd1GngEwWycs5+UISyKLraSi9geWKK9Gr1j7AeqOJVtt2XtD1Bchef2BjQDAcCTC0CsA4mihMtXw2XwgsV2sFw812F+4P3y2GdI6nn3FGSs//4HJNAXDzU4Dg/oj/E+bsEbhf5cMsAAAAJXRFWHRkYXRlOmNyZWF0ZQAyMDE3LTA0LTA1VDE4OjE2OjE1KzAyOjAws5bLVQAAACV0RVh0ZGF0ZTptb2RpZnkAMjAxNy0wNC0wNVQxODoxNjoxNSswMjowMMLLc+kAAAAZdEVYdFNvZnR3YXJlAHd3dy5pbmtzY2FwZS5vcmeb7jwaAAAAC3RFWHRUaXRsZQBHcm91cJYfIowAAABXelRYdFJhdyBwcm9maWxlIHR5cGUgaXB0YwAAeJzj8gwIcVYoKMpPy8xJ5VIAAyMLLmMLEyMTS5MUAxMgRIA0w2QDI7NUIMvY1MjEzMQcxAfLgEigSi4A6hcRdPJCNZUAAAAASUVORK5CYII="), + false, + }, + { + cty.StringVal("testdata/missing"), + cty.NilVal, + true, // no file exists + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("FileBase64(\".\", %#v)", test.Path), func(t *testing.T) { + got, err := FileBase64(".", test.Path) + + if test.Err { + if err == nil { + t.Fatal("succeeded; want error") + } + 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) + } + }) + } +} diff --git a/lang/funcs/string.go b/lang/funcs/string.go new file mode 100644 index 0000000000..e7653ed7a7 --- /dev/null +++ b/lang/funcs/string.go @@ -0,0 +1,132 @@ +package funcs + +import ( + "fmt" + "sort" + "strings" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +var JoinFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "separator", + Type: cty.String, + }, + }, + VarParam: &function.Parameter{ + Name: "lists", + Type: cty.List(cty.String), + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + sep := args[0].AsString() + listVals := args[1:] + if len(listVals) < 1 { + return cty.UnknownVal(cty.String), fmt.Errorf("at least one list is required") + } + + l := 0 + for _, list := range listVals { + if !list.IsWhollyKnown() { + return cty.UnknownVal(cty.String), nil + } + l += list.LengthInt() + } + + items := make([]string, 0, l) + for _, list := range listVals { + for it := list.ElementIterator(); it.Next(); { + _, val := it.Element() + items = append(items, val.AsString()) + } + } + + return cty.StringVal(strings.Join(items, sep)), nil + }, +}) + +var SortFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "list", + Type: cty.List(cty.String), + }, + }, + Type: function.StaticReturnType(cty.List(cty.String)), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + listVal := args[0] + + if !listVal.IsWhollyKnown() { + // If some of the element values aren't known yet then we + // can't yet preduct the order of the result. + return cty.UnknownVal(retType), nil + } + if listVal.LengthInt() == 0 { // Easy path + return listVal, nil + } + + list := make([]string, 0, listVal.LengthInt()) + for it := listVal.ElementIterator(); it.Next(); { + _, v := it.Element() + list = append(list, v.AsString()) + } + + sort.Strings(list) + retVals := make([]cty.Value, len(list)) + for i, s := range list { + retVals[i] = cty.StringVal(s) + } + return cty.ListVal(retVals), nil + }, +}) + +var SplitFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "separator", + Type: cty.String, + }, + { + Name: "str", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.List(cty.String)), + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + sep := args[0].AsString() + str := args[1].AsString() + elems := strings.Split(str, sep) + elemVals := make([]cty.Value, len(elems)) + for i, s := range elems { + elemVals[i] = cty.StringVal(s) + } + if len(elemVals) == 0 { + return cty.ListValEmpty(cty.String), nil + } + return cty.ListVal(elemVals), nil + }, +}) + +// Join concatenates together the string elements of one or more lists with a +// given separator. +func Join(sep cty.Value, lists ...cty.Value) (cty.Value, error) { + args := make([]cty.Value, len(lists)+1) + args[0] = sep + copy(args[1:], lists) + return JoinFunc.Call(args) +} + +// Sort re-orders the elements of a given list of strings so that they are +// in ascending lexicographical order. +func Sort(list cty.Value) (cty.Value, error) { + return SortFunc.Call([]cty.Value{list}) +} + +// Split divides a given string by a given separator, returning a list of +// strings containing the characters between the separator sequences. +func Split(sep, str cty.Value) (cty.Value, error) { + return SplitFunc.Call([]cty.Value{sep, str}) +} diff --git a/lang/funcs/string_test.go b/lang/funcs/string_test.go new file mode 100644 index 0000000000..6c27a8d2ed --- /dev/null +++ b/lang/funcs/string_test.go @@ -0,0 +1,246 @@ +package funcs + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestJoin(t *testing.T) { + tests := []struct { + Sep cty.Value + Lists []cty.Value + Want cty.Value + }{ + { + cty.StringVal(" "), + []cty.Value{ + cty.ListVal([]cty.Value{ + cty.StringVal("Hello"), + cty.StringVal("World"), + }), + }, + cty.StringVal("Hello World"), + }, + { + cty.StringVal(" "), + []cty.Value{ + cty.ListVal([]cty.Value{ + cty.StringVal("Hello"), + cty.StringVal("World"), + }), + cty.ListVal([]cty.Value{ + cty.StringVal("Foo"), + cty.StringVal("Bar"), + }), + }, + cty.StringVal("Hello World Foo Bar"), + }, + { + cty.StringVal(" "), + []cty.Value{ + cty.ListValEmpty(cty.String), + }, + cty.StringVal(""), + }, + { + cty.StringVal(" "), + []cty.Value{ + cty.ListValEmpty(cty.String), + cty.ListValEmpty(cty.String), + cty.ListValEmpty(cty.String), + }, + cty.StringVal(""), + }, + { + cty.StringVal(" "), + []cty.Value{ + cty.ListValEmpty(cty.String), + cty.ListVal([]cty.Value{ + cty.StringVal("Foo"), + cty.StringVal("Bar"), + }), + }, + cty.StringVal("Foo Bar"), + }, + { + cty.UnknownVal(cty.String), + []cty.Value{ + cty.ListVal([]cty.Value{ + cty.StringVal("Hello"), + cty.StringVal("World"), + }), + }, + cty.UnknownVal(cty.String), + }, + { + cty.StringVal(" "), + []cty.Value{ + cty.ListVal([]cty.Value{ + cty.StringVal("Hello"), + cty.UnknownVal(cty.String), + }), + }, + cty.UnknownVal(cty.String), + }, + { + cty.StringVal(" "), + []cty.Value{ + cty.UnknownVal(cty.List(cty.String)), + }, + cty.UnknownVal(cty.String), + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("Join(%#v, %#v...)", test.Sep, test.Lists), func(t *testing.T) { + got, err := Join(test.Sep, test.Lists...) + + 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) + } + }) + } +} + +func TestSort(t *testing.T) { + tests := []struct { + List cty.Value + Want cty.Value + }{ + { + cty.ListValEmpty(cty.String), + cty.ListValEmpty(cty.String), + }, + { + cty.ListVal([]cty.Value{ + cty.StringVal("banana"), + }), + cty.ListVal([]cty.Value{ + cty.StringVal("banana"), + }), + }, + { + cty.ListVal([]cty.Value{ + cty.StringVal("banana"), + cty.StringVal("apple"), + }), + cty.ListVal([]cty.Value{ + cty.StringVal("apple"), + cty.StringVal("banana"), + }), + }, + { + cty.ListVal([]cty.Value{ + cty.StringVal("8"), + cty.StringVal("9"), + cty.StringVal("10"), + }), + cty.ListVal([]cty.Value{ + cty.StringVal("10"), // lexicographical sort, not numeric sort + cty.StringVal("8"), + cty.StringVal("9"), + }), + }, + { + cty.UnknownVal(cty.List(cty.String)), + cty.UnknownVal(cty.List(cty.String)), + }, + { + cty.ListVal([]cty.Value{ + cty.UnknownVal(cty.String), + }), + cty.UnknownVal(cty.List(cty.String)), + }, + { + cty.ListVal([]cty.Value{ + cty.UnknownVal(cty.String), + cty.StringVal("banana"), + }), + cty.UnknownVal(cty.List(cty.String)), + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("Sort(%#v)", test.List), func(t *testing.T) { + got, err := Sort(test.List) + + 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) + } + }) + } +} +func TestSplit(t *testing.T) { + tests := []struct { + Sep cty.Value + Str cty.Value + Want cty.Value + }{ + { + cty.StringVal(" "), + cty.StringVal("Hello World"), + cty.ListVal([]cty.Value{ + cty.StringVal("Hello"), + cty.StringVal("World"), + }), + }, + { + cty.StringVal(" "), + cty.StringVal("Hello"), + cty.ListVal([]cty.Value{ + cty.StringVal("Hello"), + }), + }, + { + cty.StringVal(" "), + cty.StringVal(""), + cty.ListVal([]cty.Value{ + cty.StringVal(""), + }), + }, + { + cty.StringVal(""), + cty.StringVal(""), + cty.ListValEmpty(cty.String), + }, + { + cty.UnknownVal(cty.String), + cty.StringVal("Hello World"), + cty.UnknownVal(cty.List(cty.String)), + }, + { + cty.StringVal(" "), + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.List(cty.String)), + }, + { + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.String), + cty.UnknownVal(cty.List(cty.String)), + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("Split(%#v, %#v)", test.Sep, test.Str), func(t *testing.T) { + got, err := Split(test.Sep, test.Str) + + 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) + } + }) + } +} diff --git a/lang/funcs/testdata/hello.txt b/lang/funcs/testdata/hello.txt new file mode 100644 index 0000000000..5e1c309dae --- /dev/null +++ b/lang/funcs/testdata/hello.txt @@ -0,0 +1 @@ +Hello World \ No newline at end of file diff --git a/lang/funcs/testdata/icon.png b/lang/funcs/testdata/icon.png new file mode 100644 index 0000000000..a474f146fa Binary files /dev/null and b/lang/funcs/testdata/icon.png differ diff --git a/lang/functions.go b/lang/functions.go index 797a274104..731fcd3125 100644 --- a/lang/functions.go +++ b/lang/functions.go @@ -6,6 +6,8 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/function" "github.com/zclconf/go-cty/cty/function/stdlib" + + "github.com/hashicorp/terraform/lang/funcs" ) var impureFunctions = []string{ @@ -18,6 +20,13 @@ var impureFunctions = []string{ func (s *Scope) Functions() map[string]function.Function { s.funcsLock.Lock() if s.funcs == nil { + // Some of our functions are just directly the cty stdlib functions. + // Others are implemented in the subdirectory "funcs" here in this + // repository. New functions should generally start out their lives + // in the "funcs" directory and potentially graduate to cty stdlib + // later if the functionality seems to be something domain-agnostic + // that would be useful to all applications using cty functions. + s.funcs = map[string]function.Function{ "abs": stdlib.AbsoluteFunc, "basename": unimplFunc, // TODO @@ -40,9 +49,10 @@ func (s *Scope) Functions() map[string]function.Function { "csvdecode": stdlib.CSVDecodeFunc, "dirname": unimplFunc, // TODO "distinct": unimplFunc, // TODO - "element": unimplFunc, // TODO + "element": funcs.ElementFunc, "chunklist": unimplFunc, // TODO - "file": unimplFunc, // TODO + "file": funcs.MakeFileFunc(s.BaseDir, false), + "filebase64": funcs.MakeFileFunc(s.BaseDir, true), "matchkeys": unimplFunc, // TODO "flatten": unimplFunc, // TODO "floor": unimplFunc, // TODO @@ -50,12 +60,13 @@ func (s *Scope) Functions() map[string]function.Function { "formatlist": stdlib.FormatListFunc, "indent": unimplFunc, // TODO "index": unimplFunc, // TODO - "join": unimplFunc, // TODO + "join": funcs.JoinFunc, "jsondecode": stdlib.JSONDecodeFunc, "jsonencode": stdlib.JSONEncodeFunc, - "length": unimplFunc, // TODO + "length": funcs.LengthFunc, "list": unimplFunc, // TODO "log": unimplFunc, // TODO + "lookup": unimplFunc, // TODO "lower": stdlib.LowerFunc, "map": unimplFunc, // TODO "max": stdlib.MaxFunc, @@ -71,8 +82,8 @@ func (s *Scope) Functions() map[string]function.Function { "sha512": unimplFunc, // TODO "signum": unimplFunc, // TODO "slice": unimplFunc, // TODO - "sort": unimplFunc, // TODO - "split": unimplFunc, // TODO + "sort": funcs.SortFunc, + "split": funcs.SplitFunc, "substr": stdlib.SubstrFunc, "timestamp": unimplFunc, // TODO "timeadd": unimplFunc, // TODO @@ -81,7 +92,7 @@ func (s *Scope) Functions() map[string]function.Function { "trimspace": unimplFunc, // TODO "upper": stdlib.UpperFunc, "urlencode": unimplFunc, // TODO - "uuid": unimplFunc, // TODO + "uuid": funcs.UUIDFunc, "zipmap": unimplFunc, // TODO }