diff --git a/pkg/yqlib/decoder_toml.go b/pkg/yqlib/decoder_toml.go index a3e3a831..bcbea80d 100644 --- a/pkg/yqlib/decoder_toml.go +++ b/pkg/yqlib/decoder_toml.go @@ -157,9 +157,25 @@ func (dec *tomlDecoder) createInlineTableMap(tomlNode *toml.Node) (*CandidateNod }, nil } +// commentIsOnNewLine reports whether comment begins on a line after the end of +// prev, by scanning the raw input bytes between them for a newline. When the +// range cannot be determined it returns true, preserving the historic behaviour +// of treating in-array comments as head comments for the next element. +func (dec *tomlDecoder) commentIsOnNewLine(prev, comment *toml.Node) bool { + prevEnd := prev.Raw.Offset + prev.Raw.Length + commentStart := comment.Raw.Offset + if commentStart < prevEnd { + return true + } + between := dec.parser.Raw(toml.Range{Offset: prevEnd, Length: commentStart - prevEnd}) + return bytes.IndexByte(between, '\n') >= 0 +} + func (dec *tomlDecoder) createArray(tomlNode *toml.Node) (*CandidateNode, error) { content := make([]*CandidateNode, 0) var pendingArrayComments []string + var prevElem *toml.Node + var prevYamlNode *CandidateNode iterator := tomlNode.Children() for iterator.Next() { @@ -167,8 +183,19 @@ func (dec *tomlDecoder) createArray(tomlNode *toml.Node) (*CandidateNode, error) // Handle comments within arrays if child.Kind == toml.Comment { - // Collect comments to attach to the next array element - pendingArrayComments = append(pendingArrayComments, string(child.Data)) + // A comment on the same line as the preceding element is a trailing + // (inline) comment on that element; a comment on its own line is a + // head comment for the next element. + if prevYamlNode != nil && !dec.commentIsOnNewLine(prevElem, child) { + if prevYamlNode.LineComment == "" { + prevYamlNode.LineComment = string(child.Data) + } else { + prevYamlNode.LineComment = prevYamlNode.LineComment + "\n" + string(child.Data) + } + } else { + // Collect comments to attach to the next array element + pendingArrayComments = append(pendingArrayComments, string(child.Data)) + } continue } @@ -184,6 +211,8 @@ func (dec *tomlDecoder) createArray(tomlNode *toml.Node) (*CandidateNode, error) } content = append(content, yamlNode) + prevElem = child + prevYamlNode = yamlNode } return &CandidateNode{ diff --git a/pkg/yqlib/encoder_toml.go b/pkg/yqlib/encoder_toml.go index e5d87ae9..107f4fd4 100644 --- a/pkg/yqlib/encoder_toml.go +++ b/pkg/yqlib/encoder_toml.go @@ -188,6 +188,10 @@ func (te *tomlEncoder) encodeTopLevelEntry(w io.Writer, path []string, node *Can te.wroteRootAttr = false } for _, it := range node.Content { + // Write head comment before the array-of-tables header + if err := te.writeComment(w, it.HeadComment); err != nil { + return err + } if _, err := w.Write([]byte("[[" + quotedKey + "]]\n")); err != nil { return err } @@ -283,10 +287,10 @@ func (te *tomlEncoder) writeArrayAttribute(w io.Writer, key string, seq *Candida return err } - // Check if any array elements have head comments - if so, use multiline format + // Check if any array elements have head or line comments - if so, use multiline format hasElementComments := false for _, it := range seq.Content { - if it.HeadComment != "" { + if it.HeadComment != "" || it.LineComment != "" { hasElementComments = true break } @@ -299,6 +303,13 @@ func (te *tomlEncoder) writeArrayAttribute(w io.Writer, key string, seq *Candida } for i, it := range seq.Content { + // Separate a head-commented element from the previous one with a blank line + if i > 0 && it.HeadComment != "" { + if _, err := w.Write([]byte("\n")); err != nil { + return err + } + } + // Write head comment for this element if it.HeadComment != "" { commentLines := strings.Split(it.HeadComment, "\n") @@ -340,15 +351,17 @@ func (te *tomlEncoder) writeArrayAttribute(w io.Writer, key string, seq *Candida // Always add trailing comma in multiline arrays itemStr += "," - if _, err := w.Write([]byte(" " + itemStr + "\n")); err != nil { - return err + // Append trailing (inline) comment on the same line, if present + if it.LineComment != "" { + lineComment := strings.TrimSpace(it.LineComment) + if !strings.HasPrefix(lineComment, "#") { + lineComment = "# " + lineComment + } + itemStr += " " + lineComment } - // Add blank line between elements (except after the last one) - if i < len(seq.Content)-1 { - if _, err := w.Write([]byte("\n")); err != nil { - return err - } + if _, err := w.Write([]byte(" " + itemStr + "\n")); err != nil { + return err } } @@ -546,6 +559,10 @@ func (te *tomlEncoder) encodeSeparateMapping(w io.Writer, path []string, m *Cand te.wroteRootAttr = false } for _, it := range v.Content { + // Write head comment before the array-of-tables header + if err := te.writeComment(w, it.HeadComment); err != nil { + return err + } if _, err := w.Write([]byte("[[" + key + "]]\n")); err != nil { return err } @@ -603,6 +620,10 @@ func (te *tomlEncoder) encodeMappingBodyWithPath(w io.Writer, path []string, m * if isTomlArrayOfTables(v) { dotted := tomlDottedKey(append(append([]string{}, path...), k)) for _, it := range v.Content { + // Write head comment before the array-of-tables header + if err := te.writeComment(w, it.HeadComment); err != nil { + return err + } if _, err := w.Write([]byte("[[" + dotted + "]]\n")); err != nil { return err } @@ -677,7 +698,7 @@ func (te *tomlEncoder) colorizeToml(input []byte) []byte { // Table sections - [section] or [[array]] // Only treat '[' as a table section if it appears at the start of the line - // (possibly after whitespace). This avoids incorrectly colouring inline arrays like + // (possibly after whitespace). This avoids incorrectly colouring inline arrays like // "ports = [8000, 8001]" as table sections. if ch == '[' { isSectionHeader := true diff --git a/pkg/yqlib/toml_test.go b/pkg/yqlib/toml_test.go index d36c10cd..a300411e 100644 --- a/pkg/yqlib/toml_test.go +++ b/pkg/yqlib/toml_test.go @@ -325,6 +325,33 @@ the_array = [ ] ` +// Bug: a head comment directly above an [[array-of-tables]] header is dropped +// on round-trip when other content precedes the array-of-tables. +var rtArrayTableHeadComment = `[package] +name = "x" + +# the binary +[[bin]] +name = "y" +` + +// Same bug via a nested array-of-tables ([[a.b]]) under a table with attributes. +var rtNestedArrayTableHeadComment = `[fruit] +name = "apple" +# a variety +[[fruit.variety]] +name = "red delicious" +` + +// Bug: an inline (trailing) comment on an array item detaches from its item and +// floats onto its own line as a head comment on the next item, with a spurious +// blank line. +var rtArrayItemInlineComment = `default = [ + "a", # why a + "b", +] +` + var expectedSubArrays = `array: - subarray: - subsubarray: @@ -369,7 +396,7 @@ var tomlScenarios = []formatScenario{ skipDoc: true, description: "blank", input: `A = "hello`, - expectedError: `bad file 'sample.yml': basic string not terminated by "`, + expectedError: `bad file 'sample.yml': unterminated basic string`, scenarioType: "decode-error", }, { @@ -700,6 +727,30 @@ var tomlScenarios = []formatScenario{ expected: tomlTableWithComments, scenarioType: "roundtrip", }, + { + skipDoc: true, + description: "Roundtrip: head comment above array-of-tables", + input: rtArrayTableHeadComment, + expression: ".", + expected: rtArrayTableHeadComment, + scenarioType: "roundtrip", + }, + { + skipDoc: true, + description: "Roundtrip: head comment above nested array-of-tables", + input: rtNestedArrayTableHeadComment, + expression: ".", + expected: rtNestedArrayTableHeadComment, + scenarioType: "roundtrip", + }, + { + skipDoc: true, + description: "Roundtrip: inline comment on array item", + input: rtArrayItemInlineComment, + expression: ".", + expected: rtArrayItemInlineComment, + scenarioType: "roundtrip", + }, // Encode (YAML → TOML) scenarios - verify readable table sections are produced { description: "Encode: Simple mapping produces table section",