Repository: goccy/go-yaml
Pull request #625 (2025-01-22 08:12:21)

Fix indentation for raw string newlines
Closes https://github.com/goccy/go-yaml/issues/292

Before submitting your PR, please confirm the following.

- [x] Describe the purpose for which you created this PR.  
- [x] Create test code that corresponds to the modification

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/625?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.36%. Comparing base [(`f739772`)](https://app.codecov.io/gh/goccy/go-yaml/commit/f739772fd1a18b85c139af33d06a464829e4a464?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=Masaaki+Goshima) to head [(`c559864`)](https://app.codecov.io/gh/goccy/go-yaml/commit/c559864862493fbc949c864f72c78b8d06ddf5e5?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     #625   +/-   ##
=======================================
  Coverage   77.36%   77.36%           
=======================================
  Files          22       22           
  Lines        7885     7887    +2     
=======================================
+ Hits         6100     6102    +2     
  Misses       1368     1368           
  Partials      417      417           
```

</details>

Patch:
diff --git a/ast/ast.go b/ast/ast.go
index 357ca81..b2a83e9 100644
--- a/ast/ast.go
+++ b/ast/ast.go
@@ -576,7 +576,9 @@ func (d *DocumentNode) String() string {
 	if d.Start != nil {
 		doc = append(doc, d.Start.Value)
 	}
-	doc = append(doc, d.Body.String())
+	if d.Body != nil {
+		doc = append(doc, d.Body.String())
+	}
 	if d.End != nil {
 		doc = append(doc, d.End.Value)
 	}
@@ -816,11 +818,12 @@ func (n *StringNode) String() string {
 		// It works mostly, but inconsistencies occur if line break characters are mixed.
 		header := token.LiteralBlockHeader(n.Value)
 		space := strings.Repeat(" ", n.Token.Position.Column-1)
+		indent := strings.Repeat(" ", n.Token.Position.IndentNum)
 		values := []string{}
 		for _, v := range strings.Split(n.Value, lbc) {
-			values = append(values, fmt.Sprintf("%s  %s", space, v))
+			values = append(values, fmt.Sprintf("%s%s%s", space, indent, v))
 		}
-		block := strings.TrimSuffix(strings.TrimSuffix(strings.Join(values, lbc), fmt.Sprintf("%s  %s", lbc, space)), fmt.Sprintf("  %s", space))
+		block := strings.TrimSuffix(strings.TrimSuffix(strings.Join(values, lbc), fmt.Sprintf("%s%s%s", lbc, indent, space)), fmt.Sprintf("%s%s", indent, space))
 		return fmt.Sprintf("%s%s%s", header, lbc, block)
 	} else if len(n.Value) > 0 && (n.Value[0] == '{' || n.Value[0] == '[') {
 		return fmt.Sprintf(`'%s'`, n.Value)
@@ -847,11 +850,12 @@ func (n *StringNode) stringWithoutComment() string {
 		// It works mostly, but inconsistencies occur if line break characters are mixed.
 		header := token.LiteralBlockHeader(n.Value)
 		space := strings.Repeat(" ", n.Token.Position.Column-1)
+		indent := strings.Repeat(" ", n.Token.Position.IndentNum)
 		values := []string{}
 		for _, v := range strings.Split(n.Value, lbc) {
-			values = append(values, fmt.Sprintf("%s  %s", space, v))
+			values = append(values, fmt.Sprintf("%s%s%s", space, indent, v))
 		}
-		block := strings.TrimSuffix(strings.TrimSuffix(strings.Join(values, lbc), fmt.Sprintf("%s  %s", lbc, space)), fmt.Sprintf("  %s", space))
+		block := strings.TrimSuffix(strings.TrimSuffix(strings.Join(values, lbc), fmt.Sprintf("%s%s%s", lbc, indent, space)), fmt.Sprintf("  %s", space))
 		return fmt.Sprintf("%s%s%s", header, lbc, block)
 	} else if len(n.Value) > 0 && (n.Value[0] == '{' || n.Value[0] == '[') {
 		return fmt.Sprintf(`'%s'`, n.Value)
@@ -1350,7 +1354,6 @@ type MappingValueNode struct {
 	Start       *token.Token
 	Key         MapKeyNode
 	Value       Node
-	Anchor      *AnchorNode
 	FootComment *CommentGroupNode
 }
 
diff --git a/decode.go b/decode.go
index fda5a6d..779ecb5 100644
--- a/decode.go
+++ b/decode.go
@@ -1980,6 +1980,9 @@ func (d *Decoder) decodeInit() error {
 		return err
 	}
 	d.parsedFile = file
+	for k := range d.toCommentMap {
+		delete(d.toCommentMap, k)
+	}
 	return nil
 }
 
diff --git a/encode.go b/encode.go
index 027b1be..bb36f0f 100644
--- a/encode.go
+++ b/encode.go
@@ -28,8 +28,6 @@ const (
 type Encoder struct {
 	writer                     io.Writer
 	opts                       []EncodeOption
-	indent                     int
-	indentSequence             bool
 	singleQuote                bool
 	isFlowStyle                bool
 	isJSONStyle                bool
@@ -41,11 +39,12 @@ type Encoder struct {
 	commentMap                 map[*Path][]*Comment
 	written                    bool
 
-	line        int
-	column      int
-	offset      int
-	indentNum   int
-	indentLevel int
+	line           int
+	column         int
+	offset         int
+	indentNum      int
+	indentLevel    int
+	indentSequence bool
 }
 
 // NewEncoder returns a new encoder that writes to w.
@@ -54,12 +53,12 @@ func NewEncoder(w io.Writer, opts ...EncodeOption) *Encoder {
 	return &Encoder{
 		writer:             w,
 		opts:               opts,
-		indent:             DefaultIndentSpaces,
 		anchorPtrToNameMap: map[uintptr]string{},
 		customMarshalerMap: map[reflect.Type]func(interface{}) ([]byte, error){},
 		line:               1,
 		column:             1,
 		offset:             0,
+		indentNum:          DefaultIndentSpaces,
 	}
 }
 
@@ -573,8 +572,13 @@ func (e *Encoder) encodeBool(v bool) *ast.BoolNode {
 
 func (e *Encoder) encodeSlice(ctx context.Context, value reflect.Value) (*ast.SequenceNode, error) {
 	if e.indentSequence {
-		e.column += e.indent
+		e.column += e.indentNum
 	}
+	defer func() {
+		if e.indentSequence {
+			e.column -= e.indentNum
+		}
+	}()
 	column := e.column
 	sequence := ast.Sequence(token.New("-", "-", e.pos(column)), e.isFlowStyle)
 	for i := 0; i < value.Len(); i++ {
@@ -584,16 +588,18 @@ func (e *Encoder) encodeSlice(ctx context.Context, value reflect.Value) (*ast.Se
 		}
 		sequence.Values = append(sequence.Values, node)
 	}
-	if e.indentSequence {
-		e.column -= e.indent
-	}
 	return sequence, nil
 }
 
 func (e *Encoder) encodeArray(ctx context.Context, value reflect.Value) (*ast.SequenceNode, error) {
 	if e.indentSequence {
-		e.column += e.indent
+		e.column += e.indentNum
 	}
+	defer func() {
+		if e.indentSequence {
+			e.column -= e.indentNum
+		}
+	}()
 	column := e.column
 	sequence := ast.Sequence(token.New("-", "-", e.pos(column)), e.isFlowStyle)
 	for i := 0; i < value.Len(); i++ {
@@ -603,9 +609,6 @@ func (e *Encoder) encodeArray(ctx context.Context, value reflect.Value) (*ast.Se
 		}
 		sequence.Values = append(sequence.Values, node)
 	}
-	if e.indentSequence {
-		e.column -= e.indent
-	}
 	return sequence, nil
 }
 
@@ -617,7 +620,7 @@ func (e *Encoder) encodeMapItem(ctx context.Context, item MapItem, column int) (
 		return nil, err
 	}
 	if e.isMapNode(value) {
-		value.AddColumn(e.indent)
+		value.AddColumn(e.indentNum)
 	}
 	return ast.MappingValue(
 		token.New("", "", e.pos(column)),
@@ -660,7 +663,7 @@ func (e *Encoder) encodeMap(ctx context.Context, value reflect.Value, column int
 			return nil
 		}
 		if e.isMapNode(value) {
-			value.AddColumn(e.indent)
+			value.AddColumn(e.indentNum)
 		}
 		node.Values = append(node.Values, ast.MappingValue(
 			nil,
@@ -783,7 +786,7 @@ func (e *Encoder) encodeStruct(ctx context.Context, value reflect.Value, column
 			return nil, err
 		}
 		if e.isMapNode(value) {
-			value.AddColumn(e.indent)
+			value.AddColumn(e.indentNum)
 		}
 		var key ast.MapKeyNode = e.encodeString(structField.RenderName, column)
 		switch {
@@ -833,8 +836,8 @@ func (e *Encoder) encodeStruct(ctx context.Context, value reflect.Value, column
 					// if declared same key name, skip encoding this field
 					continue
 				}
-				key.AddColumn(-e.indent)
-				value.AddColumn(-e.indent)
+				key.AddColumn(-e.indentNum)
+				value.AddColumn(-e.indentNum)
 				node.Values = append(node.Values, ast.MappingValue(nil, key, value))
 			}
 			continue
@@ -848,7 +851,7 @@ func (e *Encoder) encodeStruct(ctx context.Context, value reflect.Value, column
 		node.Values = append(node.Values, ast.MappingValue(nil, key, value))
 	}
 	if hasInlineAnchorField {
-		node.AddColumn(e.indent)
+		node.AddColumn(e.indentNum)
 		anchorName := "anchor"
 		anchorNode := ast.Anchor(token.New("&", "&", e.pos(column)))
 		anchorNode.Name = ast.String(token.New(anchorName, anchorName, e.pos(column)))
diff --git a/option.go b/option.go
index 33100e9..bd685e2 100644
--- a/option.go
+++ b/option.go
@@ -114,7 +114,7 @@ type EncodeOption func(e *Encoder) error
 // Indent change indent number
 func Indent(spaces int) EncodeOption {
 	return func(e *Encoder) error {
-		e.indent = spaces
+		e.indentNum = spaces
 		return nil
 	}
 }
diff --git a/path.go b/path.go
index 2192086..4d2cbba 100644
--- a/path.go
+++ b/path.go
@@ -116,7 +116,7 @@ func parsePathDot(b *PathBuilder, buf []rune, cursor int) (*PathBuilder, []rune,
 	}
 end:
 	if start == cursor {
-		return nil, nil, 0, fmt.Errorf("cloud not find by empty key: %w", ErrInvalidPathString)
+		return nil, nil, 0, fmt.Errorf("could not find by empty key: %w", ErrInvalidPathString)
 	}
 	return b.child(string(buf[start:cursor])), buf, cursor, nil
 }
@@ -629,7 +629,7 @@ func (n *indexNode) filter(node ast.Node) (ast.Node, error) {
 	}
 	sequence, _ := node.(*ast.SequenceNode)
 	if n.selector >= uint(len(sequence.Values)) {
-		return nil, fmt.Errorf("expected index is %d. but got sequences has %d items: %w", n.selector, sequence.Values, ErrInvalidQuery)
+		return nil, fmt.Errorf("expected index is %d. but got sequences has %d items: %w", n.selector, len(sequence.Values), ErrInvalidQuery)
 	}
 	value := sequence.Values[n.selector]
 	if n.child == nil {
@@ -648,7 +648,7 @@ func (n *indexNode) replace(node ast.Node, target ast.Node) error {
 	}
 	sequence, _ := node.(*ast.SequenceNode)
 	if n.selector >= uint(len(sequence.Values)) {
-		return fmt.Errorf("expected index is %d. but got sequences has %d items: %w", n.selector, sequence.Values, ErrInvalidQuery)
+		return fmt.Errorf("expected index is %d. but got sequences has %d items: %w", n.selector, len(sequence.Values), ErrInvalidQuery)
 	}
 	if n.child == nil {
 		if err := sequence.Replace(int(n.selector), target); err != nil {


Test patch:
diff --git a/encode_test.go b/encode_test.go
index 1d9b9bd..376964a 100644
--- a/encode_test.go
+++ b/encode_test.go
@@ -1540,49 +1540,71 @@ func TestIssue356(t *testing.T) {
 }
 
 func TestMarshalIndentWithMultipleText(t *testing.T) {
-	t.Run("depth1", func(t *testing.T) {
-		b, err := yaml.MarshalWithOptions(map[string]interface{}{
-			"key": []string{`line1
+	tests := []struct {
+		name   string
+		input  map[string]interface{}
+		indent yaml.EncodeOption
+		want   string
+	}{
+		{
+			name: "depth1",
+			input: map[string]interface{}{
+				"key": []string{`line1
 line2
 line3`},
-		}, yaml.Indent(2))
-		if err != nil {
-			t.Fatal(err)
-		}
-		got := string(b)
-		expected := `key:
+			},
+			indent: yaml.Indent(2),
+			want: `key:
 - |-
   line1
   line2
   line3
-`
-		if expected != got {
-			t.Fatalf("failed to encode.\nexpected:\n%s\nbut got:\n%s\n", expected, got)
-		}
-	})
-	t.Run("depth2", func(t *testing.T) {
-		b, err := yaml.MarshalWithOptions(map[string]interface{}{
-			"key": map[string]interface{}{
-				"key2": []string{`line1
+`,
+		},
+		{
+			name: "depth2",
+			input: map[string]interface{}{
+				"key": map[string]interface{}{
+					"key2": []string{`line1
 line2
 line3`},
+				},
 			},
-		}, yaml.Indent(2))
-		if err != nil {
-			t.Fatal(err)
-		}
-		got := string(b)
-		expected := `key:
+			indent: yaml.Indent(2),
+			want: `key:
   key2:
   - |-
     line1
     line2
     line3
-`
-		if expected != got {
-			t.Fatalf("failed to encode.\nexpected:\n%s\nbut got:\n%s\n", expected, got)
-		}
-	})
+`,
+		},
+		{
+			name: "raw string new lines",
+			input: map[string]interface{}{
+				"key": "line1\nline2\nline3",
+			},
+			indent: yaml.Indent(4),
+			want: `key: |-
+    line1
+    line2
+    line3
+`,
+		},
+	}
+
+	for _, tt := range tests {
+		t.Run(tt.name, func(t *testing.T) {
+			b, err := yaml.MarshalWithOptions(tt.input, tt.indent)
+			if err != nil {
+				t.Fatalf("failed to marshal yaml: %v", err)
+			}
+			got := string(b)
+			if tt.want != got {
+				t.Fatalf("failed to encode.\nexpected:\n%s\nbut got:\n%s\n", tt.want, got)
+			}
+		})
+	}
 }
 
 type bytesMarshaler struct{}
diff --git a/parser/parser_test.go b/parser/parser_test.go
index 8a80925..487bfe1 100644
--- a/parser/parser_test.go
+++ b/parser/parser_test.go
@@ -16,6 +16,7 @@ import (
 
 func TestParser(t *testing.T) {
 	sources := []string{
+		"",
 		"null\n",
 		"0_",
 		"{}\n",
@@ -144,12 +145,52 @@ foo: zzz
 `,
 	}
 	for _, src := range sources {
-		if _, err := parser.Parse(lexer.Tokenize(src), 0); err != nil {
+		f, err := parser.Parse(lexer.Tokenize(src), 0)
+		if err != nil {
 			t.Fatalf("parse error: source [%s]: %+v", src, err)
 		}
+		_ = f.String() // ensure no panic
 	}
 }
 
+func TestParseEmptyDocument(t *testing.T) {
+	t.Run("empty document", func(t *testing.T) {
+		f, err := parser.ParseBytes([]byte(""), parser.ParseComments)
+		if err != nil {
+			t.Fatal(err)
+		}
+		got := f.String()
+		expected := "\n"
+		if got != expected {
+			t.Fatalf("failed to parse comment:\nexpected:\n%q\ngot:\n%q", expected, got)
+		}
+	})
+
+	t.Run("empty document with comment (parse comment = off)", func(t *testing.T) {
+		f, err := parser.ParseBytes([]byte("# comment"), 0)
+		if err != nil {
+			t.Fatal(err)
+		}
+		got := f.String()
+		expected := "\n"
+		if got != expected {
+			t.Fatalf("failed to parse comment:\nexpected:\n%q\ngot:\n%q", expected, got)
+		}
+	})
+
+	t.Run("empty document with comment (parse comment = on)", func(t *testing.T) {
+		f, err := parser.ParseBytes([]byte("# comment"), parser.ParseComments)
+		if err != nil {
+			t.Fatal(err)
+		}
+		got := f.String()
+		expected := "# comment\n"
+		if got != expected {
+			t.Fatalf("failed to parse comment:\nexpected:\n%q\ngot:\n%q", expected, got)
+		}
+	})
+}
+
 func TestParseComplicatedDocument(t *testing.T) {
 	tests := []struct {
 		source string