diff --git a/Makefile b/Makefile index eed947e..7b6236a 100644 --- a/Makefile +++ b/Makefile @@ -41,6 +41,7 @@ generate: build bin/easyjson \ ./tests/nested_easy.go \ ./tests/named_type.go \ + ./tests/json_number.go \ ./tests/custom_map_key_type.go \ ./tests/embedded_type.go \ ./tests/reference_to_pointer.go \ @@ -64,6 +65,7 @@ test: generate go test \ ./tests \ ./jlexer \ + ./jwriter \ ./gen \ ./buffer cd benchmark && go test -benchmem -tags use_easyjson -bench . diff --git a/gen/encoder.go b/gen/encoder.go index c9a886a..5e1bae1 100644 --- a/gen/encoder.go +++ b/gen/encoder.go @@ -15,6 +15,12 @@ func (g *Generator) getEncoderName(t reflect.Type) string { return g.functionName("encode", t) } +// customEncoders is the encoder counterpart of customDecoders: types that +// must not be encoded as their underlying kind. +var customEncoders = map[string]string{ + "json.Number": "out.JsonNumber(%v)", +} + var primitiveEncoders = map[reflect.Kind]string{ reflect.String: "out.String(string(%v))", reflect.Bool: "out.Bool(bool(%v))", @@ -135,6 +141,11 @@ func (g *Generator) genTypeEncoderNoCheck(t reflect.Type, in string, tags fieldT ws := strings.Repeat(" ", indent) // Check whether type is primitive, needs to be done after interface check. + if enc := customEncoders[t.String()]; enc != "" { + fmt.Fprintf(g.out, ws+enc+"\n", in) + return nil + } + if enc := primitiveStringEncoders[t.Kind()]; enc != "" && tags.asString { fmt.Fprintf(g.out, ws+enc+"\n", in) return nil diff --git a/jwriter/writer.go b/jwriter/writer.go index 432bade..a447567 100644 --- a/jwriter/writer.go +++ b/jwriter/writer.go @@ -2,6 +2,7 @@ package jwriter import ( + "encoding/json" "fmt" "io" "math" @@ -97,6 +98,87 @@ func (w *Writer) RawText(data []byte, err error) { } } +// JsonNumber appends a json.Number to the buffer as a raw JSON number. +// Mirroring encoding/json, the empty value is encoded as 0 and an invalid +// number literal sets the writer error. +func (w *Writer) JsonNumber(n json.Number) { + if w.Error != nil { + return + } + s := string(n) + if s == "" { + // encoding/json encodes the empty Number as 0. + w.Buffer.AppendString("0") + return + } + if !isValidNumber(s) { + w.Error = fmt.Errorf("invalid number literal %q", s) + return + } + w.Buffer.AppendString(s) +} + +// isValidNumber reports whether s is a valid JSON number literal, +// per the RFC 8259 grammar. Taken from encoding/json. +func isValidNumber(s string) bool { + // This function implements the JSON numbers grammar. + // See https://tools.ietf.org/html/rfc8259#section-6 + // and https://www.json.org/img/number.png + + if s == "" { + return false + } + + // Optional - + if s[0] == '-' { + s = s[1:] + if s == "" { + return false + } + } + + // Digits + switch { + default: + return false + + case s[0] == '0': + s = s[1:] + + case '1' <= s[0] && s[0] <= '9': + s = s[1:] + for len(s) > 0 && '0' <= s[0] && s[0] <= '9' { + s = s[1:] + } + } + + // . followed by 1 or more digits. + if len(s) >= 2 && s[0] == '.' && '0' <= s[1] && s[1] <= '9' { + s = s[2:] + for len(s) > 0 && '0' <= s[0] && s[0] <= '9' { + s = s[1:] + } + } + + // e or E followed by an optional - or + and + // 1 or more digits. + if len(s) >= 2 && (s[0] == 'e' || s[0] == 'E') { + s = s[1:] + if s[0] == '+' || s[0] == '-' { + s = s[1:] + if s == "" { + return false + } + } + for len(s) > 0 && '0' <= s[0] && s[0] <= '9' { + s = s[1:] + } + } + + // Make sure we are at the end. + return s == "" +} + // Base64Bytes appends data to the buffer after base64 encoding it func (w *Writer) Base64Bytes(data []byte) { if data == nil { diff --git a/jwriter/writer_test.go b/jwriter/writer_test.go new file mode 100644 index 0000000..520f609 --- /dev/null +++ b/jwriter/writer_test.go @@ -0,0 +1,41 @@ +package jwriter + +import ( + "encoding/json" + "testing" +) + +func TestJsonNumber(t *testing.T) { + for _, test := range []struct { + number json.Number + want string + wantErr bool + }{ + {number: "123", want: "123"}, + {number: "-0.5", want: "-0.5"}, + {number: "10.00", want: "10.00"}, + {number: "25E-4", want: "25E-4"}, + // encoding/json encodes the empty Number as 0. + {number: "", want: "0"}, + // Invalid literals set the writer error, like encoding/json errors. + {number: "abc", wantErr: true}, + {number: "1.", wantErr: true}, + {number: "-", wantErr: true}, + } { + var w Writer + w.JsonNumber(test.number) + if test.wantErr { + if w.Error == nil { + t.Errorf("JsonNumber(%q): expected error, got none", test.number) + } + continue + } + if w.Error != nil { + t.Errorf("JsonNumber(%q): unexpected error: %v", test.number, w.Error) + continue + } + if got := string(w.Buffer.BuildBytes()); got != test.want { + t.Errorf("JsonNumber(%q) = %s; want %s", test.number, got, test.want) + } + } +} diff --git a/tests/basic_test.go b/tests/basic_test.go index cdc7e8a..61332c4 100644 --- a/tests/basic_test.go +++ b/tests/basic_test.go @@ -42,6 +42,7 @@ var testCases = []struct { {&IntsValue, IntsString}, {&mapStringStringValue, mapStringStringString}, {&namedTypeValue, namedTypeValueString}, + {&jsonNumberValue, jsonNumberString}, {&customMapKeyTypeValue, customMapKeyTypeValueString}, {&embeddedTypeValue, embeddedTypeValueString}, {&mapMyIntStringValue, mapMyIntStringValueString}, diff --git a/tests/json_number.go b/tests/json_number.go new file mode 100644 index 0000000..6310958 --- /dev/null +++ b/tests/json_number.go @@ -0,0 +1,18 @@ +package tests + +import "encoding/json" + +//easyjson:json +type JSONNumberStruct struct { + Int json.Number `json:"int"` + Float json.Number `json:"float"` +} + +var jsonNumberValue = JSONNumberStruct{ + Int: json.Number("123"), + Float: json.Number("10.00"), +} + +// json.Number must be encoded as a raw JSON number, not a quoted string, +// matching encoding/json (see issue #220). +var jsonNumberString = `{"int":123,"float":10.00}`