Skip to content

Commit d8ae04b

Browse files
ext/customdecode: Custom expression decoding extension
Most of the time, the standard expression decoding built in to HCL is sufficient. Sometimes though, it's useful to be able to customize the decoding of certain arguments where the application intends to use them in a very specific way, such as in static analysis. This extension is an approximate analog of gohcl's support for decoding into an hcl.Expression, allowing hcldec-based applications and applications with custom functions to similarly capture and manipulate the physical expressions used in arguments, rather than their values. This includes one example use-case: the typeexpr extension now includes a cty.Function called ConvertFunc that takes a type expression as its second argument. A type expression is not evaluatable in the usual sense, but thanks to cty capsule types we _can_ produce a cty.Value from one and then make use of it inside the function implementation, without exposing this custom type to the broader language: convert(["foo"], set(string)) This mechanism is intentionally restricted only to "argument-like" locations where there is a specific type we are attempting to decode into. For now, that's hcldec AttrSpec/BlockAttrsSpec -- analogous to gohcl decoding into hcl.Expression -- and in arguments to functions.
1 parent afbe524 commit d8ae04b

10 files changed

Lines changed: 952 additions & 18 deletions

File tree

ext/customdecode/README.md

Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
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.

ext/customdecode/customdecode.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
// Package customdecode contains a HCL extension that allows, in certain
2+
// contexts, expression evaluation to be overridden by custom static analysis.
3+
//
4+
// This mechanism is only supported in certain specific contexts where
5+
// expressions are decoded with a specific target type in mind. For more
6+
// information, see the documentation on CustomExpressionDecoder.
7+
package customdecode
8+
9+
import (
10+
"github.com/hashicorp/hcl/v2"
11+
"github.com/zclconf/go-cty/cty"
12+
)
13+
14+
type customDecoderImpl int
15+
16+
// CustomExpressionDecoder is a value intended to be used as a cty capsule
17+
// type ExtensionData key for capsule types whose values are to be obtained
18+
// by static analysis of an expression rather than normal evaluation of that
19+
// expression.
20+
//
21+
// When a cooperating capsule type is asked for ExtensionData with this key,
22+
// it must return a non-nil CustomExpressionDecoderFunc value.
23+
//
24+
// This mechanism is not universally supported; instead, it's handled in a few
25+
// specific places where expressions are evaluated with the intent of producing
26+
// a cty.Value of a type given by the calling application.
27+
//
28+
// Specifically, this currently works for type constraints given in
29+
// hcldec.AttrSpec and hcldec.BlockAttrsSpec, and it works for arguments to
30+
// function calls in the HCL native syntax. HCL extensions implemented outside
31+
// of the main HCL module may also implement this; consult their own
32+
// documentation for details.
33+
const CustomExpressionDecoder = customDecoderImpl(1)
34+
35+
// CustomExpressionDecoderFunc is the type of value that must be returned by
36+
// a capsule type handling the key CustomExpressionDecoder in its ExtensionData
37+
// implementation.
38+
//
39+
// If no error diagnostics are returned, the result value MUST be of the
40+
// capsule type that the decoder function was derived from. If the returned
41+
// error diagnostics prevent producing a value at all, return cty.NilVal.
42+
type CustomExpressionDecoderFunc func(expr hcl.Expression, ctx *hcl.EvalContext) (cty.Value, hcl.Diagnostics)
43+
44+
// CustomExpressionDecoderForType takes any cty type and returns its
45+
// custom expression decoder implementation if it has one. If it is not a
46+
// capsule type or it does not implement a custom expression decoder, this
47+
// function returns nil.
48+
func CustomExpressionDecoderForType(ty cty.Type) CustomExpressionDecoderFunc {
49+
if !ty.IsCapsuleType() {
50+
return nil
51+
}
52+
if fn, ok := ty.CapsuleExtensionData(CustomExpressionDecoder).(CustomExpressionDecoderFunc); ok {
53+
return fn
54+
}
55+
return nil
56+
}

0 commit comments

Comments
 (0)