Skip to content
Draft
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions pkg/yqlib/decoder_toml.go
Original file line number Diff line number Diff line change
Expand Up @@ -152,6 +152,7 @@ func (dec *tomlDecoder) createInlineTableMap(tomlNode *toml.Node) (*CandidateNod
return &CandidateNode{
Kind: MappingNode,
Tag: "!!map",
Style: FlowStyle,
Content: content,
}, nil
}
Expand Down
26 changes: 24 additions & 2 deletions pkg/yqlib/doc/usage/toml.md
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,9 @@ yq '.' sample.toml
```
will output
```yaml
name = { first = "Tom", last = "Preston-Werner" }
[name]
first = "Tom"
last = "Preston-Werner"
```

## Roundtrip: table section
Expand Down Expand Up @@ -372,7 +374,10 @@ dob = 1979-05-27T07:32:00-08:00
enabled = true
ports = [8000, 8001, 8002]
data = [["delta", "phi"], [3.14]]
temp_targets = { cpu = 79.5, case = 72.0 }

[database.temp_targets]
cpu = 79.5
case = 72.0

# [servers] yq can't do this one yet
[servers.alpha]
Expand All @@ -384,3 +389,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"
```

41 changes: 24 additions & 17 deletions pkg/yqlib/encoder_toml.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,12 +162,10 @@ 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 only for nodes explicitly marked with flow/inline style
// (e.g. TOML inline tables or YAML flow mappings). All other mappings become
// readable TOML table sections.
if node.Style&FlowStyle != 0 {
return te.writeInlineTableAttribute(w, path[len(path)-1], node)
}
return te.encodeSeparateMapping(w, path, node)
Expand Down Expand Up @@ -429,14 +427,19 @@ 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).
// Flow-style (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]
if v.Kind == ScalarNode {
hasAttrs = true
break
}
if v.Kind == MappingNode && v.Style&FlowStyle != 0 {
hasAttrs = true
break
}
if v.Kind == SequenceNode {
// Check if it's NOT an array of tables
allMaps := true
Expand Down Expand Up @@ -464,18 +467,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:
Expand Down Expand Up @@ -599,13 +598,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: flow-style (inline) 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.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
}
}
}
}
Expand Down
91 changes: 89 additions & 2 deletions pkg/yqlib/toml_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,12 @@ var expectedSampleWithHeader = `servers:
var rtInlineTableAttr = `name = { first = "Tom", last = "Preston-Werner" }
`

// Inline tables are converted to readable table sections on encode
var rtInlineTableAttrEncoded = `[name]
first = "Tom"
last = "Preston-Werner"
`

var rtTableSection = `[owner.contact]
name = "Tom"
age = 36
Expand Down Expand Up @@ -263,6 +269,33 @@ ip = "10.0.0.2"
role = "backend"
`

// Inline table temp_targets is expanded to a readable sub-table when re-encoding
var sampleFromWebEncoded = `# This is a TOML document
title = "TOML Example"

[owner]
name = "Tom Preston-Werner"
dob = 1979-05-27T07:32:00-08:00

[database]
enabled = true
ports = [8000, 8001, 8002]
data = [["delta", "phi"], [3.14]]

[database.temp_targets]
cpu = 79.5
case = 72.0

# [servers] yq can't do this one yet
[servers.alpha]
ip = "10.0.0.1"
role = "frontend"

[servers.beta]
ip = "10.0.0.2"
role = "backend"
`

var subArrays = `
[[array]]

Expand Down Expand Up @@ -506,7 +539,7 @@ var tomlScenarios = []formatScenario{
description: "Roundtrip: inline table attribute",
input: rtInlineTableAttr,
expression: ".",
expected: rtInlineTableAttr,
expected: rtInlineTableAttrEncoded,
Copy link
Copy Markdown
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@copilot the point of the roundtrip tests is to maintain the format as much as possible...this no longer does that.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in d1ffec1. The root cause was that FlowStyle (used in the previous commit to mark inline tables) also affects the YAML encoder, which was changing the TOML→YAML decode output.

The fix introduces a new TomlInline bool field on CandidateNode — TOML-specific and invisible to the YAML encoder. The TOML decoder sets it for inline tables, UpdateAttributesFrom propagates it through DeeplyAssign merges, and the TOML encoder checks it to decide inline vs table-section output.

TOML roundtrip tests are restored to their original expected values: inline tables stay inline, table sections stay as table sections.

scenarioType: "roundtrip",
},
{
Expand Down Expand Up @@ -605,7 +638,7 @@ var tomlScenarios = []formatScenario{
description: "Roundtrip: sample from web",
input: sampleFromWeb,
expression: ".",
expected: sampleFromWeb,
expected: sampleFromWebEncoded,
scenarioType: "roundtrip",
},
{
Expand All @@ -614,6 +647,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) {
Expand All @@ -629,6 +690,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)
}
}

Expand All @@ -654,6 +717,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))

Expand Down Expand Up @@ -687,6 +772,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))
Expand Down