Skip to content

Commit b7c8ecc

Browse files
committed
Add engine.Quote with JSON quoting standard
1 parent ef5fcd3 commit b7c8ecc

File tree

3 files changed

+180
-4
lines changed

3 files changed

+180
-4
lines changed

internal/engine/quote.go

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
package engine
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
"unicode/utf16"
7+
"unicode/utf8"
8+
)
9+
10+
func Quote(s string) string {
11+
var err error
12+
var b strings.Builder
13+
b.WriteByte('"')
14+
15+
for i := 0; i < len(s); {
16+
r, width := utf8.DecodeRuneInString(s[i:])
17+
18+
switch r {
19+
case '"':
20+
b.WriteString(`\"`)
21+
case '\\':
22+
b.WriteString(`\\`)
23+
case '\b':
24+
b.WriteString(`\b`)
25+
case '\f':
26+
b.WriteString(`\f`)
27+
case '\n':
28+
b.WriteString(`\n`)
29+
case '\r':
30+
b.WriteString(`\r`)
31+
case '\t':
32+
b.WriteString(`\t`)
33+
default:
34+
if r < 0x20 || r == 0x7F {
35+
// Control characters must be escaped as \uXXXX
36+
_, err = fmt.Fprintf(&b, `\u%04x`, r)
37+
if err != nil {
38+
panic(err)
39+
}
40+
} else if r > 0xFFFF {
41+
// Characters outside BMP need UTF-16 surrogate pairs
42+
r1, r2 := utf16.EncodeRune(r)
43+
_, err = fmt.Fprintf(&b, `\u%04x\u%04x`, r1, r2)
44+
if err != nil {
45+
panic(err)
46+
}
47+
} else if r == utf8.RuneError && width == 1 {
48+
// Invalid UTF-8 sequence - escape the byte
49+
_, err = fmt.Fprintf(&b, `\u%04x`, s[i])
50+
if err != nil {
51+
panic(err)
52+
}
53+
} else {
54+
// Regular character - write as-is
55+
b.WriteRune(r)
56+
}
57+
}
58+
59+
i += width
60+
}
61+
62+
b.WriteByte('"')
63+
return b.String()
64+
}

internal/engine/quote_test.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package engine_test
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
7+
"github.com/stretchr/testify/assert"
8+
9+
"github.com/antonmedv/fx/internal/engine"
10+
)
11+
12+
func TestQuote_BasicASCII(t *testing.T) {
13+
assert.Equal(t, "\"hello\"", engine.Quote("hello"))
14+
assert.Equal(t, "\"\"", engine.Quote(""))
15+
assert.Equal(t, "\"Hello, world!\"", engine.Quote("Hello, world!"))
16+
}
17+
18+
func TestQuote_EscapesSpecialCharacters(t *testing.T) {
19+
// Quotes and backslashes
20+
assert.Equal(t, "\"\\\"\"", engine.Quote("\""))
21+
assert.Equal(t, "\"\\\\\"", engine.Quote("\\"))
22+
23+
// Common escapes
24+
assert.Equal(t, "\"\\b\"", engine.Quote("\b"))
25+
assert.Equal(t, "\"\\f\"", engine.Quote("\f"))
26+
assert.Equal(t, "\"\\n\"", engine.Quote("\n"))
27+
assert.Equal(t, "\"\\r\"", engine.Quote("\r"))
28+
assert.Equal(t, "\"\\t\"", engine.Quote("\t"))
29+
}
30+
31+
func TestQuote_ControlCharactersAndDEL(t *testing.T) {
32+
// 0x00 .. 0x1F should be \uXXXX
33+
for b := 0; b < 0x20; b++ {
34+
s := string([]byte{byte(b)})
35+
q := engine.Quote(s)
36+
expected := "\"\\u" + hex4Lower(b) + "\""
37+
// For those with dedicated escapes, engine.Quote uses short escapes; both are valid.
38+
// We'll accept either short escape or \uXXXX for those particular bytes.
39+
switch b {
40+
case '\b':
41+
assert.Equal(t, "\"\\b\"", q)
42+
case '\f':
43+
assert.Equal(t, "\"\\f\"", q)
44+
case '\n':
45+
assert.Equal(t, "\"\\n\"", q)
46+
case '\r':
47+
assert.Equal(t, "\"\\r\"", q)
48+
case '\t':
49+
assert.Equal(t, "\"\\t\"", q)
50+
default:
51+
assert.Equal(t, expected, q, "byte %d", b)
52+
}
53+
}
54+
// 0x7F DEL
55+
assert.Equal(t, "\"\\u007f\"", engine.Quote(string([]byte{0x7F})))
56+
}
57+
58+
func TestQuote_BMP_CharactersAsIs(t *testing.T) {
59+
// Latin-1 supplement, Cyrillic, CJK BMP characters should appear as-is
60+
assert.Equal(t, "\"café\"", engine.Quote("café")) // U+00E9
61+
assert.Equal(t, "\"Привет\"", engine.Quote("Привет")) // Cyrillic
62+
assert.Equal(t, "\"漢字\"", engine.Quote("漢字")) // CJK
63+
}
64+
65+
func TestQuote_NonBMP_SurrogatePairs(t *testing.T) {
66+
// Rocket U+1F680 -> \ud83d\ude80
67+
assert.Equal(t, "\"\\ud83d\\ude80\"", engine.Quote("🚀"))
68+
// Musical symbol G clef U+1D11E -> \ud834\udd1e
69+
assert.Equal(t, "\"\\ud834\\udd1e\"", engine.Quote("𝄞"))
70+
}
71+
72+
func TestQuote_InvalidUTF8BytesAreEscaped(t *testing.T) {
73+
// Construct a string with invalid UTF-8 byte 0xFF and 0xC0 (overlong lead)
74+
s := string([]byte{'A', 0xFF, 'B', 0xC0, 'C'})
75+
got := engine.Quote(s)
76+
// Expect bytes to be escaped as \u00xx in lowercase hex
77+
want := "\"A\\u00ffB\\u00c0C\""
78+
assert.Equal(t, want, got)
79+
}
80+
81+
func TestQuote_MixedContent(t *testing.T) {
82+
s := "Line1\n\t\"Quote\" and backslash \\ and DEL:" + string([]byte{0x7F}) + " and emoji 🚀"
83+
got := engine.Quote(s)
84+
// Validate it is a valid JSON string and round-trips to the same value
85+
var v string
86+
err := json.Unmarshal([]byte(got), &v)
87+
assert.NoError(t, err)
88+
assert.Equal(t, s, v)
89+
}
90+
91+
func TestQuote_JSONRoundTrip_ValidUTF8(t *testing.T) {
92+
inputs := []string{
93+
"", "simple", "line\nfeed", "tab\tchar", "quote \" here", "backslash \\",
94+
"café", "Привет", "漢字", "emoji 🚀", "mix: \b\f\n\r\t and \u007F:" + string([]byte{0x7F}),
95+
}
96+
for _, s := range inputs {
97+
q := engine.Quote(s)
98+
var v string
99+
err := json.Unmarshal([]byte(q), &v)
100+
assert.NoError(t, err, "failed to unmarshal: %q", q)
101+
assert.Equal(t, s, v)
102+
}
103+
}
104+
105+
// hex4Lower returns a 4-digit lowercase hex for a small non-negative integer (< 65536)
106+
func hex4Lower(n int) string {
107+
const hexdigits = "0123456789abcdef"
108+
b0 := hexdigits[(n>>12)&0xF]
109+
b1 := hexdigits[(n>>8)&0xF]
110+
b2 := hexdigits[(n>>4)&0xF]
111+
b3 := hexdigits[n&0xF]
112+
return string([]byte{b0, b1, b2, b3})
113+
}

internal/engine/stringify.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,6 @@ import (
55
"math"
66
"math/big"
77
"reflect"
8-
"strconv"
98
"strings"
109
"time"
1110

@@ -28,7 +27,7 @@ func Stringify(value goja.Value, vm *goja.Runtime, depth int) string {
2827
return bi.String()
2928
case timeTimeType:
3029
t := value.Export().(time.Time)
31-
quoted := strconv.Quote(t.String())
30+
quoted := Quote(t.String())
3231
return quoted
3332
}
3433

@@ -53,7 +52,7 @@ func Stringify(value goja.Value, vm *goja.Runtime, depth int) string {
5352
return value.String()
5453

5554
case reflect.String:
56-
return strconv.Quote(value.String())
55+
return Quote(value.String())
5756

5857
case reflect.Map:
5958
obj := value.ToObject(vm)
@@ -72,7 +71,7 @@ func Stringify(value goja.Value, vm *goja.Runtime, depth int) string {
7271

7372
for i, key := range keys {
7473
out.WriteString(identKey)
75-
out.WriteString(strconv.Quote(key))
74+
out.WriteString(Quote(key))
7675
out.WriteString(":")
7776
out.WriteString(" ")
7877
out.WriteString(Stringify(obj.Get(key), vm, depth+1))

0 commit comments

Comments
 (0)