Repository: goccy/go-yaml
Pull request #644 (2025-02-09 05:30:06)

Add node formatter
resolve https://github.com/goccy/go-yaml/issues/337

For now, it will be limited to use within the decoder, but we plan to make it a public API later.

codecov-commenter: :warning: Please install the !['codecov app svg image'](https://github.com/codecov/engineering-team/assets/152432831/e90313f4-9d3a-4b63-8b54-cfe14e7ec20d) to ensure uploads and comments are reliably processed by Codecov.

## [Codecov](https://app.codecov.io/gh/goccy/go-yaml/pull/644?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Masaaki+Goshima) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 77.29%. Comparing base [(`8d4b192`)](https://app.codecov.io/gh/goccy/go-yaml/commit/8d4b192f87ebaceb061adfe9bd3ff32f41d01f49?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Masaaki+Goshima) to head [(`ef9da4e`)](https://app.codecov.io/gh/goccy/go-yaml/commit/ef9da4ee77668104e93c8590e3f0a9cd5516c4f4?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Masaaki+Goshima).

:exclamation: Your organization needs to install the [Codecov GitHub app](https://github.com/apps/codecov/installations/select_target) to enable full functionality.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##           master     #644      +/-   ##
==========================================
- Coverage   77.34%   77.29%   -0.05%     
==========================================
  Files          22       22              
  Lines        7905     7888      -17     
==========================================
- Hits         6114     6097      -17     
  Misses       1372     1372              
  Partials      419      419              
```

</details>

Patch:
diff --git a/decode.go b/decode.go
index 0191a39..aa8f184 100644
--- a/decode.go
+++ b/decode.go
@@ -19,6 +19,7 @@ import (
 
 	"github.com/goccy/go-yaml/ast"
 	"github.com/goccy/go-yaml/internal/errors"
+	"github.com/goccy/go-yaml/internal/format"
 	"github.com/goccy/go-yaml/parser"
 	"github.com/goccy/go-yaml/token"
 )
@@ -756,33 +757,13 @@ func (d *Decoder) deleteStructKeys(structType reflect.Type, unknownFields map[st
 	return nil
 }
 
-func (d *Decoder) lastNode(node ast.Node) ast.Node {
-	switch n := node.(type) {
-	case *ast.MappingNode:
-		if len(n.Values) > 0 {
-			return d.lastNode(n.Values[len(n.Values)-1])
-		}
-	case *ast.MappingValueNode:
-		return d.lastNode(n.Value)
-	case *ast.SequenceNode:
-		if len(n.Values) > 0 {
-			return d.lastNode(n.Values[len(n.Values)-1])
-		}
-	}
-	return node
-}
-
 func (d *Decoder) unmarshalableDocument(node ast.Node) ([]byte, error) {
 	var err error
 	node, err = d.resolveAlias(node)
 	if err != nil {
 		return nil, err
 	}
-	doc := node.String()
-	last := d.lastNode(node)
-	if last != nil && last.Type() == ast.LiteralType {
-		doc += "\n"
-	}
+	doc := format.FormatNode(node, d.toCommentMap != nil)
 	return []byte(doc), nil
 }
 
diff --git a/internal/format/format.go b/internal/format/format.go
new file mode 100644
index 0000000..a52ca8f
--- /dev/null
+++ b/internal/format/format.go
@@ -0,0 +1,316 @@
+package format
+
+import (
+	"strings"
+
+	"github.com/goccy/go-yaml/ast"
+	"github.com/goccy/go-yaml/token"
+)
+
+func FormatNode(n ast.Node, existsComment bool) string {
+	return newFormatter(n.GetToken(), existsComment).format(n)
+}
+
+func FormatFile(file *ast.File, existsComment bool) string {
+	if len(file.Docs) == 0 {
+		return ""
+	}
+	return newFormatter(file.Docs[0].GetToken(), existsComment).formatFile(file)
+}
+
+type Formatter struct {
+	existsComment    bool
+	tokenToOriginMap map[*token.Token]string
+}
+
+func newFormatter(tk *token.Token, existsComment bool) *Formatter {
+	tokenToOriginMap := make(map[*token.Token]string)
+	for tk.Prev != nil {
+		tk = tk.Prev
+	}
+	tokenToOriginMap[tk] = tk.Origin
+
+	var origin string
+	for tk.Next != nil {
+		tk = tk.Next
+		if tk.Type == token.CommentType {
+			origin += strings.Repeat("\n", strings.Count(normalizeNewLineChars(tk.Origin), "\n"))
+			continue
+		}
+		origin += tk.Origin
+		tokenToOriginMap[tk] = origin
+		origin = ""
+	}
+
+	return &Formatter{
+		existsComment:    existsComment,
+		tokenToOriginMap: tokenToOriginMap,
+	}
+}
+
+func (f *Formatter) format(n ast.Node) string {
+	return f.trimSpacePrefix(
+		f.trimIndentSpace(
+			n.GetToken().Position.IndentNum,
+			f.trimNewLineCharPrefix(f.formatNode(n)),
+		),
+	)
+}
+
+func (f *Formatter) formatFile(file *ast.File) string {
+	if len(file.Docs) == 0 {
+		return ""
+	}
+	var ret string
+	for _, doc := range file.Docs {
+		ret += f.formatDocument(doc)
+	}
+	return ret
+}
+
+func (f *Formatter) origin(tk *token.Token) string {
+	if f.existsComment {
+		return tk.Origin
+	}
+	return f.tokenToOriginMap[tk]
+}
+
+func (f *Formatter) formatDocument(n *ast.DocumentNode) string {
+	return f.origin(n.Start) + f.formatNode(n.Body) + f.origin(n.End)
+}
+
+func (f *Formatter) formatNull(n *ast.NullNode) string {
+	return f.formatCommentGroup(n.Comment) + f.origin(n.Token)
+}
+
+func (f *Formatter) formatString(n *ast.StringNode) string {
+	return f.formatCommentGroup(n.Comment) + f.origin(n.Token)
+}
+
+func (f *Formatter) formatInteger(n *ast.IntegerNode) string {
+	return f.formatCommentGroup(n.Comment) + f.origin(n.Token)
+}
+
+func (f *Formatter) formatFloat(n *ast.FloatNode) string {
+	return f.formatCommentGroup(n.Comment) + f.origin(n.Token)
+}
+
+func (f *Formatter) formatBool(n *ast.BoolNode) string {
+	return f.formatCommentGroup(n.Comment) + f.origin(n.Token)
+}
+
+func (f *Formatter) formatInfinity(n *ast.InfinityNode) string {
+	return f.formatCommentGroup(n.Comment) + f.origin(n.Token)
+}
+
+func (f *Formatter) formatNan(n *ast.NanNode) string {
+	return f.formatCommentGroup(n.Comment) + f.origin(n.Token)
+}
+
+func (f *Formatter) formatLiteral(n *ast.LiteralNode) string {
+	return f.origin(n.Start) + f.formatCommentGroup(n.Comment) + f.origin(n.Value.Token)
+}
+
+func (f *Formatter) formatMergeKey(n *ast.MergeKeyNode) string {
+	return f.origin(n.Token)
+}
+
+func (f *Formatter) formatMappingValue(n *ast.MappingValueNode) string {
+	return f.formatCommentGroup(n.Comment) +
+		f.formatMapKey(n.Key) + ":" + f.formatNode(n.Value) +
+		f.formatCommentGroup(n.FootComment)
+}
+
+func (f *Formatter) formatMapKey(n ast.MapKeyNode) string {
+	return f.formatCommentGroup(n.GetComment()) + f.formatNode(n)
+}
+
+func (f *Formatter) formatDirective(n *ast.DirectiveNode) string {
+	ret := f.origin(n.Start) + f.formatNode(n.Name)
+	for _, val := range n.Values {
+		ret += f.formatNode(val)
+	}
+	return ret
+}
+
+func (f *Formatter) formatMapping(n *ast.MappingNode) string {
+	if len(n.Values) == 0 {
+		return "{}"
+	}
+
+	var ret string
+	if n.IsFlowStyle {
+		ret = f.origin(n.Start)
+	}
+	ret += f.formatCommentGroup(n.Comment)
+	entry := n.Start
+	for _, value := range n.Values {
+		if n.IsFlowStyle {
+			tk := value.GetToken()
+			for tk.Prev != nil && tk != entry {
+				tk = tk.Prev
+				if tk.Type == token.CollectEntryType {
+					ret += f.origin(tk)
+					entry = tk
+					break
+				}
+			}
+		}
+		ret += f.formatMappingValue(value)
+	}
+	if n.IsFlowStyle {
+		ret += f.origin(n.End)
+	}
+	return ret
+}
+
+func (f *Formatter) formatTag(n *ast.TagNode) string {
+	return f.origin(n.Start) + f.formatNode(n.Value)
+}
+
+func (f *Formatter) formatMappingKey(n *ast.MappingKeyNode) string {
+	return f.origin(n.Start) + f.formatNode(n.Value)
+}
+
+func (f *Formatter) formatSequence(n *ast.SequenceNode) string {
+	if len(n.Values) == 0 {
+		return "[]"
+	}
+
+	var (
+		ret   string
+		entry = n.Start
+	)
+	if n.IsFlowStyle {
+		ret = f.origin(n.Start)
+	}
+	for idx, value := range n.Values {
+		tk := value.GetToken()
+		for tk.Prev != nil && tk != entry {
+			tk = tk.Prev
+			if tk.Type == token.SequenceEntryType || tk.Type == token.CollectEntryType {
+				ret += f.origin(tk)
+				entry = tk
+				break
+			}
+		}
+		if len(n.ValueHeadComments) > idx {
+			ret += f.formatCommentGroup(n.ValueHeadComments[idx])
+		}
+		ret += f.formatNode(value)
+	}
+	if n.IsFlowStyle {
+		ret += f.origin(n.End)
+	}
+	ret += f.formatCommentGroup(n.FootComment)
+	return ret
+}
+
+func (f *Formatter) formatAnchor(n *ast.AnchorNode) string {
+	return f.origin(n.Start) + f.formatNode(n.Name) + f.formatNode(n.Value)
+}
+
+func (f *Formatter) formatAlias(n *ast.AliasNode) string {
+	return f.origin(n.Start) + f.formatNode(n.Value)
+}
+
+func (f *Formatter) formatNode(n ast.Node) string {
+	switch nn := n.(type) {
+	case *ast.DocumentNode:
+		return f.formatDocument(nn)
+	case *ast.NullNode:
+		return f.formatNull(nn)
+	case *ast.BoolNode:
+		return f.formatBool(nn)
+	case *ast.IntegerNode:
+		return f.formatInteger(nn)
+	case *ast.FloatNode:
+		return f.formatFloat(nn)
+	case *ast.StringNode:
+		return f.formatString(nn)
+	case *ast.InfinityNode:
+		return f.formatInfinity(nn)
+	case *ast.NanNode:
+		return f.formatNan(nn)
+	case *ast.LiteralNode:
+		return f.formatLiteral(nn)
+	case *ast.DirectiveNode:
+		return f.formatDirective(nn)
+	case *ast.TagNode:
+		return f.formatTag(nn)
+	case *ast.MappingNode:
+		return f.formatMapping(nn)
+	case *ast.MappingKeyNode:
+		return f.formatMappingKey(nn)
+	case *ast.MappingValueNode:
+		return f.formatMappingValue(nn)
+	case *ast.MergeKeyNode:
+		return f.formatMergeKey(nn)
+	case *ast.SequenceNode:
+		return f.formatSequence(nn)
+	case *ast.AnchorNode:
+		return f.formatAnchor(nn)
+	case *ast.AliasNode:
+		return f.formatAlias(nn)
+	}
+	return ""
+}
+
+func (f *Formatter) formatCommentGroup(g *ast.CommentGroupNode) string {
+	if g == nil {
+		return ""
+	}
+	var ret string
+	for _, cm := range g.Comments {
+		ret += f.formatComment(cm)
+	}
+	return ret
+}
+
+func (f *Formatter) formatComment(n *ast.CommentNode) string {
+	if n == nil {
+		return ""
+	}
+	return n.Token.Origin
+}
+
+// nolint: unused
+func (f *Formatter) formatIndent(col int) string {
+	if col <= 1 {
+		return ""
+	}
+	return strings.Repeat(" ", col-1)
+}
+
+func (f *Formatter) trimNewLineCharPrefix(v string) string {
+	return strings.TrimLeftFunc(v, func(r rune) bool {
+		return r == '\n' || r == '\r'
+	})
+}
+
+func (f *Formatter) trimSpacePrefix(v string) string {
+	return strings.TrimLeftFunc(v, func(r rune) bool {
+		return r == ' '
+	})
+}
+
+func (f *Formatter) trimIndentSpace(trimIndentNum int, v string) string {
+	if trimIndentNum == 0 {
+		return v
+	}
+	lines := strings.Split(normalizeNewLineChars(v), "\n")
+	out := make([]string, 0, len(lines))
+	for _, line := range strings.Split(v, "\n") {
+		var cnt int
+		out = append(out, strings.TrimLeftFunc(line, func(r rune) bool {
+			cnt++
+			return r == ' ' && cnt <= trimIndentNum
+		}))
+	}
+	return strings.Join(out, "\n")
+}
+
+// normalizeNewLineChars normalize CRLF and CR to LF.
+func normalizeNewLineChars(v string) string {
+	return strings.ReplaceAll(strings.ReplaceAll(v, "\r\n", "\n"), "\r", "\n")
+}


Test patch:
diff --git a/decode_test.go b/decode_test.go
index 8ed7652..45e02b0 100644
--- a/decode_test.go
+++ b/decode_test.go
@@ -2757,13 +2757,13 @@ type unmarshalList struct {
 
 func (u *unmarshalList) UnmarshalYAML(b []byte) error {
 	expected := `
- - b: c
-   d: |
-     hello
+- b: c
+  d: |
+    hello
 
-     hello
-   f: g
- - h: i`
+    hello
+  f: g
+- h: i`
 	actual := "\n" + string(b)
 	if expected != actual {
 		return fmt.Errorf("unexpected bytes: expected [%q] but got [%q]", expected, actual)
@@ -3207,3 +3207,52 @@ a: !Not [!Equals [!Ref foo, 'bar']]
 		t.Fatalf("found unexpected value: %v", v)
 	}
 }
+
+type issue337Template struct{}
+
+func (i *issue337Template) UnmarshalYAML(b []byte) error {
+	expected := strings.TrimPrefix(`
+|
+  apiVersion: v1
+  kind: ConfigMap
+  metadata:
+    name: "abc"
+    namespace: "abc"
+  data:
+    foo: FOO
+`, "\n")
+	if !bytes.Equal(b, []byte(expected)) {
+		return fmt.Errorf("expected:\n%s\nbut got:\n%s\n", expected, string(b))
+	}
+	return nil
+}
+
+func TestIssue337(t *testing.T) {
+	yml := `
+releases:
+- name: foo
+  chart: ./raw
+  values:
+  - templates:
+    - |
+      apiVersion: v1
+      kind: ConfigMap
+      metadata:
+        name: "abc"
+        namespace: "abc"
+      data:
+        foo: FOO
+`
+	type Value struct {
+		Templates []*issue337Template `yaml:"templates"`
+	}
+	type Release struct {
+		Values []*Value `yaml:"values"`
+	}
+	var v struct {
+		Releases []*Release `yaml:"releases"`
+	}
+	if err := yaml.Unmarshal([]byte(yml), &v); err != nil {
+		t.Fatal(err)
+	}
+}