Repository: goccy/go-yaml
Pull request #265 (2021-11-06 02:54:46)

add UseSingleQuote option
Reopening #242 with a different approach. This feels simpler.


Patch:
diff --git a/encode.go b/encode.go
index 417c38e..7b4ba56 100644
--- a/encode.go
+++ b/encode.go
@@ -31,6 +31,7 @@ type Encoder struct {
 	opts                       []EncodeOption
 	indent                     int
 	indentSequence             bool
+	singleQuote                bool
 	isFlowStyle                bool
 	isJSONStyle                bool
 	useJSONMarshaler           bool
@@ -412,7 +413,11 @@ func (e *Encoder) isNeedQuoted(v string) bool {
 
 func (e *Encoder) encodeString(v string, column int) ast.Node {
 	if e.isNeedQuoted(v) {
-		v = strconv.Quote(v)
+		if e.singleQuote {
+			v = quoteWith(v, '\'')
+		} else {
+			v = strconv.Quote(v)
+		}
 	}
 	return ast.String(token.New(v, v, e.pos(column)))
 }
diff --git a/option.go b/option.go
index 0f5b566..a285a45 100644
--- a/option.go
+++ b/option.go
@@ -113,6 +113,14 @@ func IndentSequence(indent bool) EncodeOption {
 	}
 }
 
+// UseSingleQuote determines if single or double quotes should be preferred for strings.
+func UseSingleQuote(sq bool) EncodeOption {
+	return func(e *Encoder) error {
+		e.singleQuote = sq
+		return nil
+	}
+}
+
 // Flow encoding by flow style
 func Flow(isFlowStyle bool) EncodeOption {
 	return func(e *Encoder) error {
diff --git a/stdlib_quote.go b/stdlib_quote.go
new file mode 100644
index 0000000..be50ae6
--- /dev/null
+++ b/stdlib_quote.go
@@ -0,0 +1,103 @@
+// Copied and trimmed down from https://github.com/golang/go/blob/e3769299cd3484e018e0e2a6e1b95c2b18ce4f41/src/strconv/quote.go
+// We want to use the standard library's private "quoteWith" function rather than write our own so that we get robust unicode support.
+// Every private function called by quoteWith was copied.
+// There are 2 modifications to simplify the code:
+// 1. The unicode.IsPrint function was substituted for the custom implementation of IsPrint
+// 2. All code paths reachable only when ASCIIonly or grphicOnly are set to true were removed.
+
+// Copyright 2009 The Go Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style
+// license that can be found in the LICENSE file.
+
+package yaml
+
+import (
+	"unicode"
+	"unicode/utf8"
+)
+
+const (
+	lowerhex = "0123456789abcdef"
+)
+
+func quoteWith(s string, quote byte) string {
+	return string(appendQuotedWith(make([]byte, 0, 3*len(s)/2), s, quote))
+}
+
+func appendQuotedWith(buf []byte, s string, quote byte) []byte {
+	// Often called with big strings, so preallocate. If there's quoting,
+	// this is conservative but still helps a lot.
+	if cap(buf)-len(buf) < len(s) {
+		nBuf := make([]byte, len(buf), len(buf)+1+len(s)+1)
+		copy(nBuf, buf)
+		buf = nBuf
+	}
+	buf = append(buf, quote)
+	for width := 0; len(s) > 0; s = s[width:] {
+		r := rune(s[0])
+		width = 1
+		if r >= utf8.RuneSelf {
+			r, width = utf8.DecodeRuneInString(s)
+		}
+		if width == 1 && r == utf8.RuneError {
+			buf = append(buf, `\x`...)
+			buf = append(buf, lowerhex[s[0]>>4])
+			buf = append(buf, lowerhex[s[0]&0xF])
+			continue
+		}
+		buf = appendEscapedRune(buf, r, quote)
+	}
+	buf = append(buf, quote)
+	return buf
+}
+
+func appendEscapedRune(buf []byte, r rune, quote byte) []byte {
+	var runeTmp [utf8.UTFMax]byte
+	if r == rune(quote) || r == '\\' { // always backslashed
+		buf = append(buf, '\\')
+		buf = append(buf, byte(r))
+		return buf
+	}
+	if unicode.IsPrint(r) {
+		n := utf8.EncodeRune(runeTmp[:], r)
+		buf = append(buf, runeTmp[:n]...)
+		return buf
+	}
+	switch r {
+	case '\a':
+		buf = append(buf, `\a`...)
+	case '\b':
+		buf = append(buf, `\b`...)
+	case '\f':
+		buf = append(buf, `\f`...)
+	case '\n':
+		buf = append(buf, `\n`...)
+	case '\r':
+		buf = append(buf, `\r`...)
+	case '\t':
+		buf = append(buf, `\t`...)
+	case '\v':
+		buf = append(buf, `\v`...)
+	default:
+		switch {
+		case r < ' ':
+			buf = append(buf, `\x`...)
+			buf = append(buf, lowerhex[byte(r)>>4])
+			buf = append(buf, lowerhex[byte(r)&0xF])
+		case r > utf8.MaxRune:
+			r = 0xFFFD
+			fallthrough
+		case r < 0x10000:
+			buf = append(buf, `\u`...)
+			for s := 12; s >= 0; s -= 4 {
+				buf = append(buf, lowerhex[r>>uint(s)&0xF])
+			}
+		default:
+			buf = append(buf, `\U`...)
+			for s := 28; s >= 0; s -= 4 {
+				buf = append(buf, lowerhex[r>>uint(s)&0xF])
+			}
+		}
+	}
+	return buf
+}


Test patch:
diff --git a/encode_test.go b/encode_test.go
index 864eff1..0df0bca 100644
--- a/encode_test.go
+++ b/encode_test.go
@@ -577,6 +577,21 @@ func TestEncoder(t *testing.T) {
 			map[string]time.Duration{"v": 30 * time.Second},
 			nil,
 		},
+		// Quote style
+		{
+			`v: '\'a\'b'` + "\n",
+			map[string]string{"v": `'a'b`},
+			[]yaml.EncodeOption{
+				yaml.UseSingleQuote(true),
+			},
+		},
+		{
+			`v: "'a'b"` + "\n",
+			map[string]string{"v": `'a'b`},
+			[]yaml.EncodeOption{
+				yaml.UseSingleQuote(false),
+			},
+		},
 	}
 	for _, test := range tests {
 		var buf bytes.Buffer