Skip to content

Commit

Permalink
add support for nested attribute types in terra
Browse files Browse the repository at this point in the history
  • Loading branch information
jlarfors committed Oct 7, 2024
1 parent 18dd3d6 commit 68d4664
Show file tree
Hide file tree
Showing 14 changed files with 3,894 additions and 107 deletions.
146 changes: 133 additions & 13 deletions pkg/internal/hcl/encode.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (
"reflect"
"strings"

"github.com/hashicorp/hcl/v2/hclsyntax"
"github.com/hashicorp/hcl/v2/hclwrite"
"github.com/zclconf/go-cty/cty"
"github.com/zclconf/go-cty/cty/gocty"
Expand Down Expand Up @@ -262,16 +263,21 @@ func encodeStruct(
if fv.Kind() == reflect.Ptr && fv.IsNil() {
continue
}
if fv.CanInterface() && fv.Interface() != nil {
ctyVal, err := impliedCtyValue(fv)
if err != nil {
return err
}
body.SetAttributeRaw(
tagName,
hclwrite.TokensForValue(ctyVal),
if !(fv.CanInterface() && fv.Interface() != nil) {
continue
}
attrTokens, err := encodeAttributeAsGoType(fv)
if err != nil {
return fmt.Errorf(
"creating tokens for field %s: %w",
sf.Name, err,
)
}
// Make sure that tokens is not nil because we don't want to
// write empty attributes.
if attrTokens != nil {
body.SetAttributeRaw(tagName, attrTokens)
}
}
case "block":
if !sf.IsExported() {
Expand Down Expand Up @@ -317,6 +323,123 @@ func encodeStruct(
return nil
}

// encodeAttributeAsGoType encodes as an HCL attribute.
func encodeAttributeAsGoType(
rv reflect.Value,
) (hclwrite.Tokens, error) {
switch rv.Kind() {
case reflect.Pointer:
if rv.IsNil() {
return nil, nil
}
return encodeAttributeAsGoType(rv.Elem())
case reflect.Map:
if rv.IsNil() {
return nil, nil
}
tokens := hclwrite.Tokens{
&hclwrite.Token{
Type: hclsyntax.TokenOBrace,
Bytes: []byte{'{'},
},
}
iter := rv.MapRange()
for iter.Next() {
keyTokens, err := encodeAttributeAsGoType(iter.Key())
if err != nil {
return nil, err
}
valueTokens, err := encodeAttributeAsGoType(iter.Value())
if err != nil {
return nil, err
}
tokens = append(tokens, keyTokens...)
tokens = append(tokens, &hclwrite.Token{
Type: hclsyntax.TokenEqual,
Bytes: []byte{'='},
})
tokens = append(tokens, valueTokens...)
tokens = append(tokens, &hclwrite.Token{
Type: hclsyntax.TokenComma,
Bytes: []byte{','},
})
}
tokens = append(tokens, &hclwrite.Token{
Type: hclsyntax.TokenCBrace,
Bytes: []byte{'}'},
})
return tokens, nil
case reflect.Array, reflect.Slice:
if rv.Kind() == reflect.Slice && rv.IsNil() {
return nil, nil
}
tokens := hclwrite.Tokens{
&hclwrite.Token{
Type: hclsyntax.TokenOBrack,
Bytes: []byte{'['},
},
}
for i := 0; i < rv.Len(); i++ {
indexTokens, err := encodeAttributeAsGoType(rv.Index(i))
if err != nil {
return nil, err
}
tokens = append(tokens, indexTokens...)
if i < rv.Len()-1 {
tokens = append(tokens, &hclwrite.Token{
Type: hclsyntax.TokenComma,
Bytes: []byte{','},
})
}
}
tokens = append(tokens, &hclwrite.Token{
Type: hclsyntax.TokenCBrack,
Bytes: []byte{']'},
})
return tokens, nil
case reflect.Struct:
file := hclwrite.NewEmptyFile()
body := file.Body()

if err := encodeStruct(rv, nil, body); err != nil {
return nil, err
}
if len(body.BuildTokens(nil)) == 0 {
return nil, nil
}
tokens := hclwrite.Tokens{
{
Type: hclsyntax.TokenOBrace,
Bytes: []byte{'{'},
},
{
Type: hclsyntax.TokenNewline,
Bytes: []byte{'\n'},
},
}
tokens = append(tokens, body.BuildTokens(nil)...)
tokens = append(tokens, &hclwrite.Token{
Type: hclsyntax.TokenCBrace,
Bytes: []byte{'}'},
})
return tokens, nil
default:
// All values, like `terra.String` are actually structs and implement
// the Tokenizer interface.
// Handle all the basic Go types (like string, int) by implying their
// cty type and value.
ctyVal, err := impliedCtyValue(rv)
if err != nil {
return nil, fmt.Errorf(
"unsupported type for attribute: %q. Tried implying the cty value: %w",
rv.Kind(),
err,
)
}
return hclwrite.TokensForValue(ctyVal), nil
}
}

func encodeBlock(
rv reflect.Value,
tagName string,
Expand Down Expand Up @@ -345,11 +468,8 @@ func encodeBlock(
}
return encodeBlock(rv.Elem(), tagName, body)
default:
if rv.IsNil() {
return nil
}
return fmt.Errorf(
"supported type for \",block\" HCL tag: %s",
"unsupported type for \",block\" HCL tag: %s",
rv.Kind(),
)
}
Expand All @@ -372,7 +492,7 @@ func encodeRemainBody(rv reflect.Value, body *hclwrite.Body) error {
return encodeRemainBody(rv.Elem(), body)
default:
return fmt.Errorf(
"supported type for \",remain\" HCL tag: %s",
"unsupported type for \",remain\" HCL tag: %s",
rv.Kind(),
)
}
Expand Down
158 changes: 145 additions & 13 deletions pkg/internal/hcl/encode_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,11 @@ package hcl

import (
"bytes"
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"
"reflect"
"testing"

Expand Down Expand Up @@ -85,8 +90,7 @@ var cbcfg = CommonBlockConfig{
// 4. Assert that they match
//
// This is a bit complex but much more convincing and easier to maintain in the
// long run than
// string-based comparisons.
// long run than string-based comparisons.
func TestEncode(t *testing.T) {
// Create the expected HCL values which we will use to create the encoder
// arguments
Expand Down Expand Up @@ -218,26 +222,154 @@ func TestEncodeRaw(t *testing.T) {
},
},
}
assertEncodeRawAndDecode(t, expectedHCL)
}

func TestEncode_StructWithNil(t *testing.T) {
type StructWithNil struct {
ShouldBeNil *string `hcl:"should_be_nil"`
}
expectedHCL := StructWithNil{}
assertEncodeRawAndDecode(t, expectedHCL)
// Make explicit check that there are no blocks.
block := hclwrite.NewBlock("block", nil)
err := encodeStruct(reflect.ValueOf(expectedHCL), block,
block.Body())
tu.AssertNoError(t, err)

tu.AssertEqual(t, len(block.Body().Attributes()), 0)
}

type StructWithChildAttribute struct {
Child *ChildStruct `hcl:"child,attr"`
Children []ChildStruct `hcl:"children,attr"`
}

type ChildStruct struct {
ChildField string `hcl:"child_field,attr" cty:"child_field"`
}

func TestEncode_StructWithChildAttribute(t *testing.T) {
expectedHCL := StructWithChildAttribute{
Child: &ChildStruct{
ChildField: "child_field_value",
},
Children: []ChildStruct{
{
ChildField: "child_field_value_1",
},
{
ChildField: "child_field_value_2",
},
},
}
assertEncodeRawAndDecode(t, expectedHCL)
}

func assertEncodeRawAndDecode[T any](t *testing.T, in T) {
var b bytes.Buffer
err := EncodeRaw(&b, expectedHCL)
err := EncodeRaw(&b, in)
tu.AssertNoError(t, err, "EncodeRaw failed")

actualHCL := HCLFile{}
err = hclsimple.Decode("test.hcl", b.Bytes(), nil, &actualHCL)
var out T
err = hclsimple.Decode("test.hcl", b.Bytes(), nil, &out)
tu.AssertNoError(t, err, "Decode failed")

if diff := tu.Diff(actualHCL, expectedHCL); diff != "" {
if diff := tu.Diff(out, in); diff != "" {
t.Error(tu.Callers(), diff)
}
}

func TestEncode_StructWithNil(t *testing.T) {
type StructWithNil struct {
ShouldBeNil *string `hcl:"should_be_nil"`
func TestMain(m *testing.M) {
update := flag.Bool("update", false, "update golden files")
flag.Parse()
if *update {
slog.Info("updating golden files and skipping tests")
if err := generatedGoldenFiles(); err != nil {
slog.Error("generating golden files", "error", err)
os.Exit(1)
}
// Skip running tests if updating golden files.
return
}
block := hclwrite.NewBlock("block", nil)
err := encodeStruct(reflect.ValueOf(StructWithNil{}), block, block.Body())
tu.AssertNoError(t, err)
os.Exit(m.Run())
}

tu.AssertEqual(t, len(block.Body().Attributes()), 0)
func TestEncodeRawGolden(t *testing.T) {
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var b bytes.Buffer
err := EncodeRaw(&b, tt.value())
tu.AssertNoError(t, err, "EncodeRaw failed")

goldenFile := filepath.Join(goldenTestDir, tt.name+".hcl")
golden, err := os.ReadFile(goldenFile)
tu.AssertNoError(t, err, "reading golden file failed")

tu.AssertEqual(t, b.String(), string(golden))
})
}
}

type test struct {
name string
value func() interface{}
}

var tests = []test{
{
name: "empty_values",
value: func() interface{} {
type (
Whatever struct{}
EmptyStruct struct {
EmptySlice []string `hcl:"empty_slice,attr"`
EmptyArray [3]string `hcl:"empty_array,attr"`
EmptyMap map[string]string `hcl:"empty_map,attr"`
EmptyWhatever []Whatever `hcl:"empty_whatever,attr"`
}
)
return EmptyStruct{
EmptySlice: []string{},
EmptyArray: [3]string{},
EmptyMap: map[string]string{},
EmptyWhatever: []Whatever{},
}
},
},
{
name: "nil_values",
value: func() interface{} {
type (
Whatever struct{}
NilStruct struct {
NilSlice []Whatever `hcl:"nil_slice,attr"`
NilArray [3]Whatever `hcl:"nil_array,attr"`
NilMap map[string]Whatever `hcl:"nil_map,attr"`
}
)
return NilStruct{}
},
},
}

var goldenTestDir = filepath.Join("testdata", "golden")

func generatedGoldenFiles() error {
if err := os.MkdirAll(goldenTestDir, 0o755); err != nil {
return fmt.Errorf("creating golden directory: %w", err)
}
for _, tt := range tests {
tt := tt
var b bytes.Buffer
err := EncodeRaw(&b, tt.value())
if err != nil {
return fmt.Errorf("encoding %q: %w", tt.name, err)
}
testFile := filepath.Join(goldenTestDir, tt.name+".hcl")
if err := os.WriteFile(testFile, b.Bytes(), 0o644); err != nil {
return fmt.Errorf("writing golden file %q: %w", testFile, err)
}
}
return nil
}
4 changes: 4 additions & 0 deletions pkg/internal/hcl/testdata/golden/empty_values.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
empty_slice = []
empty_array = ["", "", ""]
empty_map = {}
empty_whatever = []
1 change: 1 addition & 0 deletions pkg/internal/hcl/testdata/golden/nil_values.hcl
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
nil_array = [,, ]
Loading

0 comments on commit 68d4664

Please sign in to comment.