TOML encoder: prefer readable table sections over inline tables (#2649)

* Initial plan

* Fix TOML encoder to prefer readable table sections over inline tables

When converting from YAML/JSON to TOML, the encoder now always uses
readable TOML table section syntax ([section]) instead of compact inline
hash table syntax (key = { ... }), which better matches TOML's goal as
a human-focused configuration format.

Changes:
- decoder_toml.go: Mark inline TOML tables with FlowStyle so round-trips
  can be distinguished from YAML flow mappings
- encoder_toml.go:
  - encodeTopLevelEntry: use FlowStyle check instead of EncodeSeparate to
    decide inline vs table section (all block mappings now become tables)
  - encodeSeparateMapping: count FlowStyle children as attributes; use
    recursive encodeSeparateMapping for nested non-flow mappings
  - encodeMappingBodyWithPath: emit non-flow child mappings as sub-table
    sections instead of inline tables
- toml_test.go: add encode (YAML→TOML) test scenarios, update roundtrip
  expectations for inline tables (now expanded to table sections)

Agent-Logs-Url: https://github.com/mikefarah/yq/sessions/4824a219-6d5e-42e7-bca1-a8a277bf8c6a

Co-authored-by: mikefarah <1151925+mikefarah@users.noreply.github.com>

* Fix TOML roundtrip: use TomlInline flag instead of FlowStyle to preserve inline tables

FlowStyle affected YAML decode output (causing inline tables to appear as
YAML flow mappings). Replace it with a new TOML-specific TomlInline bool
on CandidateNode that:
- Is set by the TOML decoder for inline tables (not FlowStyle)
- Is copied by UpdateAttributesFrom so it survives DeeplyAssign merges
- Is checked by the TOML encoder alongside FlowStyle (for YAML flow maps)
- Has no effect on the YAML encoder, preserving existing TOML→YAML output

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

Agent-Logs-Url: https://github.com/mikefarah/yq/sessions/f59bdf62-6d16-4664-991b-38eb87c9d81c

Co-authored-by: mikefarah <1151925+mikefarah@users.noreply.github.com>

* Refactor EncodeSeparate+TomlInline into a single EncodeHint enum

Agent-Logs-Url: https://github.com/mikefarah/yq/sessions/24db9a8f-601d-4ccf-ada7-129ed3226bb6

Co-authored-by: mikefarah <1151925+mikefarah@users.noreply.github.com>

* Fix stale comment in hasStructuralChildren

Agent-Logs-Url: https://github.com/mikefarah/yq/sessions/24db9a8f-601d-4ccf-ada7-129ed3226bb6

Co-authored-by: mikefarah <1151925+mikefarah@users.noreply.github.com>

* Remove unused hasStructuralChildren method from tomlEncoder

Agent-Logs-Url: https://github.com/mikefarah/yq/sessions/2c234b77-28e9-4995-ba6f-9d213ec551a0

Co-authored-by: mikefarah <1151925+mikefarah@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: mikefarah <1151925+mikefarah@users.noreply.github.com>
This commit is contained in:
Copilot 2026-04-12 18:36:43 +10:00 committed by GitHub
parent c47fe40a30
commit 2927a28283
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 132 additions and 71 deletions

View File

@ -27,6 +27,22 @@ const (
FlowStyle 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 { func createStringScalarNode(stringValue string) *CandidateNode {
var node = &CandidateNode{Kind: ScalarNode} var node = &CandidateNode{Kind: ScalarNode}
node.Value = stringValue 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. // (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 // EncodeHint controls how a mapping node is serialised by format-specific encoders
// rather than consolidated into nested mappings (default behaviour) // (e.g. TOML, HCL) that support both inline and block/section representations.
EncodeSeparate bool EncodeHint EncodeHint
} }
func (n *CandidateNode) CreateChild() *CandidateNode { func (n *CandidateNode) CreateChild() *CandidateNode {
@ -411,7 +427,7 @@ func (n *CandidateNode) doCopy(cloneContent bool) *CandidateNode {
EvaluateTogether: n.EvaluateTogether, EvaluateTogether: n.EvaluateTogether,
IsMapKey: n.IsMapKey, IsMapKey: n.IsMapKey,
EncodeSeparate: n.EncodeSeparate, EncodeHint: n.EncodeHint,
} }
if cloneContent { if cloneContent {
@ -465,8 +481,8 @@ func (n *CandidateNode) UpdateAttributesFrom(other *CandidateNode, prefs assignP
n.Anchor = other.Anchor n.Anchor = other.Anchor
} }
// Preserve EncodeSeparate flag for format-specific encoding hints // Preserve EncodeHint for format-specific encoding hints
n.EncodeSeparate = other.EncodeSeparate n.EncodeHint = other.EncodeHint
// merge will pickup the style of the new thing // merge will pickup the style of the new thing
// when autocreating nodes // when autocreating nodes

View File

@ -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 // 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 // This tells the encoder to emit them as separate blocks rather than consolidating them
if isMultipleBlocksOfType { if isMultipleBlocksOfType {
typeNode.EncodeSeparate = true typeNode.EncodeHint = EncodeHintSeparateBlock
} }
} }
current = typeNode current = typeNode

View File

@ -150,9 +150,10 @@ func (dec *tomlDecoder) createInlineTableMap(tomlNode *toml.Node) (*CandidateNod
} }
return &CandidateNode{ return &CandidateNode{
Kind: MappingNode, Kind: MappingNode,
Tag: "!!map", Tag: "!!map",
Content: content, EncodeHint: EncodeHintInline,
Content: content,
}, nil }, nil
} }
@ -345,10 +346,10 @@ func (dec *tomlDecoder) processTable(currentNode *toml.Node) (bool, error) {
} }
tableNodeValue := &CandidateNode{ tableNodeValue := &CandidateNode{
Kind: MappingNode, Kind: MappingNode,
Tag: "!!map", Tag: "!!map",
Content: make([]*CandidateNode, 0), Content: make([]*CandidateNode, 0),
EncodeSeparate: true, EncodeHint: EncodeHintSeparateBlock,
} }
// Attach pending head comments to the table // Attach pending head comments to the table
@ -442,9 +443,9 @@ func (dec *tomlDecoder) processArrayTable(currentNode *toml.Node) (bool, error)
hasValue := dec.parser.NextExpression() hasValue := dec.parser.NextExpression()
tableNodeValue := &CandidateNode{ tableNodeValue := &CandidateNode{
Kind: MappingNode, Kind: MappingNode,
Tag: "!!map", Tag: "!!map",
EncodeSeparate: true, EncodeHint: EncodeHintSeparateBlock,
} }
// Attach pending head comments to the array table // Attach pending head comments to the array table

View File

@ -384,3 +384,20 @@ ip = "10.0.0.2"
role = "backend" 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"
```

View File

@ -449,8 +449,8 @@ 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 EncodeHintSeparateBlock is set, emit children as separate blocks regardless of label extraction
if valueNode.EncodeSeparate { if valueNode.EncodeHint == EncodeHintSeparateBlock {
if handled, _ := he.encodeMappingChildrenAsBlocks(body, key, valueNode); handled { if handled, _ := he.encodeMappingChildrenAsBlocks(body, key, valueNode); handled {
return true return true
} }
@ -537,9 +537,9 @@ func (he *hclEncoder) encodeMappingChildrenAsBlocks(body *hclwrite.Body, blockTy
return false, nil 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 // This allows the encoder to respect the original block structure preserved by the decoder
if !valueNode.EncodeSeparate { if valueNode.EncodeHint != EncodeHintSeparateBlock {
return false, nil return false, nil
} }

View File

@ -184,12 +184,9 @@ func (te *tomlEncoder) encodeTopLevelEntry(w io.Writer, path []string, node *Can
// Regular array attribute // Regular array attribute
return te.writeArrayAttribute(w, path[len(path)-1], node) return te.writeArrayAttribute(w, path[len(path)-1], node)
case MappingNode: case MappingNode:
// Inline table if not EncodeSeparate, else emit separate tables/arrays of tables for children under this path // Use inline table syntax for nodes explicitly marked as TOML inline tables
if !node.EncodeSeparate { // or YAML flow mappings. All other mappings become readable TOML table sections.
// If children contain mappings or arrays of mappings, prefer separate sections if node.EncodeHint == EncodeHintInline || node.Style&FlowStyle != 0 {
if te.hasEncodeSeparateChild(node) || te.hasStructuralChildren(node) {
return te.encodeSeparateMapping(w, path, node)
}
return te.writeInlineTableAttribute(w, path[len(path)-1], node) return te.writeInlineTableAttribute(w, path[len(path)-1], node)
} }
return te.encodeSeparateMapping(w, path, 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. // 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. // 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 { 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 hasAttrs := false
for i := 0; i < len(m.Content); i += 2 { for i := 0; i < len(m.Content); i += 2 {
v := m.Content[i+1] v := m.Content[i+1]
@ -459,6 +457,10 @@ func (te *tomlEncoder) encodeSeparateMapping(w io.Writer, path []string, m *Cand
hasAttrs = true hasAttrs = true
break break
} }
if v.Kind == MappingNode && (v.EncodeHint == EncodeHintInline || v.Style&FlowStyle != 0) {
hasAttrs = true
break
}
if v.Kind == SequenceNode { if v.Kind == SequenceNode {
// Check if it's NOT an array of tables // Check if it's NOT an array of tables
allMaps := true allMaps := true
@ -486,18 +488,14 @@ func (te *tomlEncoder) encodeSeparateMapping(w io.Writer, path []string, m *Cand
return nil 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 { for i := 0; i < len(m.Content); i += 2 {
k := m.Content[i].Value k := m.Content[i].Value
v := m.Content[i+1] v := m.Content[i+1]
switch v.Kind { switch v.Kind {
case MappingNode: case MappingNode:
// Emit [path.k]
newPath := append(append([]string{}, path...), k) newPath := append(append([]string{}, path...), k)
if err := te.writeTableHeader(w, newPath, v); err != nil { if err := te.encodeSeparateMapping(w, newPath, v); err != nil {
return err
}
if err := te.encodeMappingBodyWithPath(w, newPath, v); err != nil {
return err return err
} }
case SequenceNode: case SequenceNode:
@ -535,39 +533,6 @@ func (te *tomlEncoder) encodeSeparateMapping(w io.Writer, path []string, m *Cand
return nil 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 // 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 { func (te *tomlEncoder) encodeMappingBodyWithPath(w io.Writer, path []string, m *CandidateNode) error {
// First, attributes (scalars and non-map arrays) // 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 { for i := 0; i < len(m.Content); i += 2 {
k := m.Content[i].Value k := m.Content[i].Value
v := m.Content[i+1] v := m.Content[i+1]
if v.Kind == MappingNode && !v.EncodeSeparate { if v.Kind == MappingNode {
if err := te.writeInlineTableAttribute(w, k, v); err != nil { if v.EncodeHint == EncodeHintInline || v.Style&FlowStyle != 0 {
return err 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
}
} }
} }
} }

View File

@ -626,6 +626,34 @@ var tomlScenarios = []formatScenario{
expected: tomlTableWithComments, expected: tomlTableWithComments,
scenarioType: "roundtrip", 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, skipDoc: true,
description: "Roundtrip: key with special characters in inline table", description: "Roundtrip: key with special characters in inline table",
@ -665,6 +693,8 @@ func testTomlScenario(t *testing.T, s formatScenario) {
} }
case "roundtrip": case "roundtrip":
test.AssertResultWithContext(t, s.expected, mustProcessFormatScenario(s, NewTomlDecoder(), NewTomlEncoder()), s.description) 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)))) 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) { func documentTomlRoundtripScenario(w *bufio.Writer, s formatScenario) {
writeOrPanic(w, fmt.Sprintf("## %v\n", s.description)) writeOrPanic(w, fmt.Sprintf("## %v\n", s.description))
@ -723,6 +775,8 @@ func documentTomlScenario(_ *testing.T, w *bufio.Writer, i interface{}) {
documentTomlDecodeScenario(w, s) documentTomlDecodeScenario(w, s)
case "roundtrip": case "roundtrip":
documentTomlRoundtripScenario(w, s) documentTomlRoundtripScenario(w, s)
case "encode":
documentTomlEncodeScenario(w, s)
default: default:
panic(fmt.Sprintf("unhandled scenario type %q", s.scenarioType)) panic(fmt.Sprintf("unhandled scenario type %q", s.scenarioType))