mirror of
https://github.com/mikefarah/yq.git
synced 2026-07-04 11:25:37 +00:00
Better roundtriping of HCL
This commit is contained in:
parent
e4bf8a1e0a
commit
f4fd8c585a
@ -135,6 +135,7 @@ Key methods:
|
|||||||
- Use the candidate_node style attribute to store style information for round-trip. Ask if this needs to be updated with new styles.
|
- Use the candidate_node style attribute to store style information for round-trip. Ask if this needs to be updated with new styles.
|
||||||
- Use build tags for optional compilation
|
- Use build tags for optional compilation
|
||||||
- Add comprehensive tests
|
- Add comprehensive tests
|
||||||
|
- Run the specific encoder/decoder test (e.g. <format>_test.go) whenever you make ay changes to the encoder_<format> or decoder_<format>
|
||||||
- Handle errors gracefully
|
- Handle errors gracefully
|
||||||
- Add the no build directive, like the xml encoder and decoder, that enables a minimal yq builds. e.g. `//go:build !yq_<format>`. Be sure to also update the build_small-yq.sh and build-tinygo-yq.sh to not include the new format.
|
- Add the no build directive, like the xml encoder and decoder, that enables a minimal yq builds. e.g. `//go:build !yq_<format>`. Be sure to also update the build_small-yq.sh and build-tinygo-yq.sh to not include the new format.
|
||||||
|
|
||||||
|
|||||||
@ -97,6 +97,9 @@ type CandidateNode struct {
|
|||||||
// (e.g. top level cross document merge). This property does not propagate to child nodes.
|
// (e.g. top level cross document merge). This property does not propagate to child nodes.
|
||||||
EvaluateTogether bool
|
EvaluateTogether bool
|
||||||
IsMapKey bool
|
IsMapKey bool
|
||||||
|
// For formats like HCL and TOML: indicates that child entries should be emitted as separate blocks/tables
|
||||||
|
// rather than consolidated into nested mappings (default behaviour)
|
||||||
|
EncodeSeparate bool
|
||||||
}
|
}
|
||||||
|
|
||||||
func (n *CandidateNode) CreateChild() *CandidateNode {
|
func (n *CandidateNode) CreateChild() *CandidateNode {
|
||||||
@ -407,6 +410,8 @@ func (n *CandidateNode) doCopy(cloneContent bool) *CandidateNode {
|
|||||||
|
|
||||||
EvaluateTogether: n.EvaluateTogether,
|
EvaluateTogether: n.EvaluateTogether,
|
||||||
IsMapKey: n.IsMapKey,
|
IsMapKey: n.IsMapKey,
|
||||||
|
|
||||||
|
EncodeSeparate: n.EncodeSeparate,
|
||||||
}
|
}
|
||||||
|
|
||||||
if cloneContent {
|
if cloneContent {
|
||||||
|
|||||||
@ -161,8 +161,14 @@ func (dec *hclDecoder) Decode() (*CandidateNode, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// process blocks
|
// process blocks
|
||||||
|
// Count blocks by type at THIS level to detect multiple separate blocks
|
||||||
|
blocksByType := make(map[string]int)
|
||||||
for _, block := range body.Blocks {
|
for _, block := range body.Blocks {
|
||||||
addBlockToMapping(root, block, dec.fileBytes)
|
blocksByType[block.Type]++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, block := range body.Blocks {
|
||||||
|
addBlockToMapping(root, block, dec.fileBytes, blocksByType[block.Type] > 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
dec.documentIndex++
|
dec.documentIndex++
|
||||||
@ -187,14 +193,23 @@ func hclBodyToNode(body *hclsyntax.Body, src []byte) *CandidateNode {
|
|||||||
|
|
||||||
node.AddKeyValueChild(key, val)
|
node.AddKeyValueChild(key, val)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process nested blocks, counting blocks by type at THIS level
|
||||||
|
// to detect which block types appear multiple times
|
||||||
|
blocksByType := make(map[string]int)
|
||||||
for _, block := range body.Blocks {
|
for _, block := range body.Blocks {
|
||||||
addBlockToMapping(node, block, src)
|
blocksByType[block.Type]++
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, block := range body.Blocks {
|
||||||
|
addBlockToMapping(node, block, src, blocksByType[block.Type] > 1)
|
||||||
}
|
}
|
||||||
return node
|
return node
|
||||||
}
|
}
|
||||||
|
|
||||||
// addBlockToMapping nests block type and labels into the parent mapping, merging children.
|
// addBlockToMapping nests block type and labels into the parent mapping, merging children.
|
||||||
func addBlockToMapping(parent *CandidateNode, block *hclsyntax.Block, src []byte) {
|
// isMultipleBlocksOfType indicates if there are multiple blocks of this type at THIS level
|
||||||
|
func addBlockToMapping(parent *CandidateNode, block *hclsyntax.Block, src []byte, isMultipleBlocksOfType bool) {
|
||||||
bodyNode := hclBodyToNode(block.Body, src)
|
bodyNode := hclBodyToNode(block.Body, src)
|
||||||
current := parent
|
current := parent
|
||||||
|
|
||||||
@ -208,6 +223,11 @@ func addBlockToMapping(parent *CandidateNode, block *hclsyntax.Block, src []byte
|
|||||||
}
|
}
|
||||||
if typeNode == nil {
|
if typeNode == nil {
|
||||||
_, typeNode = current.AddKeyValueChild(createStringScalarNode(block.Type), &CandidateNode{Kind: MappingNode})
|
_, typeNode = current.AddKeyValueChild(createStringScalarNode(block.Type), &CandidateNode{Kind: MappingNode})
|
||||||
|
// Mark the type node if there are multiple blocks of this type at this level
|
||||||
|
// This tells the encoder to emit them as separate blocks rather than consolidating them
|
||||||
|
if isMultipleBlocksOfType {
|
||||||
|
typeNode.EncodeSeparate = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
current = typeNode
|
current = typeNode
|
||||||
|
|
||||||
|
|||||||
@ -175,3 +175,27 @@ message = "Hello, ${name}!"
|
|||||||
shouty_message = upper(message)
|
shouty_message = upper(message)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Roundtrip: Separate blocks with same name.
|
||||||
|
Given a sample.hcl file of:
|
||||||
|
```hcl
|
||||||
|
resource "aws_instance" "web" {
|
||||||
|
ami = "ami-12345"
|
||||||
|
}
|
||||||
|
resource "aws_instance" "db" {
|
||||||
|
ami = "ami-67890"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
then
|
||||||
|
```bash
|
||||||
|
yq sample.hcl
|
||||||
|
```
|
||||||
|
will output
|
||||||
|
```hcl
|
||||||
|
resource "aws_instance" "web" {
|
||||||
|
ami = "ami-12345"
|
||||||
|
}
|
||||||
|
resource "aws_instance" "db" {
|
||||||
|
ami = "ami-67890"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
|||||||
@ -438,6 +438,13 @@ func (he *hclEncoder) encodeBlockIfMapping(body *hclwrite.Body, key string, valu
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If EncodeSeparate is set, emit children as separate blocks regardless of label extraction
|
||||||
|
if valueNode.EncodeSeparate {
|
||||||
|
if handled, _ := he.encodeMappingChildrenAsBlocks(body, key, valueNode); handled {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Try to extract block labels from a single-entry mapping chain
|
// Try to extract block labels from a single-entry mapping chain
|
||||||
if labels, bodyNode, ok := extractBlockLabels(valueNode); ok {
|
if labels, bodyNode, ok := extractBlockLabels(valueNode); ok {
|
||||||
if len(labels) > 1 && mappingChildrenAllMappings(bodyNode) {
|
if len(labels) > 1 && mappingChildrenAllMappings(bodyNode) {
|
||||||
@ -519,17 +526,46 @@ func (he *hclEncoder) encodeMappingChildrenAsBlocks(body *hclwrite.Body, blockTy
|
|||||||
return false, nil
|
return false, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only emit as separate blocks if EncodeSeparate is true
|
||||||
|
// This allows the encoder to respect the original block structure preserved by the decoder
|
||||||
|
if !valueNode.EncodeSeparate {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
for i := 0; i < len(valueNode.Content); i += 2 {
|
for i := 0; i < len(valueNode.Content); i += 2 {
|
||||||
childKey := valueNode.Content[i].Value
|
childKey := valueNode.Content[i].Value
|
||||||
childVal := valueNode.Content[i+1]
|
childVal := valueNode.Content[i+1]
|
||||||
labels := []string{childKey}
|
|
||||||
if extraLabels, bodyNode, ok := extractBlockLabels(childVal); ok {
|
// Check if this child also represents multiple blocks (all children are mappings)
|
||||||
labels = append(labels, extraLabels...)
|
if mappingChildrenAllMappings(childVal) {
|
||||||
childVal = bodyNode
|
// Recursively emit each grandchild as a separate block with extended labels
|
||||||
}
|
for j := 0; j < len(childVal.Content); j += 2 {
|
||||||
block := body.AppendNewBlock(blockType, labels)
|
grandchildKey := childVal.Content[j].Value
|
||||||
if err := he.encodeNodeAttributes(block.Body(), childVal); err != nil {
|
grandchildVal := childVal.Content[j+1]
|
||||||
return true, err
|
labels := []string{childKey, grandchildKey}
|
||||||
|
|
||||||
|
// Try to extract additional labels if this is a single-entry chain
|
||||||
|
if extraLabels, bodyNode, ok := extractBlockLabels(grandchildVal); ok {
|
||||||
|
labels = append(labels, extraLabels...)
|
||||||
|
grandchildVal = bodyNode
|
||||||
|
}
|
||||||
|
|
||||||
|
block := body.AppendNewBlock(blockType, labels)
|
||||||
|
if err := he.encodeNodeAttributes(block.Body(), grandchildVal); err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Single block with this child as label(s)
|
||||||
|
labels := []string{childKey}
|
||||||
|
if extraLabels, bodyNode, ok := extractBlockLabels(childVal); ok {
|
||||||
|
labels = append(labels, extraLabels...)
|
||||||
|
childVal = bodyNode
|
||||||
|
}
|
||||||
|
block := body.AppendNewBlock(blockType, labels)
|
||||||
|
if err := he.encodeNodeAttributes(block.Body(), childVal); err != nil {
|
||||||
|
return true, err
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -345,6 +345,68 @@ var hclFormatScenarios = []formatScenario{
|
|||||||
expected: "tags:\n - \"a\"\n - \"b\"\n",
|
expected: "tags:\n - \"a\"\n - \"b\"\n",
|
||||||
scenarioType: "decode",
|
scenarioType: "decode",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
description: "roundtrip list of objects",
|
||||||
|
skipDoc: true,
|
||||||
|
input: `items = [{ name = "a", value = 1 }, { name = "b", value = 2 }]`,
|
||||||
|
expected: "items = [{\n name = \"a\"\n value = 1\n }, {\n name = \"b\"\n value = 2\n}]\n",
|
||||||
|
scenarioType: "roundtrip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "roundtrip nested blocks with same name",
|
||||||
|
skipDoc: true,
|
||||||
|
input: "database \"primary\" {\n host = \"localhost\"\n port = 5432\n}\ndatabase \"replica\" {\n host = \"replica.local\"\n port = 5433\n}",
|
||||||
|
expected: "database \"primary\" {\n host = \"localhost\"\n port = 5432\n}\ndatabase \"replica\" {\n host = \"replica.local\"\n port = 5433\n}\n",
|
||||||
|
scenarioType: "roundtrip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "roundtrip mixed nested structure",
|
||||||
|
skipDoc: true,
|
||||||
|
input: "servers \"web\" {\n addresses = [\"10.0.1.1\", \"10.0.1.2\"]\n port = 8080\n}",
|
||||||
|
expected: "servers \"web\" {\n addresses = [\"10.0.1.1\", \"10.0.1.2\"]\n port = 8080\n}\n",
|
||||||
|
scenarioType: "roundtrip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "roundtrip null value",
|
||||||
|
skipDoc: true,
|
||||||
|
input: `value = null`,
|
||||||
|
expected: "value = null\n",
|
||||||
|
scenarioType: "roundtrip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "roundtrip empty list",
|
||||||
|
skipDoc: true,
|
||||||
|
input: `items = []`,
|
||||||
|
expected: "items = []\n",
|
||||||
|
scenarioType: "roundtrip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "roundtrip empty object",
|
||||||
|
skipDoc: true,
|
||||||
|
input: `config = {}`,
|
||||||
|
expected: "config = {}\n",
|
||||||
|
scenarioType: "roundtrip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "Roundtrip: Separate blocks with same name.",
|
||||||
|
input: "resource \"aws_instance\" \"web\" {\n ami = \"ami-12345\"\n}\nresource \"aws_instance\" \"db\" {\n ami = \"ami-67890\"\n}",
|
||||||
|
expected: "resource \"aws_instance\" \"web\" {\n ami = \"ami-12345\"\n}\nresource \"aws_instance\" \"db\" {\n ami = \"ami-67890\"\n}\n",
|
||||||
|
scenarioType: "roundtrip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "roundtrip deeply nested structure",
|
||||||
|
skipDoc: true,
|
||||||
|
input: "app \"database\" \"primary\" \"connection\" {\n host = \"db.local\"\n port = 5432\n}",
|
||||||
|
expected: "app \"database\" \"primary\" \"connection\" {\n host = \"db.local\"\n port = 5432\n}\n",
|
||||||
|
scenarioType: "roundtrip",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
description: "roundtrip with leading comments",
|
||||||
|
skipDoc: true,
|
||||||
|
input: "# Main config\nenabled = true\nport = 8080",
|
||||||
|
expected: "# Main config\nenabled = true\nport = 8080\n",
|
||||||
|
scenarioType: "roundtrip",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func testHclScenario(t *testing.T, s formatScenario) {
|
func testHclScenario(t *testing.T, s formatScenario) {
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user