diff --git a/parsing/hcl/hcl.go b/parsing/hcl/hcl.go index 9b14104..17e9c3c 100644 --- a/parsing/hcl/hcl.go +++ b/parsing/hcl/hcl.go @@ -14,6 +14,5 @@ var _ parsing.Writer = (*hclWriter)(nil) func init() { parsing.RegisterReader(HCL, newHCLReader) - // HCL writer is not implemented yet - //parsing.RegisterWriter(HCL, newHCLWriter) + parsing.RegisterWriter(HCL, newHCLWriter) } diff --git a/parsing/hcl/writer.go b/parsing/hcl/writer.go index 307b478..7ebb510 100644 --- a/parsing/hcl/writer.go +++ b/parsing/hcl/writer.go @@ -1,8 +1,12 @@ package hcl import ( + "bytes" + "fmt" + "github.com/hashicorp/hcl/v2/hclwrite" "github.com/tomwright/dasel/v3/model" "github.com/tomwright/dasel/v3/parsing" + "github.com/zclconf/go-cty/cty" ) func newHCLWriter(options parsing.WriterOptions) (parsing.Writer, error) { @@ -15,5 +19,169 @@ type hclWriter struct { // Write writes a value to a byte slice. func (j *hclWriter) Write(value *model.Value) ([]byte, error) { - return nil, nil + f, err := j.valueToFile(value) + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + if _, err := f.WriteTo(buf); err != nil { + return nil, err + } + return buf.Bytes(), nil +} + +func (j *hclWriter) valueToFile(v *model.Value) (*hclwrite.File, error) { + f := hclwrite.NewEmptyFile() + + body := f.Body() + + if err := j.addValueToBody(nil, v, body); err != nil { + return nil, err + } + + return f, nil +} + +func (j *hclWriter) addValueToBody(previousLabels []string, v *model.Value, body *hclwrite.Body) error { + if !v.IsMap() { + return fmt.Errorf("hcl body is expected to be a map, got %s", v.Type()) + } + + kvs, err := v.MapKeyValues() + if err != nil { + return err + } + + blocks := make([]*hclwrite.Block, 0) + for _, kv := range kvs { + switch kv.Value.Type() { + case model.TypeMap: + block, err := j.valueToBlock(kv.Key, previousLabels, kv.Value) + if err != nil { + return fmt.Errorf("failed to encode %q to hcl block: %w", kv.Key, err) + } + blocks = append(blocks, block) + case model.TypeSlice: + vals := make([]cty.Value, 0) + + allMaps := true + + if err := kv.Value.RangeSlice(func(_ int, value *model.Value) error { + ctyVal, err := j.valueToCty(value) + if err != nil { + return err + } + vals = append(vals, ctyVal) + + if !value.IsMap() { + allMaps = false + } + return nil + }); err != nil { + return err + } + + if allMaps { + if err := kv.Value.RangeSlice(func(_ int, value *model.Value) error { + block, err := j.valueToBlock(kv.Key, previousLabels, value) + if err != nil { + return fmt.Errorf("failed to encode %q to hcl block: %w", kv.Key, err) + } + blocks = append(blocks, block) + return nil + }); err != nil { + return err + } + } else { + body.SetAttributeValue(kv.Key, cty.TupleVal(vals)) + } + + default: + ctyVal, err := j.valueToCty(kv.Value) + if err != nil { + return fmt.Errorf("failed to encode attribute %q: %w", kv.Key, err) + } + body.SetAttributeValue(kv.Key, ctyVal) + } + } + + for _, block := range blocks { + body.AppendBlock(block) + } + + return nil +} + +func (j *hclWriter) valueToCty(v *model.Value) (cty.Value, error) { + switch v.Type() { + case model.TypeString: + val, err := v.StringValue() + if err != nil { + return cty.Value{}, err + } + return cty.StringVal(val), nil + case model.TypeBool: + val, err := v.BoolValue() + if err != nil { + return cty.Value{}, err + } + return cty.BoolVal(val), nil + case model.TypeInt: + val, err := v.IntValue() + if err != nil { + return cty.Value{}, err + } + return cty.NumberIntVal(val), nil + case model.TypeFloat: + val, err := v.FloatValue() + if err != nil { + return cty.Value{}, err + } + return cty.NumberFloatVal(val), nil + case model.TypeNull: + return cty.NullVal(cty.NilType), nil + case model.TypeSlice: + var vals []cty.Value + if err := v.RangeSlice(func(_ int, value *model.Value) error { + ctyVal, err := j.valueToCty(value) + if err != nil { + return err + } + vals = append(vals, ctyVal) + return nil + }); err != nil { + return cty.Value{}, err + } + return cty.TupleVal(vals), nil + case model.TypeMap: + mapV := map[string]cty.Value{} + if err := v.RangeMap(func(s string, value *model.Value) error { + ctyVal, err := j.valueToCty(value) + if err != nil { + return err + } + mapV[s] = ctyVal + return nil + }); err != nil { + return cty.Value{}, err + } + return cty.ObjectVal(mapV), nil + default: + return cty.Value{}, fmt.Errorf("unhandled type when converting to cty value %q", v.Type()) + } +} + +func (j *hclWriter) valueToBlock(key string, labels []string, v *model.Value) (*hclwrite.Block, error) { + if !v.IsMap() { + return nil, fmt.Errorf("must be map") + } + + b := hclwrite.NewBlock(key, labels) + + if err := j.addValueToBody(labels, v, b.Body()); err != nil { + return nil, err + } + + return b, nil } diff --git a/parsing/hcl/writer_test.go b/parsing/hcl/writer_test.go new file mode 100644 index 0000000..c7aa398 --- /dev/null +++ b/parsing/hcl/writer_test.go @@ -0,0 +1,65 @@ +package hcl_test + +import ( + "github.com/google/go-cmp/cmp" + "testing" + + "github.com/tomwright/dasel/v3/parsing" + "github.com/tomwright/dasel/v3/parsing/hcl" +) + +type readWriteTestCase struct { + in string +} + +func (tc readWriteTestCase) run(t *testing.T) { + r, err := hcl.HCL.NewReader(parsing.DefaultReaderOptions()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + w, err := hcl.HCL.NewWriter(parsing.DefaultWriterOptions()) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + in := []byte(tc.in) + + data, err := r.Read(in) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + + got, err := w.Write(data) + if err != nil { + t.Errorf("unexpected error: %v", err) + return + } + gotStr := string(got) + + if !cmp.Equal(tc.in, gotStr) { + t.Errorf("unexpected output: %s", cmp.Diff(tc.in, gotStr)) + } +} + +func TestHclReader_ReadWrite(t *testing.T) { + t.Run("document a", readWriteTestCase{ + in: `io_mode = "async" + +service "http" "web_proxy" { + listen_addr = "127.0.0.1:8080" + + process "main" { + command = ["/usr/local/bin/awesome-app", "server"] + } + + process "mgmt" { + command = ["/usr/local/bin/awesome-app", "mgmt"] + } + + process "mgmt" { + command = ["/usr/local/bin/awesome-app", "mgmt2"] + } +}`, + }.run) +}