From 656f07d0c2dfdc8064e075826df7255ff1ab078d Mon Sep 17 00:00:00 2001 From: Mike Farah Date: Sun, 7 Dec 2025 15:04:40 +1100 Subject: [PATCH] wip --- pkg/yqlib/decoder_hcl.go | 1 + pkg/yqlib/encoder_hcl.go | 240 +++++++++++++++++++++++++++++++++++++++ pkg/yqlib/format.go | 2 +- pkg/yqlib/hcl_test.go | 61 +++++++++- 4 files changed, 299 insertions(+), 5 deletions(-) create mode 100644 pkg/yqlib/encoder_hcl.go diff --git a/pkg/yqlib/decoder_hcl.go b/pkg/yqlib/decoder_hcl.go index d9213e20..3576e491 100644 --- a/pkg/yqlib/decoder_hcl.go +++ b/pkg/yqlib/decoder_hcl.go @@ -181,6 +181,7 @@ func convertHclExprToNode(expr hclsyntax.Expression, src []byte) *CandidateNode case *hclsyntax.ObjectConsExpr: // parse object into YAML mapping m := &CandidateNode{Kind: MappingNode} + m.Style = FlowStyle // Mark as inline object (flow style) for encoder for _, item := range e.Items { // evaluate key expression to get the key string keyVal, keyDiags := item.KeyExpr.Value(nil) diff --git a/pkg/yqlib/encoder_hcl.go b/pkg/yqlib/encoder_hcl.go new file mode 100644 index 00000000..b152a7b8 --- /dev/null +++ b/pkg/yqlib/encoder_hcl.go @@ -0,0 +1,240 @@ +package yqlib + +import ( + "fmt" + "io" + "strconv" +) + +type hclEncoder struct { + indentString string +} + +// NewHclEncoder creates a new HCL encoder +func NewHclEncoder() Encoder { + return &hclEncoder{ + indentString: " ", // 2 spaces for HCL indentation + } +} + +func (he *hclEncoder) CanHandleAliases() bool { + return false +} + +func (he *hclEncoder) PrintDocumentSeparator(_ io.Writer) error { + return nil +} + +func (he *hclEncoder) PrintLeadingContent(_ io.Writer, _ string) error { + return nil +} + +func (he *hclEncoder) Encode(writer io.Writer, node *CandidateNode) error { + log.Debugf("I need to encode %v", NodeToString(node)) + + return he.encodeNodeInContext(writer, node, "", false) +} + +func (he *hclEncoder) encodeNodeInContext(writer io.Writer, node *CandidateNode, indent string, isInAttribute bool) error { + switch node.Kind { + case ScalarNode: + return writeString(writer, he.formatScalarValue(node.Value)) + case MappingNode: + return he.encodeMappingInContext(writer, node, indent, isInAttribute) + case SequenceNode: + return he.encodeSequence(writer, node, indent) + case AliasNode: + return fmt.Errorf("HCL encoder does not support aliases") + default: + return fmt.Errorf("unsupported node kind: %v", node.Kind) + } +} + +func (he *hclEncoder) encodeMappingInContext(writer io.Writer, node *CandidateNode, indent string, isInAttribute bool) error { + if len(node.Content) == 0 { + return writeString(writer, "{}") + } + + // If this mapping is an attribute value or flow-styled, render as inline object: { a = 1, b = "two" } + if isInAttribute || node.Style == FlowStyle { + return he.encodeInlineMapping(writer, node, indent) + } // If we're at the top level (indent == "") AND all values are scalars OR mappings, + // render as attributes (key = value) or blocks (key { ... }) + if indent == "" { + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + key := keyNode.Value + + // Block-style for nested mappings (unless they're inline objects), attribute-style for scalars/sequences + if valueNode.Kind == MappingNode && valueNode.Style != FlowStyle { + // Block: key { ... } + if err := writeString(writer, key); err != nil { + return err + } + if err := writeString(writer, " {\n"); err != nil { + return err + } + + nextIndent := he.indentString + for j := 0; j < len(valueNode.Content); j += 2 { + nestedKeyNode := valueNode.Content[j] + nestedValueNode := valueNode.Content[j+1] + nestedKey := nestedKeyNode.Value + + if err := writeString(writer, nextIndent); err != nil { + return err + } + if err := writeString(writer, nestedKey); err != nil { + return err + } + if err := writeString(writer, " = "); err != nil { + return err + } + if err := he.encodeNodeInContext(writer, nestedValueNode, nextIndent, true); err != nil { + return err + } + if err := writeString(writer, "\n"); err != nil { + return err + } + } + + if err := writeString(writer, "}\n"); err != nil { + return err + } + } else { + // Attribute: key = value + if err := writeString(writer, key); err != nil { + return err + } + if err := writeString(writer, " = "); err != nil { + return err + } + if err := he.encodeNodeInContext(writer, valueNode, "", true); err != nil { + return err + } + if err := writeString(writer, "\n"); err != nil { + return err + } + } + } + return nil + } + + // Otherwise, this shouldn't happen at nested levels in top-level syntax + return writeString(writer, "{}") +} + +func (he *hclEncoder) encodeInlineMapping(writer io.Writer, node *CandidateNode, indent string) error { + if len(node.Content) == 0 { + return writeString(writer, "{}") + } + + if err := writeString(writer, "{\n"); err != nil { + return err + } + + nextIndent := indent + he.indentString + for i := 0; i < len(node.Content); i += 2 { + keyNode := node.Content[i] + valueNode := node.Content[i+1] + key := keyNode.Value + + if err := writeString(writer, nextIndent); err != nil { + return err + } + if err := writeString(writer, key); err != nil { + return err + } + if err := writeString(writer, " = "); err != nil { + return err + } + if err := he.encodeNodeInContext(writer, valueNode, nextIndent, true); err != nil { + return err + } + if err := writeString(writer, "\n"); err != nil { + return err + } + } + + if err := writeString(writer, indent+"}"); err != nil { + return err + } + + return nil +} + +func (he *hclEncoder) encodeSequence(writer io.Writer, node *CandidateNode, indent string) error { + if len(node.Content) == 0 { + return writeString(writer, "[]") + } + + // Check if we should use inline format (simple values only) + useInline := true + for _, item := range node.Content { + if item.Kind != ScalarNode { + useInline = false + break + } + } + + if useInline { + // Inline format: ["a", "b", "c"] + if err := writeString(writer, "["); err != nil { + return err + } + for i, item := range node.Content { + if i > 0 { + if err := writeString(writer, ", "); err != nil { + return err + } + } + if err := he.encodeNodeInContext(writer, item, indent, true); err != nil { + return err + } + } + if err := writeString(writer, "]"); err != nil { + return err + } + return nil + } + + // Multi-line format for complex items + if err := writeString(writer, "[\n"); err != nil { + return err + } + + nextIndent := indent + he.indentString + for _, item := range node.Content { + if err := writeString(writer, nextIndent); err != nil { + return err + } + if err := he.encodeNodeInContext(writer, item, nextIndent, true); err != nil { + return err + } + if err := writeString(writer, ",\n"); err != nil { + return err + } + } + + if err := writeString(writer, indent+"]"); err != nil { + return err + } + + return nil +} + +func (he *hclEncoder) formatScalarValue(value string) string { + // Check if value is a boolean + if value == "true" || value == "false" { + return value + } + + // Check if value is a number + if _, err := strconv.ParseFloat(value, 64); err == nil { + return value + } + + // Treat as string, quote it + return strconv.Quote(value) +} diff --git a/pkg/yqlib/format.go b/pkg/yqlib/format.go index 030ba48d..033f424d 100644 --- a/pkg/yqlib/format.go +++ b/pkg/yqlib/format.go @@ -68,7 +68,7 @@ var TomlFormat = &Format{"toml", []string{}, } var HclFormat = &Format{"hcl", []string{"h", "hcl"}, - nil, + func() Encoder { return NewHclEncoder() }, func() Decoder { return NewHclDecoder() }, } diff --git a/pkg/yqlib/hcl_test.go b/pkg/yqlib/hcl_test.go index ad7fa5ba..cea4986b 100644 --- a/pkg/yqlib/hcl_test.go +++ b/pkg/yqlib/hcl_test.go @@ -40,7 +40,7 @@ var hclFormatScenarios = []formatScenario{ { description: "object/map attribute", input: `obj = { a = 1, b = "two" }`, - expected: "obj:\n a: 1\n b: two\n", + expected: "obj: {a: 1, b: two}\n", scenarioType: "decode", }, { @@ -76,7 +76,7 @@ var hclFormatScenarios = []formatScenario{ { description: "nested object", input: `config = { db = { host = "localhost", port = 5432 } }`, - expected: "config:\n db:\n host: localhost\n port: 5432\n", + expected: "config: {db: {host: localhost, port: 5432}}\n", scenarioType: "decode", }, { @@ -91,11 +91,64 @@ var hclFormatScenarios = []formatScenario{ expected: "resource aws_instance example:\n ami: ami-12345\n", scenarioType: "decode", }, + { + description: "roundtrip simple attribute", + input: `io_mode = "async"`, + expected: `io_mode = "async"` + "\n", + scenarioType: "roundtrip", + }, + { + description: "roundtrip number attribute", + input: `port = 8080`, + expected: "port = 8080\n", + scenarioType: "roundtrip", + }, + { + description: "roundtrip float attribute", + input: `pi = 3.14`, + expected: "pi = 3.14\n", + scenarioType: "roundtrip", + }, + { + description: "roundtrip boolean attribute", + input: `enabled = true`, + expected: "enabled = true\n", + scenarioType: "roundtrip", + }, + { + description: "roundtrip list of strings", + input: `tags = ["a", "b"]`, + expected: "tags = [\"a\", \"b\"]\n", + scenarioType: "roundtrip", + }, + { + description: "roundtrip object/map attribute", + input: `obj = { a = 1, b = "two" }`, + expected: "obj = {\n a = 1\n b = \"two\"\n}\n", + scenarioType: "roundtrip", + }, + { + description: "roundtrip nested block", + input: `server { port = 8080 }`, + expected: "server {\n port = 8080\n}\n", + scenarioType: "roundtrip", + }, + { + description: "roundtrip multiple attributes", + input: "name = \"app\"\nversion = 1\nenabled = true", + expected: "name = \"app\"\nversion = 1\nenabled = true\n", + scenarioType: "roundtrip", + }, } func testHclScenario(t *testing.T, s formatScenario) { - if s.scenarioType == "decode" { - test.AssertResultWithContext(t, s.expected, mustProcessFormatScenario(s, NewHclDecoder(), NewYamlEncoder(ConfiguredYamlPreferences)), s.description) + switch s.scenarioType { + case "decode": + // Decode to YAML, which means we need to clear HCL-specific tags + result := mustProcessFormatScenario(s, NewHclDecoder(), NewYamlEncoder(ConfiguredYamlPreferences)) + test.AssertResultWithContext(t, s.expected, result, s.description) + case "roundtrip": + test.AssertResultWithContext(t, s.expected, mustProcessFormatScenario(s, NewHclDecoder(), NewHclEncoder()), s.description) } }