Skip to content

Commit 8048794

Browse files
committed
Add gohcl body variable inspections
Signed-off-by: Christian Mesh <[email protected]>
1 parent 5d1b784 commit 8048794

File tree

5 files changed

+324
-11
lines changed

5 files changed

+324
-11
lines changed

gohcl/decode.go

Lines changed: 123 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,13 @@ func decodeBodyToStruct(body hcl.Body, ctx *hcl.EvalContext, val reflect.Value)
123123
fieldV.Set(reflect.ValueOf(attr))
124124
case exprType.AssignableTo(field.Type):
125125
fieldV.Set(reflect.ValueOf(attr.Expr))
126+
case field.Type.Kind() == reflect.Ptr && field.Type.Elem().Kind() == reflect.Struct:
127+
// TODO might want to check for nil here
128+
rn := reflect.New(field.Type.Elem())
129+
fieldV.Set(rn)
130+
diags = append(diags, DecodeExpression(
131+
attr.Expr, ctx, fieldV.Interface(),
132+
)...)
126133
default:
127134
diags = append(diags, DecodeExpression(
128135
attr.Expr, ctx, fieldV.Addr().Interface(),
@@ -276,7 +283,9 @@ func decodeBlockToValue(block *hcl.Block, ctx *hcl.EvalContext, v reflect.Value)
276283

277284
// DecodeExpression extracts the value of the given expression into the given
278285
// value. This value must be something that gocty is able to decode into,
279-
// since the final decoding is delegated to that package.
286+
// since the final decoding is delegated to that package. If a reference to
287+
// a struct is provided which contains gohcl tags, it will be decoded using
288+
// the attr and optional tags.
280289
//
281290
// The given EvalContext is used to resolve any variables or functions in
282291
// expressions encountered while decoding. This may be nil to require only
@@ -290,20 +299,59 @@ func decodeBlockToValue(block *hcl.Block, ctx *hcl.EvalContext, v reflect.Value)
290299
// integration use-cases.
291300
func DecodeExpression(expr hcl.Expression, ctx *hcl.EvalContext, val interface{}) hcl.Diagnostics {
292301
srcVal, diags := expr.Value(ctx)
302+
if diags.HasErrors() {
303+
return diags
304+
}
305+
306+
return append(diags, DecodeValue(srcVal, expr.StartRange(), expr.Range(), val)...)
307+
}
308+
309+
// DecodeValue extracts the given value into the provided target.
310+
// This value must be something that gocty is able to decode into,
311+
// since the final decoding is delegated to that package. If a reference to
312+
// a struct is provided which contains gohcl tags, it will be decoded using
313+
// the attr and optional tags.
314+
//
315+
// The returned diagnostics should be inspected with its HasErrors method to
316+
// determine if the populated value is valid and complete. If error diagnostics
317+
// are returned then the given value may have been partially-populated but
318+
// may still be accessed by a careful caller for static analysis and editor
319+
// integration use-cases.
320+
func DecodeValue(srcVal cty.Value, subject hcl.Range, context hcl.Range, val interface{}) hcl.Diagnostics {
321+
rv := reflect.ValueOf(val)
322+
if rv.Type().Kind() == reflect.Ptr && rv.Type().Elem().Kind() == reflect.Struct && hasFieldTags(rv.Elem().Type()) {
323+
attrs := make(hcl.Attributes)
324+
for k, v := range srcVal.AsValueMap() {
325+
attrs[k] = &hcl.Attribute{
326+
Name: k,
327+
Expr: hcl.StaticExpr(v, context),
328+
Range: subject,
329+
}
330+
331+
}
332+
return decodeBodyToStruct(synthBody{
333+
attrs: attrs,
334+
subject: subject,
335+
context: context,
336+
}, nil, rv.Elem())
337+
338+
}
293339

294340
convTy, err := gocty.ImpliedType(val)
295341
if err != nil {
296342
panic(fmt.Sprintf("unsuitable DecodeExpression target: %s", err))
297343
}
298344

345+
var diags hcl.Diagnostics
346+
299347
srcVal, err = convert.Convert(srcVal, convTy)
300348
if err != nil {
301349
diags = append(diags, &hcl.Diagnostic{
302350
Severity: hcl.DiagError,
303351
Summary: "Unsuitable value type",
304352
Detail: fmt.Sprintf("Unsuitable value: %s", err.Error()),
305-
Subject: expr.StartRange().Ptr(),
306-
Context: expr.Range().Ptr(),
353+
Subject: subject.Ptr(),
354+
Context: context.Ptr(),
307355
})
308356
return diags
309357
}
@@ -314,10 +362,80 @@ func DecodeExpression(expr hcl.Expression, ctx *hcl.EvalContext, val interface{}
314362
Severity: hcl.DiagError,
315363
Summary: "Unsuitable value type",
316364
Detail: fmt.Sprintf("Unsuitable value: %s", err.Error()),
317-
Subject: expr.StartRange().Ptr(),
318-
Context: expr.Range().Ptr(),
365+
Subject: subject.Ptr(),
366+
Context: context.Ptr(),
319367
})
320368
}
321369

322370
return diags
323371
}
372+
373+
type synthBody struct {
374+
attrs hcl.Attributes
375+
subject hcl.Range
376+
context hcl.Range
377+
}
378+
379+
func (s synthBody) Content(schema *hcl.BodySchema) (*hcl.BodyContent, hcl.Diagnostics) {
380+
body, partial, diags := s.PartialContent(schema)
381+
382+
attrs, _ := partial.JustAttributes()
383+
for name := range attrs {
384+
diags = append(diags, &hcl.Diagnostic{
385+
Severity: hcl.DiagError,
386+
Summary: "Unsupported argument",
387+
Detail: fmt.Sprintf("An argument named %q is not expected here.", name),
388+
Subject: s.subject.Ptr(),
389+
Context: s.context.Ptr(),
390+
})
391+
}
392+
393+
return body, diags
394+
}
395+
396+
func (s synthBody) PartialContent(schema *hcl.BodySchema) (*hcl.BodyContent, hcl.Body, hcl.Diagnostics) {
397+
var diags hcl.Diagnostics
398+
399+
for _, block := range schema.Blocks {
400+
panic("hcl block tags are not allowed in attribute structs: " + block.Type)
401+
}
402+
403+
attrs := make(hcl.Attributes)
404+
remainder := make(hcl.Attributes)
405+
406+
for _, attr := range schema.Attributes {
407+
v, ok := s.attrs[attr.Name]
408+
if !ok {
409+
if attr.Required {
410+
diags = append(diags, &hcl.Diagnostic{
411+
Severity: hcl.DiagError,
412+
Summary: "Missing required argument",
413+
Detail: fmt.Sprintf("The argument %q is required, but no definition was found.", attr.Name),
414+
Subject: s.subject.Ptr(),
415+
Context: s.context.Ptr(),
416+
})
417+
}
418+
continue
419+
}
420+
421+
attrs[attr.Name] = v
422+
}
423+
424+
for k, v := range s.attrs {
425+
if _, ok := attrs[k]; !ok {
426+
remainder[k] = v
427+
}
428+
}
429+
430+
return &hcl.BodyContent{
431+
Attributes: attrs,
432+
MissingItemRange: s.context,
433+
}, synthBody{attrs: remainder}, diags
434+
}
435+
436+
func (s synthBody) JustAttributes() (hcl.Attributes, hcl.Diagnostics) {
437+
return s.attrs, nil
438+
}
439+
func (s synthBody) MissingItemRange() hcl.Range {
440+
return s.context
441+
}

gohcl/doc.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,18 +10,18 @@
1010
// A struct field tag scheme is used, similar to other decoding and
1111
// unmarshalling libraries. The tags are formatted as in the following example:
1212
//
13-
// ThingType string `hcl:"thing_type,attr"`
13+
// ThingType string `hcl:"thing_type,attr"`
1414
//
1515
// Within each tag there are two comma-separated tokens. The first is the
1616
// name of the corresponding construct in configuration, while the second
1717
// is a keyword giving the kind of construct expected. The following
1818
// kind keywords are supported:
1919
//
20-
// attr (the default) indicates that the value is to be populated from an attribute
21-
// block indicates that the value is to populated from a block
22-
// label indicates that the value is to populated from a block label
23-
// optional is the same as attr, but the field is optional
24-
// remain indicates that the value is to be populated from the remaining body after populating other fields
20+
// attr (the default) indicates that the value is to be populated from an attribute
21+
// block indicates that the value is to populated from a block
22+
// label indicates that the value is to populated from a block label
23+
// optional is the same as attr, but the field is optional
24+
// remain indicates that the value is to be populated from the remaining body after populating other fields
2525
//
2626
// "attr" fields may either be of type *hcl.Expression, in which case the raw
2727
// expression is assigned, or of any type accepted by gocty, in which case

gohcl/schema.go

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -111,6 +111,18 @@ func ImpliedBodySchema(val interface{}) (schema *hcl.BodySchema, partial bool) {
111111
return schema, partial
112112
}
113113

114+
func hasFieldTags(ty reflect.Type) bool {
115+
ct := ty.NumField()
116+
for i := 0; i < ct; i++ {
117+
field := ty.Field(i)
118+
tag := field.Tag.Get("hcl")
119+
if tag != "" {
120+
return true
121+
}
122+
}
123+
return false
124+
}
125+
114126
type fieldTags struct {
115127
Attributes map[string]int
116128
Blocks map[string]int

gohcl/vardecode.go

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
// Copyright (c) The OpenTofu Authors
2+
// SPDX-License-Identifier: MPL-2.0
3+
// Copyright (c) HashiCorp, Inc.
4+
// SPDX-License-Identifier: MPL-2.0
5+
6+
package gohcl
7+
8+
import (
9+
"fmt"
10+
"reflect"
11+
12+
"github.com/hashicorp/hcl/v2"
13+
)
14+
15+
func VariablesInBody(body hcl.Body, val interface{}) ([]hcl.Traversal, hcl.Diagnostics) {
16+
rv := reflect.ValueOf(val)
17+
if rv.Kind() != reflect.Ptr {
18+
panic(fmt.Sprintf("target value must be a pointer, not %s", rv.Type().String()))
19+
}
20+
21+
return findVariablesInBody(body, rv.Elem())
22+
}
23+
24+
func findVariablesInBody(body hcl.Body, val reflect.Value) ([]hcl.Traversal, hcl.Diagnostics) {
25+
et := val.Type()
26+
switch et.Kind() {
27+
case reflect.Struct:
28+
return findVariablesInBodyStruct(body, val)
29+
case reflect.Map:
30+
return findVariablesInBodyMap(body, val)
31+
default:
32+
panic(fmt.Sprintf("target value must be pointer to struct or map, not %s", et.String()))
33+
}
34+
}
35+
36+
func findVariablesInBodyStruct(body hcl.Body, val reflect.Value) ([]hcl.Traversal, hcl.Diagnostics) {
37+
var variables []hcl.Traversal
38+
39+
schema, partial := ImpliedBodySchema(val.Interface())
40+
41+
var content *hcl.BodyContent
42+
var diags hcl.Diagnostics
43+
if partial {
44+
content, _, diags = body.PartialContent(schema)
45+
} else {
46+
content, diags = body.Content(schema)
47+
}
48+
if content == nil {
49+
return variables, diags
50+
}
51+
52+
tags := getFieldTags(val.Type())
53+
54+
for name := range tags.Attributes {
55+
attr := content.Attributes[name]
56+
if attr != nil {
57+
variables = append(variables, attr.Expr.Variables()...)
58+
}
59+
}
60+
61+
blocksByType := content.Blocks.ByType()
62+
63+
for typeName, fieldIdx := range tags.Blocks {
64+
blocks := blocksByType[typeName]
65+
field := val.Type().Field(fieldIdx)
66+
67+
ty := field.Type
68+
if ty.Kind() == reflect.Slice {
69+
ty = ty.Elem()
70+
}
71+
if ty.Kind() == reflect.Ptr {
72+
ty = ty.Elem()
73+
}
74+
75+
for _, block := range blocks {
76+
blockVars, blockDiags := findVariablesInBody(block.Body, reflect.New(ty).Elem())
77+
variables = append(variables, blockVars...)
78+
diags = append(diags, blockDiags...)
79+
}
80+
81+
}
82+
83+
return variables, diags
84+
}
85+
86+
func findVariablesInBodyMap(body hcl.Body, v reflect.Value) ([]hcl.Traversal, hcl.Diagnostics) {
87+
var variables []hcl.Traversal
88+
89+
attrs, diags := body.JustAttributes()
90+
if attrs == nil {
91+
return variables, diags
92+
}
93+
94+
for _, attr := range attrs {
95+
variables = append(variables, attr.Expr.Variables()...)
96+
}
97+
98+
return variables, diags
99+
}

gohcl/vardecode_test.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
// Copyright (c) The OpenTofu Authors
2+
// SPDX-License-Identifier: MPL-2.0
3+
// Copyright (c) 2023 HashiCorp, Inc.
4+
// SPDX-License-Identifier: MPL-2.0
5+
6+
package gohcl_test
7+
8+
import (
9+
"fmt"
10+
"testing"
11+
12+
"github.com/hashicorp/hcl/v2"
13+
"github.com/hashicorp/hcl/v2/gohcl"
14+
"github.com/hashicorp/hcl/v2/hclsyntax"
15+
"github.com/zclconf/go-cty/cty"
16+
)
17+
18+
var data = `
19+
inner "foo" "bar" {
20+
val = magic.foo.bar
21+
data = {
22+
"z" = nested.value
23+
}
24+
}
25+
`
26+
27+
type InnerBlock struct {
28+
Type string `hcl:"type,label"`
29+
Name string `hcl:"name,label"`
30+
Value string `hcl:"val"`
31+
Data map[string]string `hcl:"data"`
32+
}
33+
34+
type OuterBlock struct {
35+
Contents InnerBlock `hcl:"inner,block"`
36+
}
37+
38+
func Test(t *testing.T) {
39+
40+
println("> Parse HCL")
41+
file, diags := hclsyntax.ParseConfig([]byte(data), "INLINE", hcl.Pos{Byte: 0, Line: 1, Column: 1})
42+
43+
println(diags.Error())
44+
45+
ob := &OuterBlock{}
46+
47+
println()
48+
println("> Detect Variables")
49+
vars, diags := gohcl.VariablesInBody(file.Body, ob)
50+
println(diags.Error())
51+
for _, v := range vars {
52+
ident := ""
53+
for _, p := range v {
54+
if root, ok := p.(hcl.TraverseRoot); ok {
55+
ident += root.Name
56+
}
57+
if attr, ok := p.(hcl.TraverseAttr); ok {
58+
ident += "." + attr.Name
59+
}
60+
}
61+
println("Required: " + ident)
62+
}
63+
64+
println()
65+
println("> Decode Body")
66+
67+
ctx := &hcl.EvalContext{
68+
Variables: map[string]cty.Value{
69+
"magic": cty.ObjectVal(map[string]cty.Value{
70+
"foo": cty.ObjectVal(map[string]cty.Value{
71+
"bar": cty.StringVal("BAR IS BEST BAR"),
72+
}),
73+
}),
74+
"nested": cty.ObjectVal(map[string]cty.Value{
75+
"value": cty.StringVal("ZISHERE"),
76+
}),
77+
},
78+
}
79+
80+
diags = gohcl.DecodeBody(file.Body, ctx, ob)
81+
println(diags.Error())
82+
83+
fmt.Printf("%#v\n", ob)
84+
}

0 commit comments

Comments
 (0)