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: ", err) + } + return + } + if test.wantErr != "" { + t.Errorf("wrong error\ngot: \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: ", err) + } + if !test.want.RawEquals(got) { + t.Errorf("wrong result\ngot: %#v\nwant: %#v", got, test.want) + } + }) + } +}