Repository: goccy/go-yaml
Pull request #164 (2020-10-05 03:21:37)

Multiline block fix
Introduced the option `ForceBlockIfMultiline`, which allows multiline strings containing "special YAML characters" to be represented in prettified blocks, instead of being quoted forcefully.

The main reason for this PR existence is the desire to have more prettified YAML code.

E.g. let's use the following map element:

```go
myMap := map[string]interface{}{"v": "# comment\nname: hello\npassword: hello123\nspecial: \":ghost:\"\ntext: |\n  nested multiline!"}
```

When encoded "normally", the result is:

```yaml
v: "# comment\nname: hello\npassword: hello123\nspecial: \":ghost:\"\ntext: |\n  nested multiline!"
```

When encoded with the `ForceBlockIfMultiline` option, the result is:

```yaml
v: |-
  # comment
  name: hello
  password: hello123
  special: ":ghost:"
  text: |
    nested multiline!
```


Patch:
diff --git a/encode.go b/encode.go
index 9bcd7ec..120cf27 100644
--- a/encode.go
+++ b/encode.go
@@ -26,14 +26,15 @@ const (
 
 // Encoder writes YAML values to an output stream.
 type Encoder struct {
-	writer             io.Writer
-	opts               []EncodeOption
-	indent             int
-	isFlowStyle        bool
-	isJSONStyle        bool
-	useJSONMarshaler   bool
-	anchorCallback     func(*ast.AnchorNode, interface{}) error
-	anchorPtrToNameMap map[uintptr]string
+	writer                     io.Writer
+	opts                       []EncodeOption
+	indent                     int
+	isFlowStyle                bool
+	isJSONStyle                bool
+	useJSONMarshaler           bool
+	anchorCallback             func(*ast.AnchorNode, interface{}) error
+	anchorPtrToNameMap         map[uintptr]string
+	useLiteralStyleIfMultiline bool
 
 	line        int
 	column      int
@@ -272,8 +273,21 @@ func (e *Encoder) encodeFloat(v float64) ast.Node {
 	return ast.Float(token.New(value, value, e.pos(e.column)))
 }
 
+func (e *Encoder) isNeedQuoted(v string) bool {
+	if e.isJSONStyle {
+		return true
+	}
+	if e.useLiteralStyleIfMultiline && strings.ContainsAny(v, "\n\r") {
+		return false
+	}
+	if token.IsNeedQuoted(v) {
+		return true
+	}
+	return false
+}
+
 func (e *Encoder) encodeString(v string, column int) ast.Node {
-	if e.isJSONStyle || token.IsNeedQuoted(v) {
+	if e.isNeedQuoted(v) {
 		v = strconv.Quote(v)
 	}
 	return ast.String(token.New(v, v, e.pos(column)))
diff --git a/option.go b/option.go
index 343fc55..9c4557f 100644
--- a/option.go
+++ b/option.go
@@ -113,6 +113,15 @@ func Flow(isFlowStyle bool) EncodeOption {
 	}
 }
 
+// UseLiteralStyleIfMultiline causes encoding multiline strings with a literal syntax,
+// no matter what characters they include
+func UseLiteralStyleIfMultiline(useLiteralStyleIfMultiline bool) EncodeOption {
+	return func(e *Encoder) error {
+		e.useLiteralStyleIfMultiline = useLiteralStyleIfMultiline
+		return nil
+	}
+}
+
 // JSON encode in JSON format
 func JSON() EncodeOption {
 	return func(e *Encoder) error {


Test patch:
diff --git a/encode_test.go b/encode_test.go
index 4d5cef4..00486f7 100644
--- a/encode_test.go
+++ b/encode_test.go
@@ -4,6 +4,7 @@ import (
 	"bytes"
 	"fmt"
 	"math"
+	"reflect"
 	"strconv"
 	"testing"
 	"time"
@@ -17,120 +18,170 @@ var emptyStr = ""
 
 func TestEncoder(t *testing.T) {
 	tests := []struct {
-		source string
-		value  interface{}
+		source  string
+		value   interface{}
+		options []yaml.EncodeOption
 	}{
 		{
 			"null\n",
 			(*struct{})(nil),
+			nil,
 		},
 		{
 			"v: hi\n",
 			map[string]string{"v": "hi"},
+			nil,
 		},
 		{
 			"v: \"true\"\n",
 			map[string]string{"v": "true"},
+			nil,
 		},
 		{
 			"v: \"false\"\n",
 			map[string]string{"v": "false"},
+			nil,
 		},
 		{
 			"v: true\n",
 			map[string]interface{}{"v": true},
+			nil,
 		},
 		{
 			"v: false\n",
 			map[string]bool{"v": false},
+			nil,
 		},
 		{
 			"v: 10\n",
 			map[string]int{"v": 10},
+			nil,
 		},
 		{
 			"v: -10\n",
 			map[string]int{"v": -10},
+			nil,
 		},
 		{
 			"v: 4294967296\n",
 			map[string]int{"v": 4294967296},
+			nil,
 		},
 		{
 			"v: 0.1\n",
 			map[string]interface{}{"v": 0.1},
+			nil,
 		},
 		{
 			"v: 0.99\n",
 			map[string]float32{"v": 0.99},
+			nil,
 		},
 		{
 			"v: -0.1\n",
 			map[string]float64{"v": -0.1},
+			nil,
 		},
 		{
 			"v: 1.0\n",
 			map[string]float64{"v": 1.0},
+			nil,
 		},
 		{
 			"v: .inf\n",
 			map[string]interface{}{"v": math.Inf(0)},
+			nil,
 		},
 		{
 			"v: -.inf\n",
 			map[string]interface{}{"v": math.Inf(-1)},
+			nil,
 		},
 		{
 			"v: .nan\n",
 			map[string]interface{}{"v": math.NaN()},
+			nil,
 		},
 		{
 			"v: null\n",
 			map[string]interface{}{"v": nil},
+			nil,
 		},
 		{
 			"v: \"\"\n",
 			map[string]string{"v": ""},
+			nil,
 		},
 		{
 			"v:\n- A\n- B\n",
 			map[string][]string{"v": {"A", "B"}},
+			nil,
 		},
 		{
 			"a: -\n",
 			map[string]string{"a": "-"},
+			nil,
 		},
 		{
 			"123\n",
 			123,
+			nil,
 		},
 		{
 			"hello: world\n",
 			map[string]string{"hello": "world"},
+			nil,
 		},
 		{
 			"hello: |\n  hello\n  world\n",
 			map[string]string{"hello": "hello\nworld\n"},
+			nil,
 		},
 		{
 			"hello: |-\n  hello\n  world\n",
 			map[string]string{"hello": "hello\nworld"},
+			nil,
 		},
 		{
 			"hello: |+\n  hello\n  world\n\n",
 			map[string]string{"hello": "hello\nworld\n\n"},
+			nil,
 		},
 		{
 			"hello:\n  hello: |\n    hello\n    world\n",
 			map[string]map[string]string{"hello": {"hello": "hello\nworld\n"}},
+			nil,
 		},
 		{
 			"hello: |\r  hello\r  world\n",
 			map[string]string{"hello": "hello\rworld\r"},
+			nil,
 		},
 		{
 			"hello: |\r\n  hello\r\n  world\n",
 			map[string]string{"hello": "hello\r\nworld\r\n"},
+			nil,
+		},
+		{
+			"v: |-\n  username: hello\n  password: hello123\n",
+			map[string]interface{}{"v": "username: hello\npassword: hello123"},
+			[]yaml.EncodeOption{
+				yaml.UseLiteralStyleIfMultiline(true),
+			},
+		},
+		{
+			"v: |-\n  # comment\n  username: hello\n  password: hello123\n",
+			map[string]interface{}{"v": "# comment\nusername: hello\npassword: hello123"},
+			[]yaml.EncodeOption{
+				yaml.UseLiteralStyleIfMultiline(true),
+			},
+		},
+		{
+			"v: \"# comment\\nusername: hello\\npassword: hello123\"\n",
+			map[string]interface{}{"v": "# comment\nusername: hello\npassword: hello123"},
+			[]yaml.EncodeOption{
+				yaml.UseLiteralStyleIfMultiline(false),
+			},
 		},
 		{
 			"v:\n- A\n- 1\n- B:\n  - 2\n  - 3\n",
@@ -143,6 +194,7 @@ func TestEncoder(t *testing.T) {
 					},
 				},
 			},
+			nil,
 		},
 		{
 			"a:\n  b: c\n",
@@ -151,6 +203,7 @@ func TestEncoder(t *testing.T) {
 					"b": "c",
 				},
 			},
+			nil,
 		},
 		{
 			"t2: 2018-01-09T10:40:47Z\nt4: 2098-01-09T10:40:47Z\n",
@@ -158,6 +211,7 @@ func TestEncoder(t *testing.T) {
 				"t2": "2018-01-09T10:40:47Z",
 				"t4": "2098-01-09T10:40:47Z",
 			},
+			nil,
 		},
 		{
 			"a:\n  b: c\n  d: e\n",
@@ -167,42 +221,51 @@ func TestEncoder(t *testing.T) {
 					"d": "e",
 				},
 			},
+			nil,
 		},
 		{
 			"a: 3s\n",
 			map[string]string{
 				"a": "3s",
 			},
+			nil,
 		},
 		{
 			"a: <foo>\n",
 			map[string]string{"a": "<foo>"},
+			nil,
 		},
 		{
 			"a: \"1:1\"\n",
 			map[string]string{"a": "1:1"},
+			nil,
 		},
 		{
 			"a: 1.2.3.4\n",
 			map[string]string{"a": "1.2.3.4"},
+			nil,
 		},
 		{
 			"a: \"b: c\"\n",
 			map[string]string{"a": "b: c"},
+			nil,
 		},
 		{
 			"a: \"Hello #comment\"\n",
 			map[string]string{"a": "Hello #comment"},
+			nil,
 		},
 		{
 			"a: 100.5\n",
 			map[string]interface{}{
 				"a": 100.5,
 			},
+			nil,
 		},
 		{
 			"a: \"\\\\0\"\n",
 			map[string]string{"a": "\\0"},
+			nil,
 		},
 		{
 			"a: 1\nb: 2\nc: 3\nd: 4\nsub:\n  e: 5\n",
@@ -215,6 +278,7 @@ func TestEncoder(t *testing.T) {
 					"e": 5,
 				},
 			},
+			nil,
 		},
 		{
 			"a: 1\nb: []\n",
@@ -224,6 +288,7 @@ func TestEncoder(t *testing.T) {
 			}{
 				1, ([]string)(nil),
 			},
+			nil,
 		},
 		{
 			"a: 1\nb: []\n",
@@ -233,6 +298,7 @@ func TestEncoder(t *testing.T) {
 			}{
 				1, []string{},
 			},
+			nil,
 		},
 		{
 			"a: {}\n",
@@ -241,6 +307,7 @@ func TestEncoder(t *testing.T) {
 			}{
 				map[string]interface{}{},
 			},
+			nil,
 		},
 		{
 			"a: b\nc: d\n",
@@ -250,6 +317,7 @@ func TestEncoder(t *testing.T) {
 			}{
 				"b", "d",
 			},
+			nil,
 		},
 		{
 			"a: 1\n",
@@ -259,6 +327,7 @@ func TestEncoder(t *testing.T) {
 			}{
 				1, 0,
 			},
+			nil,
 		},
 		{
 			"a: \"\"\n",
@@ -267,6 +336,7 @@ func TestEncoder(t *testing.T) {
 			}{
 				"",
 			},
+			nil,
 		},
 		{
 			"a: null\n",
@@ -275,6 +345,7 @@ func TestEncoder(t *testing.T) {
 			}{
 				nil,
 			},
+			nil,
 		},
 		{
 			"a: \"\"\n",
@@ -283,6 +354,7 @@ func TestEncoder(t *testing.T) {
 			}{
 				&emptyStr,
 			},
+			nil,
 		},
 		{
 			"a: null\n",
@@ -291,6 +363,7 @@ func TestEncoder(t *testing.T) {
 			}{
 				nil,
 			},
+			nil,
 		},
 		{
 			"a: 0\n",
@@ -299,6 +372,7 @@ func TestEncoder(t *testing.T) {
 			}{
 				&zero,
 			},
+			nil,
 		},
 
 		// Conditional flag
@@ -308,6 +382,7 @@ func TestEncoder(t *testing.T) {
 				A int `yaml:"a,omitempty"`
 				B int `yaml:"b,omitempty"`
 			}{1, 0},
+			nil,
 		},
 		{
 			"{}\n",
@@ -315,6 +390,7 @@ func TestEncoder(t *testing.T) {
 				A int `yaml:"a,omitempty"`
 				B int `yaml:"b,omitempty"`
 			}{0, 0},
+			nil,
 		},
 
 		{
@@ -328,6 +404,7 @@ func TestEncoder(t *testing.T) {
 				X string `yaml:"x,omitempty"`
 				Y string
 			}{}},
+			nil,
 		},
 
 		{
@@ -341,6 +418,7 @@ func TestEncoder(t *testing.T) {
 				X string `yaml:"x,omitempty"`
 				Y string `yaml:"y,omitempty"`
 			}{}},
+			nil,
 		},
 
 		{
@@ -348,6 +426,7 @@ func TestEncoder(t *testing.T) {
 			struct {
 				A *struct{ X, y int } `yaml:"a,omitempty,flow"`
 			}{&struct{ X, y int }{1, 2}},
+			nil,
 		},
 
 		{
@@ -355,6 +434,7 @@ func TestEncoder(t *testing.T) {
 			struct {
 				A *struct{ X, y int } `yaml:"a,omitempty,flow"`
 			}{nil},
+			nil,
 		},
 
 		{
@@ -362,6 +442,7 @@ func TestEncoder(t *testing.T) {
 			struct {
 				A *struct{ X, y int } `yaml:"a,omitempty,flow"`
 			}{&struct{ X, y int }{}},
+			nil,
 		},
 
 		{
@@ -369,12 +450,14 @@ func TestEncoder(t *testing.T) {
 			struct {
 				A struct{ X, y int } `yaml:"a,omitempty,flow"`
 			}{struct{ X, y int }{1, 2}},
+			nil,
 		},
 		{
 			"{}\n",
 			struct {
 				A struct{ X, y int } `yaml:"a,omitempty,flow"`
 			}{struct{ X, y int }{0, 1}},
+			nil,
 		},
 		{
 			"a: 1.0\n",
@@ -382,6 +465,7 @@ func TestEncoder(t *testing.T) {
 				A float64 `yaml:"a,omitempty"`
 				B float64 `yaml:"b,omitempty"`
 			}{1, 0},
+			nil,
 		},
 		{
 			"a: 1\n",
@@ -391,6 +475,7 @@ func TestEncoder(t *testing.T) {
 			}{
 				1, []string{},
 			},
+			nil,
 		},
 
 		// Flow flag
@@ -399,12 +484,14 @@ func TestEncoder(t *testing.T) {
 			struct {
 				A []int `yaml:"a,flow"`
 			}{[]int{1, 2}},
+			nil,
 		},
 		{
 			"a: {b: c, d: e}\n",
 			&struct {
 				A map[string]string `yaml:"a,flow"`
 			}{map[string]string{"b": "c", "d": "e"}},
+			nil,
 		},
 		{
 			"a: {b: c, d: e}\n",
@@ -413,31 +500,36 @@ func TestEncoder(t *testing.T) {
 					B, D string
 				} `yaml:"a,flow"`
 			}{struct{ B, D string }{"c", "e"}},
+			nil,
 		},
 
 		// Multi bytes
 		{
 			"v: あいうえお\nv2: かきくけこ\n",
 			map[string]string{"v": "あいうえお", "v2": "かきくけこ"},
+			nil,
 		},
 
 		// time value
 		{
 			"v: 0001-01-01T00:00:00Z\n",
 			map[string]time.Time{"v": time.Time{}},
+			nil,
 		},
 		{
 			"v: 0001-01-01T00:00:00Z\n",
 			map[string]*time.Time{"v": &time.Time{}},
+			nil,
 		},
 		{
 			"v: null\n",
 			map[string]*time.Time{"v": nil},
+			nil,
 		},
 	}
 	for _, test := range tests {
 		var buf bytes.Buffer
-		enc := yaml.NewEncoder(&buf)
+		enc := yaml.NewEncoder(&buf, test.options...)
 		if err := enc.Encode(test.value); err != nil {
 			t.Fatalf("%+v", err)
 		}
@@ -604,6 +696,60 @@ people:
 	}
 }
 
+func TestEncodeWithNestedYAML(t *testing.T) {
+	// Represents objects containing stringified YAML, and special chars
+	tests := []struct {
+		value interface{}
+		// If true, expects a different result between when using forced literal style or not
+		expectDifferent bool
+	}{
+		{
+			value:           map[string]interface{}{"v": "# comment\nname: hello\npassword: hello123\nspecial: \":ghost:\"\ntext: |\n  nested multiline!"},
+			expectDifferent: true,
+		},
+		{
+			value:           map[string]interface{}{"v": "# comment\nusername: hello\npassword: hello123"},
+			expectDifferent: true,
+		},
+		{
+			value:           map[string]interface{}{"v": "# comment\n"},
+			expectDifferent: true,
+		},
+		{
+			value: map[string]interface{}{"v": "\n"},
+		},
+	}
+
+	for _, test := range tests {
+		yamlBytesForced, err := yaml.MarshalWithOptions(test.value, yaml.UseLiteralStyleIfMultiline(true))
+		if err != nil {
+			t.Fatalf("%+v", err)
+		}
+
+		// Convert it back for proper equality testing
+		var unmarshaled interface{}
+
+		if err := yaml.Unmarshal(yamlBytesForced, &unmarshaled); err != nil {
+			t.Fatalf("%+v", err)
+		}
+
+		if !reflect.DeepEqual(test.value, unmarshaled) {
+			t.Fatalf("expected %v(%T). but actual %v(%T)", test.value, test.value, unmarshaled, unmarshaled)
+		}
+
+		if test.expectDifferent {
+			yamlBytesNotForced, err := yaml.MarshalWithOptions(test.value)
+			if err != nil {
+				t.Fatalf("%+v", err)
+			}
+
+			if string(yamlBytesForced) == string(yamlBytesNotForced) {
+				t.Fatalf("expected different strings when force literal style is not enabled. forced: %s, not forced: %s", string(yamlBytesForced), string(yamlBytesNotForced))
+			}
+		}
+	}
+}
+
 func TestEncoder_Inline(t *testing.T) {
 	type base struct {
 		A int
@@ -859,10 +1005,10 @@ func Example_Marshal_ExplicitAnchorAlias() {
 	}
 	fmt.Println(string(bytes))
 	// OUTPUT:
-	//c: &x
+	// c: &x
 	//   a: 1
 	//   b: hello
-	//d: *x
+	// d: *x
 }
 
 func Example_Marshal_ImplicitAnchorAlias() {