From 51eeadba3dcf87a77382488163e4405959944a24 Mon Sep 17 00:00:00 2001 From: Karthik P Date: Mon, 14 Apr 2025 21:19:36 +0530 Subject: [PATCH] feat: add sum, startswith and endswith functions This commit adds 3 new HCL2 functions: * `sum`: computes the sum of a collection of numerical values * `startswith`: checks if a string has another as prefix * `endswith`: checks if a string has another as suffix --- hcl2template/function/ends_with.go | 31 +++ hcl2template/function/ends_with_test.go | 83 +++++++ hcl2template/function/starts_with.go | 32 +++ hcl2template/function/starts_with_test.go | 98 ++++++++ hcl2template/function/sum.go | 83 +++++++ hcl2template/function/sum_test.go | 226 ++++++++++++++++++ hcl2template/functions.go | 3 + .../hcl_templates/functions/numeric/sum.mdx | 17 ++ .../functions/string/endswith.mdx | 27 +++ .../functions/string/startswith.mdx | 27 +++ website/data/docs-nav-data.json | 12 + 11 files changed, 639 insertions(+) create mode 100644 hcl2template/function/ends_with.go create mode 100644 hcl2template/function/ends_with_test.go create mode 100644 hcl2template/function/starts_with.go create mode 100644 hcl2template/function/starts_with_test.go create mode 100644 hcl2template/function/sum.go create mode 100644 hcl2template/function/sum_test.go create mode 100644 website/content/docs/templates/hcl_templates/functions/numeric/sum.mdx create mode 100644 website/content/docs/templates/hcl_templates/functions/string/endswith.mdx create mode 100644 website/content/docs/templates/hcl_templates/functions/string/startswith.mdx diff --git a/hcl2template/function/ends_with.go b/hcl2template/function/ends_with.go new file mode 100644 index 000000000..7c538f340 --- /dev/null +++ b/hcl2template/function/ends_with.go @@ -0,0 +1,31 @@ +package function + +import ( + "strings" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +// EndsWithFunc constructs a function that checks if a string ends with +// a specific suffix using strings.HasSuffix +var EndsWithFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + }, + { + Name: "suffix", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.Bool), + RefineResult: refineNotNull, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + str := args[0].AsString() + suffix := args[1].AsString() + + return cty.BoolVal(strings.HasSuffix(str, suffix)), nil + }, +}) diff --git a/hcl2template/function/ends_with_test.go b/hcl2template/function/ends_with_test.go new file mode 100644 index 000000000..66067a688 --- /dev/null +++ b/hcl2template/function/ends_with_test.go @@ -0,0 +1,83 @@ +package function + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestEndsWith(t *testing.T) { + tests := []struct { + String, Suffix cty.Value + Want cty.Value + }{ + { + cty.StringVal("hello world"), + cty.StringVal("world"), + cty.True, + }, + { + cty.StringVal("hey world"), + cty.StringVal("worldss"), + cty.False, + }, + { + cty.StringVal(""), + cty.StringVal(""), + cty.True, + }, + { + cty.StringVal("a"), + cty.StringVal(""), + cty.True, + }, + { + cty.StringVal("hello world"), + cty.StringVal(" "), + cty.False, + }, + { + cty.StringVal(" "), + cty.StringVal(""), + cty.True, + }, + { + cty.StringVal(" "), + cty.StringVal("hello"), + cty.False, + }, + { + cty.StringVal(""), + cty.StringVal("a"), + cty.False, + }, + { + cty.UnknownVal(cty.String), + cty.StringVal("a"), + cty.UnknownVal(cty.Bool).RefineNotNull(), + }, + { + cty.UnknownVal(cty.String), + cty.StringVal(""), + cty.UnknownVal(cty.Bool).RefineNotNull(), + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("EndsWith(%#v, %#v)", test.String, test.Suffix), func(t *testing.T) { + got, err := EndsWithFunc.Call([]cty.Value{test.String, test.Suffix}) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf( + "wrong result\nstring: %#v\nsuffix: %#v\ngot: %#v\nwant: %#v", + test.String, test.Suffix, got, test.Want, + ) + } + }) + } +} diff --git a/hcl2template/function/starts_with.go b/hcl2template/function/starts_with.go new file mode 100644 index 000000000..6a6d5bec2 --- /dev/null +++ b/hcl2template/function/starts_with.go @@ -0,0 +1,32 @@ +package function + +import ( + "strings" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/function" +) + +// StartsWithFunc constructs a function that checks if a string starts with +// a specific prefix using strings.HasPrefix +var StartsWithFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "str", + Type: cty.String, + AllowUnknown: false, + }, + { + Name: "prefix", + Type: cty.String, + }, + }, + Type: function.StaticReturnType(cty.Bool), + RefineResult: refineNotNull, + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { + str := args[0].AsString() + prefix := args[1].AsString() + + return cty.BoolVal(strings.HasPrefix(str, prefix)), nil + }, +}) diff --git a/hcl2template/function/starts_with_test.go b/hcl2template/function/starts_with_test.go new file mode 100644 index 000000000..4ec2974eb --- /dev/null +++ b/hcl2template/function/starts_with_test.go @@ -0,0 +1,98 @@ +package function + +import ( + "fmt" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestStartsWith(t *testing.T) { + tests := []struct { + String, Prefix cty.Value + Want cty.Value + }{ + { + cty.StringVal("hello world"), + cty.StringVal("hello"), + cty.True, + }, + { + cty.StringVal("hey world"), + cty.StringVal("hello"), + cty.False, + }, + { + cty.StringVal(""), + cty.StringVal(""), + cty.True, + }, + { + cty.StringVal(""), + cty.StringVal(" "), + cty.False, + }, + { + cty.StringVal("a"), + cty.StringVal(""), + cty.True, + }, + { + cty.StringVal(""), + cty.StringVal("a"), + cty.False, + }, + + { + // Unicode combining characters edge-case: we match the prefix + // in terms of unicode code units rather than grapheme clusters, + // which is inconsistent with our string processing elsewhere but + // would be a breaking change to fix that bug now. + cty.StringVal("\U0001f937\u200d\u2642"), // "Man Shrugging" is encoded as "Person Shrugging" followed by zero-width joiner and then the masculine gender presentation modifier + cty.StringVal("\U0001f937"), // Just the "Person Shrugging" character without any modifiers + cty.True, + }, + { + cty.StringVal("hello world"), + cty.StringVal(" "), + cty.False, + }, + { + cty.StringVal(" "), + cty.StringVal(""), + cty.True, + }, + { + cty.StringVal(" "), + cty.StringVal("hello"), + cty.False, + }, + { + cty.UnknownVal(cty.String), + cty.StringVal("a"), + cty.UnknownVal(cty.Bool).RefineNotNull(), + }, + { + cty.UnknownVal(cty.String), + cty.StringVal(""), + cty.UnknownVal(cty.Bool).RefineNotNull(), + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("StartsWith(%#v, %#v)", test.String, test.Prefix), func(t *testing.T) { + got, err := StartsWithFunc.Call([]cty.Value{test.String, test.Prefix}) + + if err != nil { + t.Fatalf("unexpected error: %s", err) + } + + if !got.RawEquals(test.Want) { + t.Errorf( + "wrong result\nstring: %#v\nprefix: %#v\ngot: %#v\nwant: %#v", + test.String, test.Prefix, got, test.Want, + ) + } + }) + } +} diff --git a/hcl2template/function/sum.go b/hcl2template/function/sum.go new file mode 100644 index 000000000..ab261714a --- /dev/null +++ b/hcl2template/function/sum.go @@ -0,0 +1,83 @@ +package function + +import ( + "fmt" + "math/big" + + "github.com/zclconf/go-cty/cty" + "github.com/zclconf/go-cty/cty/convert" + "github.com/zclconf/go-cty/cty/function" +) + +var SumFunc = function.New(&function.Spec{ + Params: []function.Parameter{ + { + Name: "list", + Type: cty.DynamicPseudoType, + }, + }, + Type: function.StaticReturnType(cty.Number), + RefineResult: refineNotNull, + Impl: func(args []cty.Value, retType cty.Type) (ret cty.Value, err error) { + + if !args[0].CanIterateElements() { + return cty.NilVal, function.NewArgErrorf(0, "cannot sum noniterable") + } + + if args[0].LengthInt() == 0 { // Easy path + return cty.NilVal, function.NewArgErrorf(0, "cannot sum an empty list") + } + + arg := args[0].AsValueSlice() + ty := args[0].Type() + + if !ty.IsListType() && !ty.IsSetType() && !ty.IsTupleType() { + return cty.NilVal, function.NewArgErrorf(0, "argument must be list, set, or tuple. Received %s", ty.FriendlyName()) + } + + if !args[0].IsWhollyKnown() { + return cty.UnknownVal(cty.Number), nil + } + + // big.Float.Add can panic if the input values are opposing infinities, + // so we must catch that here in order to remain within + // the cty Function abstraction. + defer func() { + if r := recover(); r != nil { + if _, ok := r.(big.ErrNaN); ok { + ret = cty.NilVal + err = fmt.Errorf("can't compute sum of opposing infinities") + } else { + // not a panic we recognize + panic(r) + } + } + }() + + s := arg[0] + if s.IsNull() { + return cty.NilVal, function.NewArgErrorf(0, "argument must be list, set, or tuple of number values") + } + s, err = convert.Convert(s, cty.Number) + if err != nil { + return cty.NilVal, function.NewArgErrorf(0, "argument must be list, set, or tuple of number values") + } + for _, v := range arg[1:] { + if v.IsNull() { + return cty.NilVal, function.NewArgErrorf(0, "argument must be list, set, or tuple of number values") + } + v, err = convert.Convert(v, cty.Number) + if err != nil { + return cty.NilVal, function.NewArgErrorf(0, "argument must be list, set, or tuple of number values") + } + s = s.Add(v) + } + + return s, nil + }, +}) + +// Sum adds numbers in a list, set, or tuple +func Sum(list cty.Value) (cty.Value, error) { + return SumFunc.Call([]cty.Value{list}) +} diff --git a/hcl2template/function/sum_test.go b/hcl2template/function/sum_test.go new file mode 100644 index 000000000..0be9c93e2 --- /dev/null +++ b/hcl2template/function/sum_test.go @@ -0,0 +1,226 @@ +package function + +import ( + "fmt" + "math" + "testing" + + "github.com/zclconf/go-cty/cty" +) + +func TestSum(t *testing.T) { + tests := []struct { + List cty.Value + Want cty.Value + Err string + }{ + { + cty.ListVal([]cty.Value{ + cty.NumberIntVal(1), + cty.NumberIntVal(2), + cty.NumberIntVal(3), + }), + cty.NumberIntVal(6), + "", + }, + { + cty.ListVal([]cty.Value{ + cty.NumberIntVal(1476), + cty.NumberIntVal(2093), + cty.NumberIntVal(2092495), + cty.NumberIntVal(64589234), + cty.NumberIntVal(234), + }), + cty.NumberIntVal(66685532), + "", + }, + { + cty.ListVal([]cty.Value{ + cty.StringVal("a"), + cty.StringVal("b"), + cty.StringVal("c"), + }), + cty.UnknownVal(cty.String), + "argument must be list, set, or tuple of number values", + }, + { + cty.ListVal([]cty.Value{ + cty.NumberIntVal(10), + cty.NumberIntVal(-19), + cty.NumberIntVal(5), + }), + cty.NumberIntVal(-4), + "", + }, + { + cty.ListVal([]cty.Value{ + cty.NumberFloatVal(10.2), + cty.NumberFloatVal(19.4), + cty.NumberFloatVal(5.7), + }), + cty.NumberFloatVal(35.3), + "", + }, + { + cty.ListVal([]cty.Value{ + cty.NumberFloatVal(-10.2), + cty.NumberFloatVal(-19.4), + cty.NumberFloatVal(-5.7), + }), + cty.NumberFloatVal(-35.3), + "", + }, + { + cty.ListVal([]cty.Value{cty.NullVal(cty.Number)}), + cty.NilVal, + "argument must be list, set, or tuple of number values", + }, + { + cty.ListVal([]cty.Value{ + cty.NumberIntVal(5), + cty.NullVal(cty.Number), + }), + cty.NilVal, + "argument must be list, set, or tuple of number values", + }, + { + cty.SetVal([]cty.Value{ + cty.StringVal("a"), + cty.StringVal("b"), + cty.StringVal("c"), + }), + cty.UnknownVal(cty.String).RefineNotNull(), + "argument must be list, set, or tuple of number values", + }, + { + cty.SetVal([]cty.Value{ + cty.NumberIntVal(10), + cty.NumberIntVal(-19), + cty.NumberIntVal(5), + }), + cty.NumberIntVal(-4), + "", + }, + { + cty.SetVal([]cty.Value{ + cty.NumberIntVal(10), + cty.NumberIntVal(25), + cty.NumberIntVal(30), + }), + cty.NumberIntVal(65), + "", + }, + { + cty.SetVal([]cty.Value{ + cty.NumberFloatVal(2340.8), + cty.NumberFloatVal(10.2), + cty.NumberFloatVal(3), + }), + cty.NumberFloatVal(2354), + "", + }, + { + cty.SetVal([]cty.Value{ + cty.NumberFloatVal(2), + }), + cty.NumberFloatVal(2), + "", + }, + { + cty.SetVal([]cty.Value{ + cty.NumberFloatVal(-2), + cty.NumberFloatVal(-50), + cty.NumberFloatVal(-20), + cty.NumberFloatVal(-123), + cty.NumberFloatVal(-4), + }), + cty.NumberFloatVal(-199), + "", + }, + { + cty.TupleVal([]cty.Value{ + cty.NumberIntVal(12), + cty.StringVal("a"), + cty.NumberIntVal(38), + }), + cty.UnknownVal(cty.String).RefineNotNull(), + "argument must be list, set, or tuple of number values", + }, + { + cty.NumberIntVal(12), + cty.NilVal, + "cannot sum noniterable", + }, + { + cty.ListValEmpty(cty.Number), + cty.NilVal, + "cannot sum an empty list", + }, + { + cty.MapVal(map[string]cty.Value{"hello": cty.True}), + cty.NilVal, + "argument must be list, set, or tuple. Received map of bool", + }, + { + cty.UnknownVal(cty.Number), + cty.UnknownVal(cty.Number).RefineNotNull(), + "", + }, + { + cty.UnknownVal(cty.List(cty.Number)), + cty.UnknownVal(cty.Number).RefineNotNull(), + "", + }, + { // known list containing unknown values + cty.ListVal([]cty.Value{cty.UnknownVal(cty.Number)}), + cty.UnknownVal(cty.Number).RefineNotNull(), + "", + }, + { // numbers too large to represent as float64 + cty.ListVal([]cty.Value{ + cty.MustParseNumberVal("1e+500"), + cty.MustParseNumberVal("1e+500"), + }), + cty.MustParseNumberVal("2e+500"), + "", + }, + { // edge case we have a special error handler for + cty.ListVal([]cty.Value{ + cty.NumberFloatVal(math.Inf(1)), + cty.NumberFloatVal(math.Inf(-1)), + }), + cty.NilVal, + "can't compute sum of opposing infinities", + }, + { + cty.ListVal([]cty.Value{ + cty.StringVal("1"), + cty.StringVal("2"), + cty.StringVal("3"), + }), + cty.NumberIntVal(6), + "", + }, + } + + for _, test := range tests { + t.Run(fmt.Sprintf("sum(%#v)", test.List), func(t *testing.T) { + got, err := Sum(test.List) + + if test.Err != "" { + if err == nil { + t.Fatal("succeeded; want error") + } else if got, want := err.Error(), test.Err; got != want { + t.Fatalf("wrong error\n got: %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) + } + }) + } +} diff --git a/hcl2template/functions.go b/hcl2template/functions.go index 81ee6e28d..0490946df 100644 --- a/hcl2template/functions.go +++ b/hcl2template/functions.go @@ -62,6 +62,7 @@ func Functions(basedir string) map[string]function.Function { "dirname": filesystem.DirnameFunc, "distinct": stdlib.DistinctFunc, "element": stdlib.ElementFunc, + "endswith": pkrfunction.EndsWithFunc, "file": filesystem.MakeFileFunc(basedir, false), "fileexists": filesystem.MakeFileExistsFunc(basedir), "fileset": filesystem.MakeFileSetFunc(basedir), @@ -106,9 +107,11 @@ func Functions(basedir string) map[string]function.Function { "slice": stdlib.SliceFunc, "sort": stdlib.SortFunc, "split": stdlib.SplitFunc, + "startswith": pkrfunction.StartsWithFunc, "strcontains": pkrfunction.StrContains, "strrev": stdlib.ReverseFunc, "substr": stdlib.SubstrFunc, + "sum": pkrfunction.SumFunc, "textdecodebase64": TextDecodeBase64Func, "textencodebase64": TextEncodeBase64Func, "timestamp": pkrfunction.TimestampFunc, diff --git a/website/content/docs/templates/hcl_templates/functions/numeric/sum.mdx b/website/content/docs/templates/hcl_templates/functions/numeric/sum.mdx new file mode 100644 index 000000000..91ae0d9a0 --- /dev/null +++ b/website/content/docs/templates/hcl_templates/functions/numeric/sum.mdx @@ -0,0 +1,17 @@ +--- +page_title: sum - Functions - Configuration Language +description: The sum function takes a list or set of numbers and returns the sum of those numbers. +--- + +# `sum` Function + +`sum` takes a list or set of numbers and returns the sum of those numbers. + +`sum` fails when given an empty list or set. + +## Examples + +``` +> sum([10, 13, 6, 4.5]) +33.5 +``` diff --git a/website/content/docs/templates/hcl_templates/functions/string/endswith.mdx b/website/content/docs/templates/hcl_templates/functions/string/endswith.mdx new file mode 100644 index 000000000..3cfc87d21 --- /dev/null +++ b/website/content/docs/templates/hcl_templates/functions/string/endswith.mdx @@ -0,0 +1,27 @@ +--- +page_title: endswith - Functions - Configuration Language +description: |- + The endswith function takes two values: a string to check and a suffix string. It returns true if the first string ends with that exact suffix. +--- + +# `endswith` Function + +`endswith` takes two values: a string to check and a suffix string. The function returns true if the first string ends with that exact suffix. + +```hcl +endswith(string, suffix) +``` + +## Examples + +``` +> endswith("hello world", "world") +true +> endswith("hello world", "hello") +false +``` + +## Related Functions + +- [`startswith`](/packer/docs/templates/hcl_templates/functions/string/startswith) takes two values: a string to check + and a prefix string. The function returns true if the string begins with that exact prefix. diff --git a/website/content/docs/templates/hcl_templates/functions/string/startswith.mdx b/website/content/docs/templates/hcl_templates/functions/string/startswith.mdx new file mode 100644 index 000000000..2faa5391a --- /dev/null +++ b/website/content/docs/templates/hcl_templates/functions/string/startswith.mdx @@ -0,0 +1,27 @@ +--- +page_title: startsswith - Functions - Configuration Language +description: |- + The startswith function takes two values: a string to check and a prefix string. It returns true if the string begins with that exact prefix. +--- + +# `startswith` Function + +`startswith` takes two values: a string to check and a prefix string. The function returns true if the string begins with that exact prefix. + +```hcl +startswith(string, prefix) +``` + +## Examples + +``` +> startswith("hello world", "hello") +true +> startswith("hello world", "world") +false +``` + +## Related Functions + +- [`endswith`](/packer/docs/templates/hcl_templates/functions/string/endswith) takes two values: a string to check + and a suffix string. The function returns true if the first string ends with that exact suffix. diff --git a/website/data/docs-nav-data.json b/website/data/docs-nav-data.json index 1d25fc280..b80e72fef 100644 --- a/website/data/docs-nav-data.json +++ b/website/data/docs-nav-data.json @@ -235,6 +235,10 @@ { "title": "signum", "path": "templates/hcl_templates/functions/numeric/signum" + }, + { + "title": "sum", + "path": "templates/hcl_templates/functions/numeric/sum" } ] }, @@ -245,6 +249,10 @@ "title": "chomp", "path": "templates/hcl_templates/functions/string/chomp" }, + { + "title": "endswith", + "path": "templates/hcl_templates/functions/string/endswith" + }, { "title": "format", "path": "templates/hcl_templates/functions/string/format" @@ -285,6 +293,10 @@ "title": "split", "path": "templates/hcl_templates/functions/string/split" }, + { + "title": "startswith", + "path": "templates/hcl_templates/functions/string/startswith" + }, { "title": "strcontains", "path": "templates/hcl_templates/functions/string/strcontains"