summaryrefslogtreecommitdiff
path: root/ext
diff options
context:
space:
mode:
Diffstat (limited to 'ext')
-rw-r--r--ext/tryfunc/README.md44
-rw-r--r--ext/tryfunc/tryfunc.go150
-rw-r--r--ext/tryfunc/tryfunc_test.go193
3 files changed, 387 insertions, 0 deletions
diff --git a/ext/tryfunc/README.md b/ext/tryfunc/README.md
new file mode 100644
index 0000000..5d56eec
--- /dev/null
+++ b/ext/tryfunc/README.md
@@ -0,0 +1,44 @@
+# "Try" and "can" functions
+
+This Go package contains two `cty` functions intended for use in an
+`hcl.EvalContext` when evaluating HCL native syntax expressions.
+
+The first function `try` attempts to evaluate each of its argument expressions
+in order until one produces a result without any errors.
+
+```hcl
+try(non_existent_variable, 2) # returns 2
+```
+
+If none of the expressions succeed, the function call fails with all of the
+errors it encountered.
+
+The second function `can` is similar except that it ignores the result of
+the given expression altogether and simply returns `true` if the expression
+produced a successful result or `false` if it produced errors.
+
+Both of these are primarily intended for working with deep data structures
+which might not have a dependable shape. For example, we can use `try` to
+attempt to fetch a value from deep inside a data structure but produce a
+default value if any step of the traversal fails:
+
+```hcl
+result = try(foo.deep[0].lots.of["traversals"], null)
+```
+
+The final result to `try` should generally be some sort of constant value that
+will always evaluate successfully.
+
+## Using these functions
+
+Languages built on HCL can make `try` and `can` available to user code by
+exporting them in the `hcl.EvalContext` used for expression evaluation:
+
+```go
+ctx := &hcl.EvalContext{
+ Functions: map[string]function.Function{
+ "try": tryfunc.TryFunc,
+ "can": tryfunc.CanFunc,
+ },
+}
+```
diff --git a/ext/tryfunc/tryfunc.go b/ext/tryfunc/tryfunc.go
new file mode 100644
index 0000000..2f4862f
--- /dev/null
+++ b/ext/tryfunc/tryfunc.go
@@ -0,0 +1,150 @@
+// Package tryfunc contains some optional functions that can be exposed in
+// HCL-based languages to allow authors to test whether a particular expression
+// can succeed and take dynamic action based on that result.
+//
+// These functions are implemented in terms of the customdecode extension from
+// the sibling directory "customdecode", and so they are only useful when
+// used within an HCL EvalContext. Other systems using cty functions are
+// unlikely to support the HCL-specific "customdecode" extension.
+package tryfunc
+
+import (
+ "errors"
+ "fmt"
+ "strings"
+
+ "github.com/hashicorp/hcl/v2"
+ "github.com/hashicorp/hcl/v2/ext/customdecode"
+ "github.com/zclconf/go-cty/cty"
+ "github.com/zclconf/go-cty/cty/function"
+)
+
+// TryFunc is a variadic function that tries to evaluate all of is arguments
+// in sequence until one succeeds, in which case it returns that result, or
+// returns an error if none of them succeed.
+var TryFunc function.Function
+
+// CanFunc tries to evaluate the expression given in its first argument.
+var CanFunc function.Function
+
+func init() {
+ TryFunc = function.New(&function.Spec{
+ VarParam: &function.Parameter{
+ Name: "expressions",
+ Type: customdecode.ExpressionClosureType,
+ },
+ Type: func(args []cty.Value) (cty.Type, error) {
+ v, err := try(args)
+ if err != nil {
+ return cty.NilType, err
+ }
+ return v.Type(), nil
+ },
+ Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
+ return try(args)
+ },
+ })
+ CanFunc = function.New(&function.Spec{
+ Params: []function.Parameter{
+ {
+ Name: "expression",
+ Type: customdecode.ExpressionClosureType,
+ },
+ },
+ Type: function.StaticReturnType(cty.Bool),
+ Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) {
+ return can(args[0])
+ },
+ })
+}
+
+func try(args []cty.Value) (cty.Value, error) {
+ if len(args) == 0 {
+ return cty.NilVal, errors.New("at least one argument is required")
+ }
+
+ // We'll collect up all of the diagnostics we encounter along the way
+ // and report them all if none of the expressions succeed, so that the
+ // user might get some hints on how to make at least one succeed.
+ var diags hcl.Diagnostics
+ for _, arg := range args {
+ closure := customdecode.ExpressionClosureFromVal(arg)
+ if dependsOnUnknowns(closure.Expression, closure.EvalContext) {
+ // We can't safely decide if this expression will succeed yet,
+ // and so our entire result must be unknown until we have
+ // more information.
+ return cty.DynamicVal, nil
+ }
+
+ v, moreDiags := closure.Value()
+ diags = append(diags, moreDiags...)
+ if moreDiags.HasErrors() {
+ continue // try the next one, if there is one to try
+ }
+ return v, nil // ignore any accumulated diagnostics if one succeeds
+ }
+
+ // If we fall out here then none of the expressions succeeded, and so
+ // we must have at least one diagnostic and we'll return all of them
+ // so that the user can see the errors related to whichever one they
+ // were expecting to have succeeded in this case.
+ //
+ // Because our function must return a single error value rather than
+ // diagnostics, we'll construct a suitable error message string
+ // that will make sense in the context of the function call failure
+ // diagnostic HCL will eventually wrap this in.
+ var buf strings.Builder
+ buf.WriteString("no expression succeeded:\n")
+ for _, diag := range diags {
+ if diag.Subject != nil {
+ buf.WriteString(fmt.Sprintf("- %s (at %s)\n %s\n", diag.Summary, diag.Subject, diag.Detail))
+ } else {
+ buf.WriteString(fmt.Sprintf("- %s\n %s\n", diag.Summary, diag.Detail))
+ }
+ }
+ buf.WriteString("\nAt least one expression must produce a successful result")
+ return cty.NilVal, errors.New(buf.String())
+}
+
+func can(arg cty.Value) (cty.Value, error) {
+ closure := customdecode.ExpressionClosureFromVal(arg)
+ if dependsOnUnknowns(closure.Expression, closure.EvalContext) {
+ // Can't decide yet, then.
+ return cty.UnknownVal(cty.Bool), nil
+ }
+
+ _, diags := closure.Value()
+ if diags.HasErrors() {
+ return cty.False, nil
+ }
+ return cty.True, nil
+}
+
+// dependsOnUnknowns returns true if any of the variables that the given
+// expression might access are unknown values or contain unknown values.
+//
+// This is a conservative result that prefers to return true if there's any
+// chance that the expression might derive from an unknown value during its
+// evaluation; it is likely to produce false-positives for more complex
+// expressions involving deep data structures.
+func dependsOnUnknowns(expr hcl.Expression, ctx *hcl.EvalContext) bool {
+ for _, traversal := range expr.Variables() {
+ val, diags := traversal.TraverseAbs(ctx)
+ if diags.HasErrors() {
+ // If the traversal returned a definitive error then it must
+ // not traverse through any unknowns.
+ continue
+ }
+ if !val.IsWhollyKnown() {
+ // The value will be unknown if either it refers directly to
+ // an unknown value or if the traversal moves through an unknown
+ // collection. We're using IsWhollyKnown, so this also catches
+ // situations where the traversal refers to a compound data
+ // structure that contains any unknown values. That's important,
+ // because during evaluation the expression might evaluate more
+ // deeply into this structure and encounter the unknowns.
+ return true
+ }
+ }
+ return false
+}
diff --git a/ext/tryfunc/tryfunc_test.go b/ext/tryfunc/tryfunc_test.go
new file mode 100644
index 0000000..063adab
--- /dev/null
+++ b/ext/tryfunc/tryfunc_test.go
@@ -0,0 +1,193 @@
+package tryfunc
+
+import (
+ "testing"
+
+ "github.com/hashicorp/hcl/v2"
+ "github.com/hashicorp/hcl/v2/hclsyntax"
+ "github.com/zclconf/go-cty/cty"
+ "github.com/zclconf/go-cty/cty/function"
+)
+
+func TestTryFunc(t *testing.T) {
+ tests := map[string]struct {
+ expr string
+ vars map[string]cty.Value
+ want cty.Value
+ wantErr string
+ }{
+ "one argument succeeds": {
+ `try(1)`,
+ nil,
+ cty.NumberIntVal(1),
+ ``,
+ },
+ "two arguments, first succeeds": {
+ `try(1, 2)`,
+ nil,
+ cty.NumberIntVal(1),
+ ``,
+ },
+ "two arguments, first fails": {
+ `try(nope, 2)`,
+ nil,
+ cty.NumberIntVal(2),
+ ``,
+ },
+ "two arguments, first depends on unknowns": {
+ `try(unknown, 2)`,
+ map[string]cty.Value{
+ "unknown": cty.UnknownVal(cty.Number),
+ },
+ cty.DynamicVal, // can't proceed until first argument is known
+ ``,
+ },
+ "two arguments, first succeeds and second depends on unknowns": {
+ `try(1, unknown)`,
+ map[string]cty.Value{
+ "unknown": cty.UnknownVal(cty.Number),
+ },
+ cty.NumberIntVal(1), // we know 1st succeeds, so it doesn't matter that 2nd is unknown
+ ``,
+ },
+ "two arguments, first depends on unknowns deeply": {
+ `try(has_unknowns, 2)`,
+ map[string]cty.Value{
+ "has_unknowns": cty.ListVal([]cty.Value{cty.UnknownVal(cty.Bool)}),
+ },
+ cty.DynamicVal, // can't proceed until first argument is wholly known
+ ``,
+ },
+ "two arguments, first traverses through an unkown": {
+ `try(unknown.baz, 2)`,
+ map[string]cty.Value{
+ "unknown": cty.UnknownVal(cty.Map(cty.String)),
+ },
+ cty.DynamicVal, // can't proceed until first argument is wholly known
+ ``,
+ },
+ "three arguments, all fail": {
+ `try(this, that, this_thing_in_particular)`,
+ nil,
+ cty.NumberIntVal(2),
+ // The grammar of this stringification of the message is unfortunate,
+ // but caller can type-assert our result to get the original
+ // diagnostics directly in order to produce a better result.
+ `test.hcl:1,1-5: Error in function call; Call to function "try" failed: no expression succeeded:
+- Variables not allowed (at test.hcl:1,5-9)
+ Variables may not be used here.
+- Variables not allowed (at test.hcl:1,11-15)
+ Variables may not be used here.
+- Variables not allowed (at test.hcl:1,17-41)
+ Variables may not be used here.
+
+At least one expression must produce a successful result.`,
+ },
+ "no arguments": {
+ `try()`,
+ nil,
+ cty.NilVal,
+ `test.hcl:1,1-5: Error in function call; Call to function "try" failed: at least one argument is required.`,
+ },
+ }
+
+ for k, test := range tests {
+ t.Run(k, func(t *testing.T) {
+ expr, diags := hclsyntax.ParseExpression([]byte(test.expr), "test.hcl", hcl.Pos{Line: 1, Column: 1})
+ if diags.HasErrors() {
+ t.Fatalf("unexpected problems: %s", diags.Error())
+ }
+
+ ctx := &hcl.EvalContext{
+ Variables: test.vars,
+ Functions: map[string]function.Function{
+ "try": TryFunc,
+ },
+ }
+
+ got, err := expr.Value(ctx)
+
+ if err != nil {
+ if test.wantErr != "" {
+ if got, want := err.Error(), test.wantErr; got != want {
+ t.Errorf("wrong error\ngot: %s\nwant: %s", got, want)
+ }
+ } else {
+ t.Errorf("unexpected error\ngot: %s\nwant: <nil>", err)
+ }
+ return
+ }
+ if test.wantErr != "" {
+ t.Errorf("wrong error\ngot: <nil>\nwant: %s", test.wantErr)
+ }
+
+ if !test.want.RawEquals(got) {
+ t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want)
+ }
+ })
+ }
+}
+
+func TestCanFunc(t *testing.T) {
+ tests := map[string]struct {
+ expr string
+ vars map[string]cty.Value
+ want cty.Value
+ }{
+ "succeeds": {
+ `can(1)`,
+ nil,
+ cty.True,
+ },
+ "fails": {
+ `can(nope)`,
+ nil,
+ cty.False,
+ },
+ "simple unknown": {
+ `can(unknown)`,
+ map[string]cty.Value{
+ "unknown": cty.UnknownVal(cty.Number),
+ },
+ cty.UnknownVal(cty.Bool),
+ },
+ "traversal through unknown": {
+ `can(unknown.foo)`,
+ map[string]cty.Value{
+ "unknown": cty.UnknownVal(cty.Map(cty.Number)),
+ },
+ cty.UnknownVal(cty.Bool),
+ },
+ "deep unknown": {
+ `can(has_unknown)`,
+ map[string]cty.Value{
+ "has_unknown": cty.ListVal([]cty.Value{cty.UnknownVal(cty.Bool)}),
+ },
+ cty.UnknownVal(cty.Bool),
+ },
+ }
+
+ for k, test := range tests {
+ t.Run(k, func(t *testing.T) {
+ expr, diags := hclsyntax.ParseExpression([]byte(test.expr), "test.hcl", hcl.Pos{Line: 1, Column: 1})
+ if diags.HasErrors() {
+ t.Fatalf("unexpected problems: %s", diags.Error())
+ }
+
+ ctx := &hcl.EvalContext{
+ Variables: test.vars,
+ Functions: map[string]function.Function{
+ "can": CanFunc,
+ },
+ }
+
+ got, err := expr.Value(ctx)
+ if err != nil {
+ t.Errorf("unexpected error\ngot: %s\nwant: <nil>", err)
+ }
+ if !test.want.RawEquals(got) {
+ t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want)
+ }
+ })
+ }
+}