diff --git a/pkg/yqlib/candidate_node.go b/pkg/yqlib/candidate_node.go index 92321527..92f14cde 100644 --- a/pkg/yqlib/candidate_node.go +++ b/pkg/yqlib/candidate_node.go @@ -27,6 +27,22 @@ const ( FlowStyle ) +// EncodeHint controls how a mapping node is serialised by format-specific encoders +// that distinguish between inline and block/section representations (e.g. TOML, HCL). +type EncodeHint int + +const ( + // EncodeHintDefault lets the encoder choose the representation (e.g. TOML block + // mappings default to [section] headers). + EncodeHintDefault EncodeHint = iota + // EncodeHintSeparateBlock forces the node to be emitted as a separate block or + // table-section header (used by TOML [section] and HCL block decoders). + EncodeHintSeparateBlock + // EncodeHintInline forces the node to be emitted as an inline / flow table + // (used by TOML inline-table decoder and TOML encoder). + EncodeHintInline +) + func createStringScalarNode(stringValue string) *CandidateNode { var node = &CandidateNode{Kind: ScalarNode} node.Value = stringValue @@ -97,9 +113,9 @@ type CandidateNode struct { // (e.g. top level cross document merge). This property does not propagate to child nodes. EvaluateTogether 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 + // EncodeHint controls how a mapping node is serialised by format-specific encoders + // (e.g. TOML, HCL) that support both inline and block/section representations. + EncodeHint EncodeHint } func (n *CandidateNode) CreateChild() *CandidateNode { @@ -411,7 +427,7 @@ func (n *CandidateNode) doCopy(cloneContent bool) *CandidateNode { EvaluateTogether: n.EvaluateTogether, IsMapKey: n.IsMapKey, - EncodeSeparate: n.EncodeSeparate, + EncodeHint: n.EncodeHint, } if cloneContent { @@ -465,8 +481,8 @@ func (n *CandidateNode) UpdateAttributesFrom(other *CandidateNode, prefs assignP n.Anchor = other.Anchor } - // Preserve EncodeSeparate flag for format-specific encoding hints - n.EncodeSeparate = other.EncodeSeparate + // Preserve EncodeHint for format-specific encoding hints + n.EncodeHint = other.EncodeHint // merge will pickup the style of the new thing // when autocreating nodes diff --git a/pkg/yqlib/decoder_hcl.go b/pkg/yqlib/decoder_hcl.go index 731f9ff3..11cd0030 100644 --- a/pkg/yqlib/decoder_hcl.go +++ b/pkg/yqlib/decoder_hcl.go @@ -226,7 +226,7 @@ func addBlockToMapping(parent *CandidateNode, block *hclsyntax.Block, src []byte // 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 + typeNode.EncodeHint = EncodeHintSeparateBlock } } current = typeNode diff --git a/pkg/yqlib/decoder_toml.go b/pkg/yqlib/decoder_toml.go index cf3a9d31..a3e3a831 100644 --- a/pkg/yqlib/decoder_toml.go +++ b/pkg/yqlib/decoder_toml.go @@ -150,9 +150,10 @@ func (dec *tomlDecoder) createInlineTableMap(tomlNode *toml.Node) (*CandidateNod } return &CandidateNode{ - Kind: MappingNode, - Tag: "!!map", - Content: content, + Kind: MappingNode, + Tag: "!!map", + EncodeHint: EncodeHintInline, + Content: content, }, nil } @@ -345,10 +346,10 @@ func (dec *tomlDecoder) processTable(currentNode *toml.Node) (bool, error) { } tableNodeValue := &CandidateNode{ - Kind: MappingNode, - Tag: "!!map", - Content: make([]*CandidateNode, 0), - EncodeSeparate: true, + Kind: MappingNode, + Tag: "!!map", + Content: make([]*CandidateNode, 0), + EncodeHint: EncodeHintSeparateBlock, } // Attach pending head comments to the table @@ -442,9 +443,9 @@ func (dec *tomlDecoder) processArrayTable(currentNode *toml.Node) (bool, error) hasValue := dec.parser.NextExpression() tableNodeValue := &CandidateNode{ - Kind: MappingNode, - Tag: "!!map", - EncodeSeparate: true, + Kind: MappingNode, + Tag: "!!map", + EncodeHint: EncodeHintSeparateBlock, } // Attach pending head comments to the array table diff --git a/pkg/yqlib/doc/usage/toml.md b/pkg/yqlib/doc/usage/toml.md index 45402b47..dab4abc6 100644 --- a/pkg/yqlib/doc/usage/toml.md +++ b/pkg/yqlib/doc/usage/toml.md @@ -384,3 +384,20 @@ ip = "10.0.0.2" role = "backend" ``` +## Encode: Simple mapping produces table section +Given a sample.yml file of: +```yaml +arg: + hello: foo + +``` +then +```bash +yq -o toml '.' sample.yml +``` +will output +```toml +[arg] +hello = "foo" +``` + diff --git a/pkg/yqlib/encoder_hcl.go b/pkg/yqlib/encoder_hcl.go index 8dce06ea..f592b81d 100644 --- a/pkg/yqlib/encoder_hcl.go +++ b/pkg/yqlib/encoder_hcl.go @@ -449,8 +449,8 @@ func (he *hclEncoder) encodeBlockIfMapping(body *hclwrite.Body, key string, valu return false } - // If EncodeSeparate is set, emit children as separate blocks regardless of label extraction - if valueNode.EncodeSeparate { + // If EncodeHintSeparateBlock is set, emit children as separate blocks regardless of label extraction + if valueNode.EncodeHint == EncodeHintSeparateBlock { if handled, _ := he.encodeMappingChildrenAsBlocks(body, key, valueNode); handled { return true } @@ -537,9 +537,9 @@ func (he *hclEncoder) encodeMappingChildrenAsBlocks(body *hclwrite.Body, blockTy return false, nil } - // Only emit as separate blocks if EncodeSeparate is true + // Only emit as separate blocks if EncodeHintSeparateBlock is set // This allows the encoder to respect the original block structure preserved by the decoder - if !valueNode.EncodeSeparate { + if valueNode.EncodeHint != EncodeHintSeparateBlock { return false, nil } diff --git a/pkg/yqlib/encoder_toml.go b/pkg/yqlib/encoder_toml.go index 705154ab..8c418cc2 100644 --- a/pkg/yqlib/encoder_toml.go +++ b/pkg/yqlib/encoder_toml.go @@ -184,12 +184,9 @@ func (te *tomlEncoder) encodeTopLevelEntry(w io.Writer, path []string, node *Can // Regular array attribute return te.writeArrayAttribute(w, path[len(path)-1], node) case MappingNode: - // Inline table if not EncodeSeparate, else emit separate tables/arrays of tables for children under this path - if !node.EncodeSeparate { - // If children contain mappings or arrays of mappings, prefer separate sections - if te.hasEncodeSeparateChild(node) || te.hasStructuralChildren(node) { - return te.encodeSeparateMapping(w, path, node) - } + // Use inline table syntax for nodes explicitly marked as TOML inline tables + // or YAML flow mappings. All other mappings become readable TOML table sections. + if node.EncodeHint == EncodeHintInline || node.Style&FlowStyle != 0 { return te.writeInlineTableAttribute(w, path[len(path)-1], node) } return te.encodeSeparateMapping(w, path, node) @@ -451,7 +448,8 @@ func (te *tomlEncoder) writeTableHeader(w io.Writer, path []string, m *Candidate // encodeSeparateMapping handles a mapping that should be encoded as table sections. // It emits the table header for this mapping if it has any content, then processes children. func (te *tomlEncoder) encodeSeparateMapping(w io.Writer, path []string, m *CandidateNode) error { - // Check if this mapping has any non-mapping, non-array-of-tables children (i.e., attributes) + // Check if this mapping has any non-mapping, non-array-of-tables children (i.e., attributes). + // Inline mapping children also count as attributes since they render as key = { ... }. hasAttrs := false for i := 0; i < len(m.Content); i += 2 { v := m.Content[i+1] @@ -459,6 +457,10 @@ func (te *tomlEncoder) encodeSeparateMapping(w io.Writer, path []string, m *Cand hasAttrs = true break } + if v.Kind == MappingNode && (v.EncodeHint == EncodeHintInline || v.Style&FlowStyle != 0) { + hasAttrs = true + break + } if v.Kind == SequenceNode { // Check if it's NOT an array of tables allMaps := true @@ -486,18 +488,14 @@ func (te *tomlEncoder) encodeSeparateMapping(w io.Writer, path []string, m *Cand return nil } - // No attributes, just nested structures - process children + // No attributes, just nested table structures - process children recursively for i := 0; i < len(m.Content); i += 2 { k := m.Content[i].Value v := m.Content[i+1] switch v.Kind { case MappingNode: - // Emit [path.k] newPath := append(append([]string{}, path...), k) - if err := te.writeTableHeader(w, newPath, v); err != nil { - return err - } - if err := te.encodeMappingBodyWithPath(w, newPath, v); err != nil { + if err := te.encodeSeparateMapping(w, newPath, v); err != nil { return err } case SequenceNode: @@ -535,39 +533,6 @@ func (te *tomlEncoder) encodeSeparateMapping(w io.Writer, path []string, m *Cand return nil } -func (te *tomlEncoder) hasEncodeSeparateChild(m *CandidateNode) bool { - for i := 0; i < len(m.Content); i += 2 { - v := m.Content[i+1] - if v.Kind == MappingNode && v.EncodeSeparate { - return true - } - } - return false -} - -func (te *tomlEncoder) hasStructuralChildren(m *CandidateNode) bool { - for i := 0; i < len(m.Content); i += 2 { - v := m.Content[i+1] - // Only consider it structural if mapping has EncodeSeparate or is non-empty - if v.Kind == MappingNode && v.EncodeSeparate { - return true - } - if v.Kind == SequenceNode { - allMaps := true - for _, it := range v.Content { - if it.Kind != MappingNode { - allMaps = false - break - } - } - if allMaps { - return true - } - } - } - return false -} - // encodeMappingBodyWithPath encodes attributes and nested arrays of tables using full dotted path context func (te *tomlEncoder) encodeMappingBodyWithPath(w io.Writer, path []string, m *CandidateNode) error { // First, attributes (scalars and non-map arrays) @@ -621,13 +586,21 @@ func (te *tomlEncoder) encodeMappingBodyWithPath(w io.Writer, path []string, m * } } - // Finally, child mappings that are not marked EncodeSeparate get inlined as attributes + // Finally, child mappings: inline-hint or flow-style ones become inline table attributes, + // while all others are emitted as separate sub-table sections. for i := 0; i < len(m.Content); i += 2 { k := m.Content[i].Value v := m.Content[i+1] - if v.Kind == MappingNode && !v.EncodeSeparate { - if err := te.writeInlineTableAttribute(w, k, v); err != nil { - return err + if v.Kind == MappingNode { + if v.EncodeHint == EncodeHintInline || v.Style&FlowStyle != 0 { + if err := te.writeInlineTableAttribute(w, k, v); err != nil { + return err + } + } else { + subPath := append(append([]string{}, path...), k) + if err := te.encodeSeparateMapping(w, subPath, v); err != nil { + return err + } } } } diff --git a/pkg/yqlib/toml_test.go b/pkg/yqlib/toml_test.go index 18be6043..4e75371c 100644 --- a/pkg/yqlib/toml_test.go +++ b/pkg/yqlib/toml_test.go @@ -626,6 +626,34 @@ var tomlScenarios = []formatScenario{ expected: tomlTableWithComments, scenarioType: "roundtrip", }, + // Encode (YAML → TOML) scenarios - verify readable table sections are produced + { + description: "Encode: Simple mapping produces table section", + input: "arg:\n hello: foo\n", + expected: "[arg]\nhello = \"foo\"\n", + scenarioType: "encode", + }, + { + skipDoc: true, + description: "Encode: Nested mappings produce nested table sections", + input: "a:\n b:\n c: val\n", + expected: "[a.b]\nc = \"val\"\n", + scenarioType: "encode", + }, + { + skipDoc: true, + description: "Encode: Mixed scalars and nested mapping", + input: "a:\n hello: foo\n nested:\n key: val\n", + expected: "[a]\nhello = \"foo\"\n\n[a.nested]\nkey = \"val\"\n", + scenarioType: "encode", + }, + { + skipDoc: true, + description: "Encode: YAML flow mapping stays inline", + input: "arg: {hello: foo}\n", + expected: "arg = { hello = \"foo\" }\n", + scenarioType: "encode", + }, { skipDoc: true, description: "Roundtrip: key with special characters in inline table", @@ -665,6 +693,8 @@ func testTomlScenario(t *testing.T, s formatScenario) { } case "roundtrip": test.AssertResultWithContext(t, s.expected, mustProcessFormatScenario(s, NewTomlDecoder(), NewTomlEncoder()), s.description) + case "encode": + test.AssertResultWithContext(t, s.expected, mustProcessFormatScenario(s, NewYamlDecoder(ConfiguredYamlPreferences), NewTomlEncoder()), s.description) } } @@ -690,6 +720,28 @@ func documentTomlDecodeScenario(w *bufio.Writer, s formatScenario) { writeOrPanic(w, fmt.Sprintf("```yaml\n%v```\n\n", mustProcessFormatScenario(s, NewTomlDecoder(), NewYamlEncoder(ConfiguredYamlPreferences)))) } +func documentTomlEncodeScenario(w *bufio.Writer, s formatScenario) { + writeOrPanic(w, fmt.Sprintf("## %v\n", s.description)) + + if s.subdescription != "" { + writeOrPanic(w, s.subdescription) + writeOrPanic(w, "\n\n") + } + + writeOrPanic(w, "Given a sample.yml file of:\n") + writeOrPanic(w, fmt.Sprintf("```yaml\n%v\n```\n", s.input)) + + writeOrPanic(w, "then\n") + expression := s.expression + if expression == "" { + expression = "." + } + writeOrPanic(w, fmt.Sprintf("```bash\nyq -o toml '%v' sample.yml\n```\n", expression)) + writeOrPanic(w, "will output\n") + + writeOrPanic(w, fmt.Sprintf("```toml\n%v```\n\n", mustProcessFormatScenario(s, NewYamlDecoder(ConfiguredYamlPreferences), NewTomlEncoder()))) +} + func documentTomlRoundtripScenario(w *bufio.Writer, s formatScenario) { writeOrPanic(w, fmt.Sprintf("## %v\n", s.description)) @@ -723,6 +775,8 @@ func documentTomlScenario(_ *testing.T, w *bufio.Writer, i interface{}) { documentTomlDecodeScenario(w, s) case "roundtrip": documentTomlRoundtripScenario(w, s) + case "encode": + documentTomlEncodeScenario(w, s) default: panic(fmt.Sprintf("unhandled scenario type %q", s.scenarioType))