|
| 1 | +# HCL Custom Static Decoding Extension |
| 2 | + |
| 3 | +This HCL extension provides a mechanism for defining arguments in an HCL-based |
| 4 | +language whose values are derived using custom decoding rules against the |
| 5 | +HCL expression syntax, overriding the usual behavior of normal expression |
| 6 | +evaluation. |
| 7 | + |
| 8 | +"Arguments", for the purpose of this extension, currently includes the |
| 9 | +following two contexts: |
| 10 | + |
| 11 | +* For applications using `hcldec` for dynamic decoding, a `hcldec.AttrSpec` |
| 12 | + or `hcldec.BlockAttrsSpec` can be given a special type constraint that |
| 13 | + opts in to custom decoding behavior for the attribute(s) that are selected |
| 14 | + by that specification. |
| 15 | + |
| 16 | +* When working with the HCL native expression syntax, a function given in |
| 17 | + the `hcl.EvalContext` during evaluation can have parameters with special |
| 18 | + type constraints that opt in to custom decoding behavior for the argument |
| 19 | + expression associated with that parameter in any call. |
| 20 | + |
| 21 | +The above use-cases are rather abstract, so we'll consider a motivating |
| 22 | +real-world example: sometimes we (language designers) need to allow users |
| 23 | +to specify type constraints directly in the language itself, such as in |
| 24 | +[Terraform's Input Variables](https://www.terraform.io/docs/configuration/variables.html). |
| 25 | +Terraform's `variable` blocks include an argument called `type` which takes |
| 26 | +a type constraint given using HCL expression building-blocks as defined by |
| 27 | +[the HCL `typeexpr` extension](../typeexpr/README.md). |
| 28 | + |
| 29 | +A "type constraint expression" of that sort is not an expression intended to |
| 30 | +be evaluated in the usual way. Instead, the physical expression is |
| 31 | +deconstructed using [the static analysis operations](../../spec.md#static-analysis) |
| 32 | +to produce a `cty.Type` as the result, rather than a `cty.Value`. |
| 33 | + |
| 34 | +The purpose of this Custom Static Decoding Extension, then, is to provide a |
| 35 | +bridge to allow that sort of custom decoding to be used via mechanisms that |
| 36 | +normally deal in `cty.Value`, such as `hcldec` and native syntax function |
| 37 | +calls as listed above. |
| 38 | + |
| 39 | +(Note: [`gohcl`](https://pkg.go.dev/github.com/hashicorp/hcl/v2/gohcl) has |
| 40 | +its own mechanism to support this use case, exploiting the fact that it is |
| 41 | +working directly with "normal" Go types. Decoding into a struct field of |
| 42 | +type `hcl.Expression` obtains the expression directly without evaluating it |
| 43 | +first. The Custom Static Decoding Extension is not necessary for that `gohcl` |
| 44 | +technique. You can also implement custom decoding by working directly with |
| 45 | +the lowest-level HCL API, which separates extraction of and evaluation of |
| 46 | +expressions into two steps.) |
| 47 | + |
| 48 | +## Custom Decoding Types |
| 49 | + |
| 50 | +This extension relies on a convention implemented in terms of |
| 51 | +[_Capsule Types_ in the underlying `cty` type system](https://github.com/zclconf/go-cty/blob/master/docs/types.md#capsule-types). `cty` allows a capsule type to carry arbitrary |
| 52 | +extension metadata values as an aid to creating higher-level abstractions like |
| 53 | +this extension. |
| 54 | + |
| 55 | +A custom argument decoding mode, then, is implemented by creating a new `cty` |
| 56 | +capsule type that implements the `ExtensionData` custom operation to return |
| 57 | +a decoding function when requested. For example: |
| 58 | + |
| 59 | +```go |
| 60 | +var keywordType cty.Type |
| 61 | +keywordType = cty.CapsuleWithOps("keyword", reflect.TypeOf(""), &cty.CapsuleOps{ |
| 62 | + ExtensionData: func(key interface{}) interface{} { |
| 63 | + switch key { |
| 64 | + case customdecode.CustomExpressionDecoder: |
| 65 | + return customdecode.CustomExpressionDecoderFunc( |
| 66 | + func(expr hcl.Expression, ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics) { |
| 67 | + var diags hcl.Diagnostics |
| 68 | + kw := hcl.ExprAsKeyword(expr) |
| 69 | + if kw == "" { |
| 70 | + diags = append(diags, &hcl.Diagnostic{ |
| 71 | + Severity: hcl.DiagError, |
| 72 | + Summary: "Invalid keyword", |
| 73 | + Detail: "A keyword is required", |
| 74 | + Subject: expr.Range().Ptr(), |
| 75 | + }) |
| 76 | + return cty.UnkownVal(keywordType), diags |
| 77 | + } |
| 78 | + return cty.CapsuleVal(keywordType, &kw) |
| 79 | + }, |
| 80 | + ) |
| 81 | + default: |
| 82 | + return nil |
| 83 | + } |
| 84 | + }, |
| 85 | +}) |
| 86 | +``` |
| 87 | + |
| 88 | +The boilerplate here is a bit fussy, but the important part for our purposes |
| 89 | +is the `case customdecode.CustomExpressionDecoder:` clause, which uses |
| 90 | +a custom extension key type defined in this package to recognize when a |
| 91 | +component implementing this extension is checking to see if a target type |
| 92 | +has a custom decode implementation. |
| 93 | + |
| 94 | +In the above case we've defined a type that decodes expressions as static |
| 95 | +keywords, so a keyword like `foo` would decode as an encapsulated `"foo"` |
| 96 | +string, while any other sort of expression like `"baz"` or `1 + 1` would |
| 97 | +return an error. |
| 98 | + |
| 99 | +We could then use `keywordType` as a type constraint either for a function |
| 100 | +parameter or a `hcldec` attribute specification, which would require the |
| 101 | +argument for that function parameter or the expression for the matching |
| 102 | +attributes to be a static keyword, rather than an arbitrary expression. |
| 103 | +For example, in a `hcldec.AttrSpec`: |
| 104 | + |
| 105 | +```go |
| 106 | +keywordSpec := &hcldec.AttrSpec{ |
| 107 | + Name: "keyword", |
| 108 | + Type: keywordType, |
| 109 | +} |
| 110 | +``` |
| 111 | + |
| 112 | +The above would accept input like the following and would set its result to |
| 113 | +a `cty.Value` of `keywordType`, after decoding: |
| 114 | + |
| 115 | +```hcl |
| 116 | +keyword = foo |
| 117 | +``` |
| 118 | + |
| 119 | +## The Expression and Expression Closure `cty` types |
| 120 | + |
| 121 | +Building on the above, this package also includes two capsule types that use |
| 122 | +the above mechanism to allow calling applications to capture expressions |
| 123 | +directly and thus defer analysis to a later step, after initial decoding. |
| 124 | + |
| 125 | +The `customdecode.ExpressionType` type encapsulates an `hcl.Expression` alone, |
| 126 | +for situations like our type constraint expression example above where it's |
| 127 | +the static structure of the expression we want to inspect, and thus any |
| 128 | +variables and functions defined in the evaluation context are irrelevant. |
| 129 | + |
| 130 | +The `customdecode.ExpressionClosureType` type encapsulates a |
| 131 | +`*customdecode.ExpressionClosure` value, which binds the given expression to |
| 132 | +the `hcl.EvalContext` it was asked to evaluate against and thus allows the |
| 133 | +receiver of that result to later perform normal evaluation of the expression |
| 134 | +with all the same variables and functions that would've been available to it |
| 135 | +naturally. |
| 136 | + |
| 137 | +Both of these types can be used as type constraints either for `hcldec` |
| 138 | +attribute specifications or for function arguments. Here's an example of |
| 139 | +`ExpressionClosureType` to implement a function that can evaluate |
| 140 | +an expression with some additional variables defined locally, which we'll |
| 141 | +call the `with(...)` function: |
| 142 | + |
| 143 | +```go |
| 144 | +var WithFunc = function.New(&function.Spec{ |
| 145 | + Params: []function.Parameter{ |
| 146 | + { |
| 147 | + Name: "variables", |
| 148 | + Type: cty.DynamicPseudoType, |
| 149 | + }, |
| 150 | + { |
| 151 | + Name: "expression", |
| 152 | + Type: customdecode.ExpressionClosureType, |
| 153 | + }, |
| 154 | + }, |
| 155 | + Type: func(args []cty.Value) (cty.Type, error) { |
| 156 | + varsVal := args[0] |
| 157 | + exprVal := args[1] |
| 158 | + if !varsVal.Type().IsObjectType() { |
| 159 | + return cty.NilVal, function.NewArgErrorf(0, "must be an object defining local variables") |
| 160 | + } |
| 161 | + if !varsVal.IsKnown() { |
| 162 | + // We can't predict our result type until the variables object |
| 163 | + // is known. |
| 164 | + return cty.DynamicPseudoType, nil |
| 165 | + } |
| 166 | + vars := varsVal.AsValueMap() |
| 167 | + closure := customdecode.ExpressionClosureFromVal(exprVal) |
| 168 | + result, err := evalWithLocals(vars, closure) |
| 169 | + if err != nil { |
| 170 | + return cty.NilVal, err |
| 171 | + } |
| 172 | + return result.Type(), nil |
| 173 | + }, |
| 174 | + Impl: func(args []cty.Value, retType cty.Type) (cty.Value, error) { |
| 175 | + varsVal := args[0] |
| 176 | + exprVal := args[1] |
| 177 | + vars := varsVal.AsValueMap() |
| 178 | + closure := customdecode.ExpressionClosureFromVal(exprVal) |
| 179 | + return evalWithLocals(vars, closure) |
| 180 | + }, |
| 181 | +}) |
| 182 | + |
| 183 | +func evalWithLocals(locals map[string]cty.Value, closure *customdecode.ExpressionClosure) (cty.Value, error) { |
| 184 | + childCtx := closure.EvalContext.NewChild() |
| 185 | + childCtx.Variables = locals |
| 186 | + val, diags := closure.Expression.Value(childCtx) |
| 187 | + if diags.HasErrors() { |
| 188 | + return cty.NilVal, function.NewArgErrorf(1, "couldn't evaluate expression: %s", diags.Error()) |
| 189 | + } |
| 190 | + return val, nil |
| 191 | +} |
| 192 | +``` |
| 193 | + |
| 194 | +If the above function were placed into an `hcl.EvalContext` as `with`, it |
| 195 | +could be used in a native syntax call to that function as follows: |
| 196 | + |
| 197 | +```hcl |
| 198 | + foo = with({name = "Cory"}, "${greeting}, ${name}!") |
| 199 | +``` |
| 200 | + |
| 201 | +The above assumes a variable in the main context called `greeting`, to which |
| 202 | +the `with` function adds `name` before evaluating the expression given in |
| 203 | +its second argument. This makes that second argument context-sensitive -- it |
| 204 | +would behave differently if the user wrote the same thing somewhere else -- so |
| 205 | +this capability should be used with care to make sure it doesn't cause confusion |
| 206 | +for the end-users of your language. |
| 207 | + |
| 208 | +There are some other examples of this capability to evaluate expressions in |
| 209 | +unusual ways in the `tryfunc` directory that is a sibling of this one. |
0 commit comments