diff --git a/lang/funcs/string.go b/lang/funcs/string.go index d4971580ad..54261cd09a 100644 --- a/lang/funcs/string.go +++ b/lang/funcs/string.go @@ -8,6 +8,7 @@ import ( "github.com/zclconf/go-cty/cty" "github.com/zclconf/go-cty/cty/function" + "github.com/zclconf/go-cty/cty/gocty" ) var JoinFunc = function.New(&function.Spec{ @@ -126,6 +127,99 @@ var ChompFunc = function.New(&function.Spec{ }, }) +// IndentFunc constructions a function that adds a given number of spaces to the +// beginnings of all but the first line in a given multi-line string. +var IndentFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "spaces", + Type: cty.Number, + }, + { + Name: "str", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + var spaces int + if err := gocty.FromCtyValue(args[0], &spaces); err != nil { + return cty.UnknownVal(cty.String), err + } + data := args[1].AsString() + pad := strings.Repeat(" ", spaces) + return cty.StringVal(strings.Replace(data, "\n", "\n"+pad, -1)), nil + }, +}) + +// ReplaceFunc constructions a function that searches a given string for another +// given substring, and replaces each occurence with a given replacement string. +var ReplaceFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + { + Name: "substr", + Type: cty.String, + }, + { + Name: "replace", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + str := args[0].AsString() + substr := args[1].AsString() + replace := args[2].AsString() + + // We search/replace using a regexp if the string is surrounded + // in forward slashes. + if len(substr) > 1 && substr[0] == '/' && substr[len(substr)-1] == '/' { + re, err := regexp.Compile(substr[1 : len(substr)-1]) + if err != nil { + return cty.UnknownVal(cty.String), err + } + + return cty.StringVal(re.ReplaceAllString(str, replace)), nil + } + + return cty.StringVal(strings.Replace(str, substr, replace, -1)), nil + }, +}) + +// TitleFunc constructions a function that converts the first letter of each word +// in the given string to uppercase. +var TitleFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + return cty.StringVal(strings.Title(args[0].AsString())), nil + }, +}) + +// TrimSpaceFunc constructions a function that removes any space characters from +// the start and end of the given string. +var TrimSpaceFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.String), + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + return cty.StringVal(strings.TrimSpace(args[0].AsString())), 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) { @@ -151,3 +245,25 @@ func Split(sep, str cty.Value) (cty.Value, error) { func Chomp(str cty.Value) (cty.Value, error) { return ChompFunc.Call([]cty.Value{str}) } + +// Indent adds a given number of spaces to the beginnings of all but the first +// line in a given multi-line string. +func Indent(spaces, str cty.Value) (cty.Value, error) { + return IndentFunc.Call([]cty.Value{spaces, str}) +} + +// Replace searches a given string for another given substring, +// and replaces all occurences with a given replacement string. +func Replace(str, substr, replace cty.Value) (cty.Value, error) { + return ReplaceFunc.Call([]cty.Value{str, substr, replace}) +} + +// Title converts the first letter of each word in the given string to uppercase. +func Title(str cty.Value) (cty.Value, error) { + return TitleFunc.Call([]cty.Value{str}) +} + +// TrimSpace removes any space characters from the start and end of the given string. +func TrimSpace(str cty.Value) (cty.Value, error) { + return TrimSpaceFunc.Call([]cty.Value{str}) +} diff --git a/lang/funcs/string_test.go b/lang/funcs/string_test.go index 1696738a1c..cddbc37440 100644 --- a/lang/funcs/string_test.go +++ b/lang/funcs/string_test.go @@ -307,3 +307,202 @@ func TestChomp(t *testing.T) { }) } } + +func TestIndent(t *testing.T) { + tests := []struct { + String cty.Value + Spaces cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal(`Fleas: +Adam +Had'em + +E.E. Cummings`), + cty.NumberIntVal(4), + cty.StringVal("Fleas:\n Adam\n Had'em\n \n E.E. Cummings"), + false, + }, + { + cty.StringVal("oneliner"), + cty.NumberIntVal(4), + cty.StringVal("oneliner"), + false, + }, + { + cty.StringVal(`#!/usr/bin/env bash +date +pwd`), + cty.NumberIntVal(4), + cty.StringVal("#!/usr/bin/env bash\n date\n pwd"), + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("indent(%#v, %#v)", test.Spaces, test.String), func(t *testing.T) { + got, err := Indent(test.Spaces, test.String) + + 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 TestReplace(t *testing.T) { + tests := []struct { + String cty.Value + Substr cty.Value + Replace cty.Value + Want cty.Value + Err bool + }{ + { // Regular search and replace + cty.StringVal("hello"), + cty.StringVal("hel"), + cty.StringVal("bel"), + cty.StringVal("bello"), + false, + }, + { // Search string doesn't match + cty.StringVal("hello"), + cty.StringVal("nope"), + cty.StringVal("bel"), + cty.StringVal("hello"), + false, + }, + { // Regular expression + cty.StringVal("hello"), + cty.StringVal("/l/"), + cty.StringVal("L"), + cty.StringVal("heLLo"), + false, + }, + { + cty.StringVal("helo"), + cty.StringVal("/(l)/"), + cty.StringVal("$1$1"), + cty.StringVal("hello"), + false, + }, + { // Bad regexp + cty.StringVal("hello"), + cty.StringVal("/(l/"), + cty.StringVal("$1$1"), + cty.UnknownVal(cty.String), + true, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("replace(%#v, %#v, %#v)", test.String, test.Substr, test.Replace), func(t *testing.T) { + got, err := Replace(test.String, test.Substr, test.Replace) + + 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 TestTitle(t *testing.T) { + tests := []struct { + String cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal("hello"), + cty.StringVal("Hello"), + false, + }, + { + cty.StringVal("hello world"), + cty.StringVal("Hello World"), + false, + }, + { + cty.StringVal(""), + cty.StringVal(""), + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("title(%#v)", test.String), func(t *testing.T) { + got, err := Title(test.String) + + 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 TestTrimSpace(t *testing.T) { + tests := []struct { + String cty.Value + Want cty.Value + Err bool + }{ + { + cty.StringVal(" hello "), + cty.StringVal("hello"), + false, + }, + { + cty.StringVal(""), + cty.StringVal(""), + false, + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("trimspace(%#v)", test.String), func(t *testing.T) { + got, err := TrimSpace(test.String) + + 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/functions.go b/lang/functions.go index d123fc56a9..9e3a4b8c0f 100644 --- a/lang/functions.go +++ b/lang/functions.go @@ -60,7 +60,7 @@ func (s *Scope) Functions() map[string]function.Function { "format": stdlib.FormatFunc, "formatlist": stdlib.FormatListFunc, "indent": unimplFunc, // TODO - "index": unimplFunc, // TODO + "index": funcs.IndentFunc, "join": funcs.JoinFunc, "jsondecode": stdlib.JSONDecodeFunc, "jsonencode": stdlib.JSONEncodeFunc, @@ -77,7 +77,7 @@ func (s *Scope) Functions() map[string]function.Function { "min": stdlib.MinFunc, "pathexpand": funcs.PathExpandFunc, "pow": funcs.PowFunc, - "replace": unimplFunc, // TODO + "replace": funcs.ReplaceFunc, "rsadecrypt": funcs.RsaDecryptFunc, "sha1": funcs.Sha1Func, "sha256": funcs.Sha256Func, @@ -89,9 +89,9 @@ func (s *Scope) Functions() map[string]function.Function { "substr": stdlib.SubstrFunc, "timestamp": funcs.TimestampFunc, "timeadd": funcs.TimeAddFunc, - "title": unimplFunc, // TODO + "title": funcs.TitleFunc, "transpose": unimplFunc, // TODO - "trimspace": unimplFunc, // TODO + "trimspace": funcs.TrimSpace, "upper": stdlib.UpperFunc, "urlencode": funcs.URLEncodeFunc, "uuid": funcs.UUIDFunc,