diff --git a/pkg/yqlib/candidate_node.go b/pkg/yqlib/candidate_node.go index 9232152780..0518eaad34 100644 --- a/pkg/yqlib/candidate_node.go +++ b/pkg/yqlib/candidate_node.go @@ -100,6 +100,9 @@ type CandidateNode struct { // 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 + // For TOML: indicates that this mapping was originally a TOML inline table and should be + // re-encoded as an inline table rather than a separate table section. + TomlInline bool } func (n *CandidateNode) CreateChild() *CandidateNode { @@ -412,6 +415,7 @@ func (n *CandidateNode) doCopy(cloneContent bool) *CandidateNode { IsMapKey: n.IsMapKey, EncodeSeparate: n.EncodeSeparate, + TomlInline: n.TomlInline, } if cloneContent { @@ -467,6 +471,8 @@ func (n *CandidateNode) UpdateAttributesFrom(other *CandidateNode, prefs assignP // Preserve EncodeSeparate flag for format-specific encoding hints n.EncodeSeparate = other.EncodeSeparate + // Preserve TomlInline flag for TOML inline table round-trips + n.TomlInline = other.TomlInline // merge will pickup the style of the new thing // when autocreating nodes diff --git a/pkg/yqlib/decoder_toml.go b/pkg/yqlib/decoder_toml.go index cf3a9d3185..dc4de390a3 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", + TomlInline: true, + Content: content, }, nil } diff --git a/pkg/yqlib/doc/usage/toml.md b/pkg/yqlib/doc/usage/toml.md index 45402b4755..dab4abc600 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_toml.go b/pkg/yqlib/encoder_toml.go index b4ccc287e0..afcfd777f0 100644 --- a/pkg/yqlib/encoder_toml.go +++ b/pkg/yqlib/encoder_toml.go @@ -162,12 +162,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.TomlInline || node.Style&FlowStyle != 0 { return te.writeInlineTableAttribute(w, path[len(path)-1], node) } return te.encodeSeparateMapping(w, path, node) @@ -429,7 +426,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). + // TomlInline 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] @@ -437,6 +435,10 @@ func (te *tomlEncoder) encodeSeparateMapping(w io.Writer, path []string, m *Cand hasAttrs = true break } + if v.Kind == MappingNode && (v.TomlInline || v.Style&FlowStyle != 0) { + hasAttrs = true + break + } if v.Kind == SequenceNode { // Check if it's NOT an array of tables allMaps := true @@ -464,18 +466,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: @@ -599,13 +597,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: TomlInline 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.TomlInline || 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 fe542a01c4..e77d92d008 100644 --- a/pkg/yqlib/toml_test.go +++ b/pkg/yqlib/toml_test.go @@ -614,6 +614,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", + }, } func testTomlScenario(t *testing.T, s formatScenario) { @@ -629,6 +657,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) } } @@ -654,6 +684,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)) @@ -687,6 +739,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))