diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 237a29b4..371d2719 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: build run: docker compose run go-json @@ -19,15 +19,15 @@ jobs: strategy: matrix: os: [ "ubuntu-latest", "macos-latest", "windows-latest" ] - go-version: [ "1.19", "1.20", "1.21" ] + go-version: [ "1.24", "oldstable", "stable" ] runs-on: ${{ matrix.os }} steps: - name: setup Go ${{ matrix.go-version }} - uses: actions/setup-go@v3 + uses: actions/setup-go@v6 with: go-version: ${{ matrix.go-version }} - name: checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: simple test run: go test -v ./... -count=1 - name: test with GC pressure @@ -42,11 +42,11 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: setup Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v6 with: - go-version: '1.21' + go-version: 'stable' - name: lint run: | make lint @@ -55,17 +55,17 @@ jobs: runs-on: ubuntu-latest steps: - name: setup Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v6 with: - go-version: '1.21' + go-version: 'stable' - name: checkout ( feature ) - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: run benchmark ( feature ) run: cd benchmarks && go test -bench GoJson | tee $HOME/new.txt - name: install benchstat run: go install golang.org/x/perf/cmd/benchstat@latest - name: checkout ( master ) - uses: actions/checkout@v3 + uses: actions/checkout@v6 with: ref: master - name: run benchmark ( master ) @@ -78,14 +78,14 @@ jobs: runs-on: ubuntu-latest steps: - name: checkout - uses: actions/checkout@v3 + uses: actions/checkout@v6 - name: setup Go - uses: actions/setup-go@v3 + uses: actions/setup-go@v6 with: - go-version: '1.21' + go-version: 'stable' - name: measure coverage run: make cover - - uses: codecov/codecov-action@v4 + - uses: codecov/codecov-action@v5 with: fail_ci_if_error: true verbose: true diff --git a/benchmarks/go.mod b/benchmarks/go.mod index 6fd858ef..02a048fd 100644 --- a/benchmarks/go.mod +++ b/benchmarks/go.mod @@ -1,6 +1,6 @@ module benchmark -go 1.19 +go 1.24 require ( github.com/francoispqt/gojay v1.2.13 diff --git a/encode_test.go b/encode_test.go index e71a9992..dc93dcdf 100644 --- a/encode_test.go +++ b/encode_test.go @@ -991,11 +991,11 @@ func TestMarshalerError(t *testing.T) { want string }{ { - json.NewMarshalerError(st, fmt.Errorf(errText), ""), + json.NewMarshalerError(st, fmt.Errorf("%s", errText), ""), "json: error calling MarshalJSON for type " + st.String() + ": " + errText, }, { - json.NewMarshalerError(st, fmt.Errorf(errText), "TestMarshalerError"), + json.NewMarshalerError(st, fmt.Errorf("%s", errText), "TestMarshalerError"), "json: error calling TestMarshalerError for type " + st.String() + ": " + errText, }, } diff --git a/go.mod b/go.mod index f5b57ff7..f543e3ac 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ module github.com/goccy/go-json -go 1.19 +go 1.24 diff --git a/internal/cmd/generator/main.go b/internal/cmd/generator/main.go index 326f5273..9af7633b 100644 --- a/internal/cmd/generator/main.go +++ b/internal/cmd/generator/main.go @@ -256,6 +256,11 @@ func (t OpType) FieldToOmitEmptyField() OpType { }) } } + opTypes = append(opTypes, + createOpType("StructHeadOmitZero", "StructField"), + createOpType("StructFieldOmitZero", "StructField"), + createOpType("StructPtrHeadOmitZero", "StructField"), + ) var b bytes.Buffer if err := tmpl.Execute(&b, struct { CodeTypes []string diff --git a/internal/cmd/generator/vm.go.tmpl b/internal/cmd/generator/vm.go.tmpl index 645d20f9..5740cdd1 100644 --- a/internal/cmd/generator/vm.go.tmpl +++ b/internal/cmd/generator/vm.go.tmpl @@ -589,6 +589,65 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b code = code.Next store(ctxptr, code.Idx, p) } + case encoder.OpStructPtrHeadOmitZero: + p := load(ctxptr, code.Idx) + if p == 0 { + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendNullComma(ctx, b) + } + code = code.End.Next + break + } + store(ctxptr, code.Idx, ptrToNPtr(p, code.PtrNum)) + fallthrough + case encoder.OpStructHeadOmitZero: + p := load(ctxptr, code.Idx) + if p == 0 && ((code.Flags&encoder.IndirectFlags) != 0 || code.Next.Op == encoder.OpStructEnd) { + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendNullComma(ctx, b) + } + code = code.End.Next + break + } + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendStructHead(ctx, b) + } + if (code.Flags & encoder.IndirectFlags) != 0 { + // the slot holds the struct base + p += uintptr(code.Offset) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + if code.Type.Kind() == reflect.Map { + // map opcodes take the map value itself, not its address + p = ptrToPtr(p) + } + code = code.Next + store(ctxptr, code.Idx, p) + } + } else { + // direct interface: the slot holds the field value itself, so the + // slot is the field's storage for every kind but map + if code.Type.Kind() == reflect.Map { + if p == 0 { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + code = code.Next + store(ctxptr, code.Idx, p) + } + } else { + p = ctxptr + uintptr(code.Idx) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + code = code.Next + store(ctxptr, code.Idx, p) + } + } + } case encoder.OpStructPtrHeadInt: if (code.Flags & encoder.IndirectFlags) != 0 { p := load(ctxptr, code.Idx) @@ -3274,6 +3333,20 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b code = code.Next store(ctxptr, code.Idx, p) } + case encoder.OpStructFieldOmitZero: + p := load(ctxptr, code.Idx) + p += uintptr(code.Offset) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + if code.Type.Kind() == reflect.Map { + // map opcodes take the map value itself, not its address + p = ptrToPtr(p) + } + code = code.Next + store(ctxptr, code.Idx, p) + } case encoder.OpStructFieldInt: p := load(ctxptr, code.Idx) b = appendStructKey(ctx, code, b) diff --git a/internal/encoder/code.go b/internal/encoder/code.go index fec45a4b..6d4f3561 100644 --- a/internal/encoder/code.go +++ b/internal/encoder/code.go @@ -1,7 +1,6 @@ package encoder import ( - "fmt" "reflect" "unsafe" @@ -633,6 +632,9 @@ func (c *StructFieldCode) getAnonymousStruct() *StructCode { } func optimizeStructHeader(code *Opcode, tag *runtime.StructTag) OpType { + if tag.IsOmitZero { + return OpStructHeadOmitZero + } headType := code.ToHeaderType(tag.IsString) if tag.IsOmitEmpty { headType = headType.HeadToOmitEmptyHead() @@ -641,6 +643,9 @@ func optimizeStructHeader(code *Opcode, tag *runtime.StructTag) OpType { } func optimizeStructField(code *Opcode, tag *runtime.StructTag) OpType { + if tag.IsOmitZero { + return OpStructFieldOmitZero + } fieldType := code.ToFieldType(tag.IsString) if tag.IsOmitEmpty { fieldType = fieldType.FieldToOmitEmptyField() @@ -714,9 +719,9 @@ func (c *StructFieldCode) addStructEndCode(ctx *compileContext, codes Opcodes) O func (c *StructFieldCode) structKey(ctx *compileContext) string { if ctx.escapeKey { rctx := &RuntimeContext{Option: &Option{Flag: HTMLEscapeOption}} - return fmt.Sprintf(`%s:`, string(AppendString(rctx, []byte{}, c.key))) + return string(AppendString(rctx, []byte{}, c.key)) + ":" } - return fmt.Sprintf(`"%s":`, c.key) + return `"` + c.key + `":` } func (c *StructFieldCode) flags() OpFlags { @@ -777,7 +782,7 @@ func (c *StructFieldCode) ToOpcode(ctx *compileContext, isFirstField, isEndField } codes := c.fieldOpcodes(ctx, field, valueCodes) if isEndField { - if isEnableStructEndOptimization(c.value) { + if !c.tag.IsOmitZero && isEnableStructEndOptimization(c.value) { field.Op = field.Op.FieldToEnd() } else { codes = c.addStructEndCode(ctx, codes) diff --git a/internal/encoder/encoder.go b/internal/encoder/encoder.go index b436f5b2..ecd60825 100644 --- a/internal/encoder/encoder.go +++ b/internal/encoder/encoder.go @@ -39,6 +39,8 @@ func (t OpType) IsMultipleOpHead() bool { return true case OpStructHeadOmitEmptyStruct: return true + case OpStructHeadOmitZero: + return true case OpStructHeadSlicePtr: return true case OpStructHeadOmitEmptySlicePtr: @@ -77,6 +79,8 @@ func (t OpType) IsMultipleOpField() bool { return true case OpStructFieldOmitEmptyStruct: return true + case OpStructFieldOmitZero: + return true case OpStructFieldSlicePtr: return true case OpStructFieldOmitEmptySlicePtr: diff --git a/internal/encoder/omitzero.go b/internal/encoder/omitzero.go new file mode 100644 index 00000000..1aa7d746 --- /dev/null +++ b/internal/encoder/omitzero.go @@ -0,0 +1,108 @@ +package encoder + +import ( + "reflect" + "unsafe" + + "github.com/goccy/go-json/internal/runtime" +) + +// IsZeroer is the interface used to check custom zero values. +type IsZeroer interface { + IsZero() bool +} + +// IsZeroForOmitZero determines if a value should be omitted when using the omitzero tag. +// It checks if a value is zero (default/empty for its type). +// +// Values are read with the *(*T)(unsafe.Pointer(&ptr)) double-deref idiom used +// throughout the VM (see internal/encoder/vm/util.go): it never converts a +// uintptr to unsafe.Pointer directly, so it passes go vet's unsafeptr check +// and the race detector's checkptr validation. +func IsZeroForOmitZero(code *Opcode, ptr uintptr) bool { + if ptr == 0 { + return true + } + + kind := code.Type.Kind() + switch kind { + // Pointer types: zero only if nil + case reflect.Ptr, reflect.Slice, reflect.Map, reflect.Chan, reflect.Interface: + // These types use a pointer as their first field + p := **(**unsafe.Pointer)(unsafe.Pointer(&ptr)) + return p == nil + + // Boolean type + case reflect.Bool: + return !**(**bool)(unsafe.Pointer(&ptr)) + + // Signed integer types + case reflect.Int: + return **(**int)(unsafe.Pointer(&ptr)) == 0 + case reflect.Int8: + return **(**int8)(unsafe.Pointer(&ptr)) == 0 + case reflect.Int16: + return **(**int16)(unsafe.Pointer(&ptr)) == 0 + case reflect.Int32: + return **(**int32)(unsafe.Pointer(&ptr)) == 0 + case reflect.Int64: + return **(**int64)(unsafe.Pointer(&ptr)) == 0 + + // Unsigned integer types + case reflect.Uint: + return **(**uint)(unsafe.Pointer(&ptr)) == 0 + case reflect.Uint8: + return **(**uint8)(unsafe.Pointer(&ptr)) == 0 + case reflect.Uint16: + return **(**uint16)(unsafe.Pointer(&ptr)) == 0 + case reflect.Uint32: + return **(**uint32)(unsafe.Pointer(&ptr)) == 0 + case reflect.Uint64: + return **(**uint64)(unsafe.Pointer(&ptr)) == 0 + case reflect.Uintptr: + return **(**uintptr)(unsafe.Pointer(&ptr)) == 0 + + // Floating point types + case reflect.Float32: + return **(**float32)(unsafe.Pointer(&ptr)) == 0 + case reflect.Float64: + return **(**float64)(unsafe.Pointer(&ptr)) == 0 + + // Complex types + case reflect.Complex64: + return **(**complex64)(unsafe.Pointer(&ptr)) == 0 + case reflect.Complex128: + return **(**complex128)(unsafe.Pointer(&ptr)) == 0 + + // String type + case reflect.String: + s := **(**string)(unsafe.Pointer(&ptr)) + return len(s) == 0 + + // For complex types (Array, Struct), fall back to reflect, which honors + // a custom IsZero() method (e.g. time.Time) + default: + rt := runtime.RType2Type(code.Type) + rv := reflect.NewAt(rt, *(*unsafe.Pointer)(unsafe.Pointer(&ptr))).Elem() + return isZeroValue(rv) + } +} + +// isZeroValue checks if a reflect.Value is zero, including support for custom IsZero methods +func isZeroValue(rv reflect.Value) bool { + // Check for custom IsZero implementation first + if rv.CanInterface() { + if iz, ok := rv.Interface().(IsZeroer); ok { + return iz.IsZero() + } + } + + // Use reflect's built-in IsZero + // This correctly handles: + // - nil pointers (IsZero() == true) + // - non-nil pointers to zero values (IsZero() == false, since pointer is not nil) + // - nil slices/maps/channels (IsZero() == true) + // - empty non-nil slices/maps (IsZero() == false, since they're not nil) + // - zero primitives like 0, "", false (IsZero() == true) + return rv.IsZero() +} diff --git a/internal/encoder/optype.go b/internal/encoder/optype.go index 5c1241b4..0734f3aa 100644 --- a/internal/encoder/optype.go +++ b/internal/encoder/optype.go @@ -22,7 +22,7 @@ const ( CodeStructEnd CodeType = 11 ) -var opTypeStrings = [400]string{ +var opTypeStrings = [403]string{ "End", "Interface", "Ptr", @@ -423,6 +423,9 @@ var opTypeStrings = [400]string{ "StructFieldOmitEmpty", "StructEnd", "StructEndOmitEmpty", + "StructHeadOmitZero", + "StructFieldOmitZero", + "StructPtrHeadOmitZero", } type OpType uint16 @@ -828,10 +831,13 @@ const ( OpStructFieldOmitEmpty OpType = 397 OpStructEnd OpType = 398 OpStructEndOmitEmpty OpType = 399 + OpStructHeadOmitZero OpType = 400 + OpStructFieldOmitZero OpType = 401 + OpStructPtrHeadOmitZero OpType = 402 ) func (t OpType) String() string { - if int(t) >= 400 { + if int(t) >= 403 { return "" } return opTypeStrings[int(t)] diff --git a/internal/encoder/query.go b/internal/encoder/query.go index 1e1850cc..491addc3 100644 --- a/internal/encoder/query.go +++ b/internal/encoder/query.go @@ -2,7 +2,7 @@ package encoder import ( "context" - "fmt" + "errors" "reflect" ) @@ -65,7 +65,7 @@ func (s FieldQueryString) build(v reflect.Value) (*FieldQuery, error) { case reflect.Interface: return s.build(reflect.ValueOf(v.Interface())) } - return nil, fmt.Errorf("failed to build field query") + return nil, errors.New("failed to build field query") } func (s FieldQueryString) buildString(v reflect.Value) (*FieldQuery, error) { @@ -99,11 +99,11 @@ func (s FieldQueryString) buildSlice(v reflect.Value) (*FieldQuery, error) { func (s FieldQueryString) buildMap(v reflect.Value) (*FieldQuery, error) { keys := v.MapKeys() if len(keys) != 1 { - return nil, fmt.Errorf("failed to build field query object") + return nil, errors.New("failed to build field query object") } key := keys[0] if key.Type().Kind() != reflect.String { - return nil, fmt.Errorf("failed to build field query. invalid object key type") + return nil, errors.New("failed to build field query. invalid object key type") } name := key.String() def, err := s.build(v.MapIndex(key)) diff --git a/internal/encoder/string.go b/internal/encoder/string.go index 4abb8416..aa3e27a3 100644 --- a/internal/encoder/string.go +++ b/internal/encoder/string.go @@ -26,7 +26,6 @@ package encoder import ( "math/bits" - "reflect" "unsafe" ) @@ -37,13 +36,8 @@ const ( var hex = "0123456789abcdef" -//nolint:govet func stringToUint64Slice(s string) []uint64 { - return *(*[]uint64)(unsafe.Pointer(&reflect.SliceHeader{ - Data: ((*reflect.StringHeader)(unsafe.Pointer(&s))).Data, - Len: len(s) / 8, - Cap: len(s) / 8, - })) + return unsafe.Slice((*uint64)(unsafe.Pointer(unsafe.StringData(s))), len(s)/8) } func AppendString(ctx *RuntimeContext, buf []byte, s string) []byte { diff --git a/internal/encoder/vm/vm.go b/internal/encoder/vm/vm.go index 645d20f9..5740cdd1 100644 --- a/internal/encoder/vm/vm.go +++ b/internal/encoder/vm/vm.go @@ -589,6 +589,65 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b code = code.Next store(ctxptr, code.Idx, p) } + case encoder.OpStructPtrHeadOmitZero: + p := load(ctxptr, code.Idx) + if p == 0 { + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendNullComma(ctx, b) + } + code = code.End.Next + break + } + store(ctxptr, code.Idx, ptrToNPtr(p, code.PtrNum)) + fallthrough + case encoder.OpStructHeadOmitZero: + p := load(ctxptr, code.Idx) + if p == 0 && ((code.Flags&encoder.IndirectFlags) != 0 || code.Next.Op == encoder.OpStructEnd) { + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendNullComma(ctx, b) + } + code = code.End.Next + break + } + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendStructHead(ctx, b) + } + if (code.Flags & encoder.IndirectFlags) != 0 { + // the slot holds the struct base + p += uintptr(code.Offset) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + if code.Type.Kind() == reflect.Map { + // map opcodes take the map value itself, not its address + p = ptrToPtr(p) + } + code = code.Next + store(ctxptr, code.Idx, p) + } + } else { + // direct interface: the slot holds the field value itself, so the + // slot is the field's storage for every kind but map + if code.Type.Kind() == reflect.Map { + if p == 0 { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + code = code.Next + store(ctxptr, code.Idx, p) + } + } else { + p = ctxptr + uintptr(code.Idx) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + code = code.Next + store(ctxptr, code.Idx, p) + } + } + } case encoder.OpStructPtrHeadInt: if (code.Flags & encoder.IndirectFlags) != 0 { p := load(ctxptr, code.Idx) @@ -3274,6 +3333,20 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b code = code.Next store(ctxptr, code.Idx, p) } + case encoder.OpStructFieldOmitZero: + p := load(ctxptr, code.Idx) + p += uintptr(code.Offset) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + if code.Type.Kind() == reflect.Map { + // map opcodes take the map value itself, not its address + p = ptrToPtr(p) + } + code = code.Next + store(ctxptr, code.Idx, p) + } case encoder.OpStructFieldInt: p := load(ctxptr, code.Idx) b = appendStructKey(ctx, code, b) diff --git a/internal/encoder/vm_color/vm.go b/internal/encoder/vm_color/vm.go index a63e83e5..510269f8 100644 --- a/internal/encoder/vm_color/vm.go +++ b/internal/encoder/vm_color/vm.go @@ -589,6 +589,65 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b code = code.Next store(ctxptr, code.Idx, p) } + case encoder.OpStructPtrHeadOmitZero: + p := load(ctxptr, code.Idx) + if p == 0 { + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendNullComma(ctx, b) + } + code = code.End.Next + break + } + store(ctxptr, code.Idx, ptrToNPtr(p, code.PtrNum)) + fallthrough + case encoder.OpStructHeadOmitZero: + p := load(ctxptr, code.Idx) + if p == 0 && ((code.Flags&encoder.IndirectFlags) != 0 || code.Next.Op == encoder.OpStructEnd) { + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendNullComma(ctx, b) + } + code = code.End.Next + break + } + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendStructHead(ctx, b) + } + if (code.Flags & encoder.IndirectFlags) != 0 { + // the slot holds the struct base + p += uintptr(code.Offset) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + if code.Type.Kind() == reflect.Map { + // map opcodes take the map value itself, not its address + p = ptrToPtr(p) + } + code = code.Next + store(ctxptr, code.Idx, p) + } + } else { + // direct interface: the slot holds the field value itself, so the + // slot is the field's storage for every kind but map + if code.Type.Kind() == reflect.Map { + if p == 0 { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + code = code.Next + store(ctxptr, code.Idx, p) + } + } else { + p = ctxptr + uintptr(code.Idx) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + code = code.Next + store(ctxptr, code.Idx, p) + } + } + } case encoder.OpStructPtrHeadInt: if (code.Flags & encoder.IndirectFlags) != 0 { p := load(ctxptr, code.Idx) @@ -3274,6 +3333,20 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b code = code.Next store(ctxptr, code.Idx, p) } + case encoder.OpStructFieldOmitZero: + p := load(ctxptr, code.Idx) + p += uintptr(code.Offset) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + if code.Type.Kind() == reflect.Map { + // map opcodes take the map value itself, not its address + p = ptrToPtr(p) + } + code = code.Next + store(ctxptr, code.Idx, p) + } case encoder.OpStructFieldInt: p := load(ctxptr, code.Idx) b = appendStructKey(ctx, code, b) diff --git a/internal/encoder/vm_color_indent/vm.go b/internal/encoder/vm_color_indent/vm.go index 3b4e22e5..e103aa17 100644 --- a/internal/encoder/vm_color_indent/vm.go +++ b/internal/encoder/vm_color_indent/vm.go @@ -589,6 +589,65 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b code = code.Next store(ctxptr, code.Idx, p) } + case encoder.OpStructPtrHeadOmitZero: + p := load(ctxptr, code.Idx) + if p == 0 { + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendNullComma(ctx, b) + } + code = code.End.Next + break + } + store(ctxptr, code.Idx, ptrToNPtr(p, code.PtrNum)) + fallthrough + case encoder.OpStructHeadOmitZero: + p := load(ctxptr, code.Idx) + if p == 0 && ((code.Flags&encoder.IndirectFlags) != 0 || code.Next.Op == encoder.OpStructEnd) { + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendNullComma(ctx, b) + } + code = code.End.Next + break + } + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendStructHead(ctx, b) + } + if (code.Flags & encoder.IndirectFlags) != 0 { + // the slot holds the struct base + p += uintptr(code.Offset) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + if code.Type.Kind() == reflect.Map { + // map opcodes take the map value itself, not its address + p = ptrToPtr(p) + } + code = code.Next + store(ctxptr, code.Idx, p) + } + } else { + // direct interface: the slot holds the field value itself, so the + // slot is the field's storage for every kind but map + if code.Type.Kind() == reflect.Map { + if p == 0 { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + code = code.Next + store(ctxptr, code.Idx, p) + } + } else { + p = ctxptr + uintptr(code.Idx) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + code = code.Next + store(ctxptr, code.Idx, p) + } + } + } case encoder.OpStructPtrHeadInt: if (code.Flags & encoder.IndirectFlags) != 0 { p := load(ctxptr, code.Idx) @@ -3274,6 +3333,20 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b code = code.Next store(ctxptr, code.Idx, p) } + case encoder.OpStructFieldOmitZero: + p := load(ctxptr, code.Idx) + p += uintptr(code.Offset) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + if code.Type.Kind() == reflect.Map { + // map opcodes take the map value itself, not its address + p = ptrToPtr(p) + } + code = code.Next + store(ctxptr, code.Idx, p) + } case encoder.OpStructFieldInt: p := load(ctxptr, code.Idx) b = appendStructKey(ctx, code, b) diff --git a/internal/encoder/vm_indent/vm.go b/internal/encoder/vm_indent/vm.go index 836c5c8a..4fc8aaeb 100644 --- a/internal/encoder/vm_indent/vm.go +++ b/internal/encoder/vm_indent/vm.go @@ -589,6 +589,65 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b code = code.Next store(ctxptr, code.Idx, p) } + case encoder.OpStructPtrHeadOmitZero: + p := load(ctxptr, code.Idx) + if p == 0 { + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendNullComma(ctx, b) + } + code = code.End.Next + break + } + store(ctxptr, code.Idx, ptrToNPtr(p, code.PtrNum)) + fallthrough + case encoder.OpStructHeadOmitZero: + p := load(ctxptr, code.Idx) + if p == 0 && ((code.Flags&encoder.IndirectFlags) != 0 || code.Next.Op == encoder.OpStructEnd) { + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendNullComma(ctx, b) + } + code = code.End.Next + break + } + if code.Flags&encoder.AnonymousHeadFlags == 0 { + b = appendStructHead(ctx, b) + } + if (code.Flags & encoder.IndirectFlags) != 0 { + // the slot holds the struct base + p += uintptr(code.Offset) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + if code.Type.Kind() == reflect.Map { + // map opcodes take the map value itself, not its address + p = ptrToPtr(p) + } + code = code.Next + store(ctxptr, code.Idx, p) + } + } else { + // direct interface: the slot holds the field value itself, so the + // slot is the field's storage for every kind but map + if code.Type.Kind() == reflect.Map { + if p == 0 { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + code = code.Next + store(ctxptr, code.Idx, p) + } + } else { + p = ctxptr + uintptr(code.Idx) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + code = code.Next + store(ctxptr, code.Idx, p) + } + } + } case encoder.OpStructPtrHeadInt: if (code.Flags & encoder.IndirectFlags) != 0 { p := load(ctxptr, code.Idx) @@ -3274,6 +3333,20 @@ func Run(ctx *encoder.RuntimeContext, b []byte, codeSet *encoder.OpcodeSet) ([]b code = code.Next store(ctxptr, code.Idx, p) } + case encoder.OpStructFieldOmitZero: + p := load(ctxptr, code.Idx) + p += uintptr(code.Offset) + if encoder.IsZeroForOmitZero(code, p) { + code = code.NextField + } else { + b = appendStructKey(ctx, code, b) + if code.Type.Kind() == reflect.Map { + // map opcodes take the map value itself, not its address + p = ptrToPtr(p) + } + code = code.Next + store(ctxptr, code.Idx, p) + } case encoder.OpStructFieldInt: p := load(ctxptr, code.Idx) b = appendStructKey(ctx, code, b) diff --git a/internal/errors/error.go b/internal/errors/error.go index 9207d0ff..06bf47fe 100644 --- a/internal/errors/error.go +++ b/internal/errors/error.go @@ -11,7 +11,7 @@ type InvalidUTF8Error struct { } func (e *InvalidUTF8Error) Error() string { - return fmt.Sprintf("json: invalid UTF-8 in string: %s", strconv.Quote(e.S)) + return "json: invalid UTF-8 in string: " + strconv.Quote(e.S) } type InvalidUnmarshalError struct { @@ -106,7 +106,7 @@ type UnsupportedValueError struct { } func (e *UnsupportedValueError) Error() string { - return fmt.Sprintf("json: unsupported value: %s", e.Str) + return "json: unsupported value: " + e.Str } func ErrSyntax(msg string, offset int64) *SyntaxError { @@ -140,13 +140,13 @@ func ErrUnexpectedEndOfJSON(msg string, cursor int64) *SyntaxError { } func ErrExpected(msg string, cursor int64) *SyntaxError { - return &SyntaxError{msg: fmt.Sprintf("expected %s", msg), Offset: cursor} + return &SyntaxError{msg: "expected " + msg, Offset: cursor} } func ErrInvalidCharacter(c byte, context string, cursor int64) *SyntaxError { if c == 0 { return &SyntaxError{ - msg: fmt.Sprintf("json: invalid character as %s", context), + msg: "json: invalid character as " + context, Offset: cursor, } } @@ -168,7 +168,7 @@ type PathError struct { } func (e *PathError) Error() string { - return fmt.Sprintf("json: invalid path format: %s", e.msg) + return "json: invalid path format: " + e.msg } func ErrInvalidPath(msg string, args ...interface{}) *PathError { diff --git a/internal/runtime/struct_field.go b/internal/runtime/struct_field.go index baab0c59..6f6d6848 100644 --- a/internal/runtime/struct_field.go +++ b/internal/runtime/struct_field.go @@ -33,6 +33,7 @@ type StructTag struct { Key string IsTaggedKey bool IsOmitEmpty bool + IsOmitZero bool IsString bool Field reflect.StructField } @@ -82,6 +83,8 @@ func StructTagFromField(field reflect.StructField) *StructTag { switch opt { case "omitempty": st.IsOmitEmpty = true + case "omitzero": + st.IsOmitZero = true case "string": st.IsString = true } diff --git a/internal/runtime/type.go b/internal/runtime/type.go index 4b693cb0..5765ce4e 100644 --- a/internal/runtime/type.go +++ b/internal/runtime/type.go @@ -46,33 +46,33 @@ func AnalyzeTypeAddr() *TypeAddr { section := sections[0] offset := offsets[0] var ( - min uintptr = uintptr(^uint(0)) - max uintptr = 0 + minAddr uintptr = uintptr(^uint(0)) + maxAddr uintptr = 0 isAligned64 = true isAligned32 = true ) - for i := 0; i < len(offset); i++ { + for i := range offset { typ := (*Type)(rtypeOff(section, offset[i])) addr := uintptr(unsafe.Pointer(typ)) - if min > addr { - min = addr + if minAddr > addr { + minAddr = addr } - if max < addr { - max = addr + if maxAddr < addr { + maxAddr = addr } if typ.Kind() == reflect.Ptr { addr = uintptr(unsafe.Pointer(typ.Elem())) - if min > addr { - min = addr + if minAddr > addr { + minAddr = addr } - if max < addr { - max = addr + if maxAddr < addr { + maxAddr = addr } } - isAligned64 = isAligned64 && (addr-min)&63 == 0 - isAligned32 = isAligned32 && (addr-min)&31 == 0 + isAligned64 = isAligned64 && (addr-minAddr)&63 == 0 + isAligned32 = isAligned32 && (addr-minAddr)&31 == 0 } - addrRange := max - min + addrRange := maxAddr - minAddr if addrRange == 0 { return } @@ -87,8 +87,8 @@ func AnalyzeTypeAddr() *TypeAddr { return } typeAddr = &TypeAddr{ - BaseTypeAddr: min, - MaxTypeAddr: max, + BaseTypeAddr: minAddr, + MaxTypeAddr: maxAddr, AddrRange: addrRange, AddrShift: addrShift, } diff --git a/omitzero_test.go b/omitzero_test.go new file mode 100644 index 00000000..51096376 --- /dev/null +++ b/omitzero_test.go @@ -0,0 +1,501 @@ +package json + +import ( + "bytes" + "testing" + "time" +) + +// TestOmitZero tests the omitzero struct tag functionality +func TestOmitZero(t *testing.T) { + tests := []struct { + name string + value interface{} + expected string + }{ + // Basic primitive fields - omitted when zero + { + name: "int zero", + value: struct { + X int `json:"x,omitzero"` + }{X: 0}, + expected: "{}", + }, + { + name: "int non-zero", + value: struct { + X int `json:"x,omitzero"` + }{X: 42}, + expected: `{"x":42}`, + }, + { + name: "string zero", + value: struct { + X string `json:"x,omitzero"` + }{X: ""}, + expected: "{}", + }, + { + name: "string non-zero", + value: struct { + X string `json:"x,omitzero"` + }{X: "hello"}, + expected: `{"x":"hello"}`, + }, + { + name: "bool zero", + value: struct { + X bool `json:"x,omitzero"` + }{X: false}, + expected: "{}", + }, + { + name: "bool non-zero", + value: struct { + X bool `json:"x,omitzero"` + }{X: true}, + expected: `{"x":true}`, + }, + { + name: "float64 zero", + value: struct { + X float64 `json:"x,omitzero"` + }{X: 0.0}, + expected: "{}", + }, + { + name: "float64 non-zero", + value: struct { + X float64 `json:"x,omitzero"` + }{X: 3.14}, + expected: `{"x":3.14}`, + }, + + // Pointer fields - nil is zero, but non-nil pointing to zero is NOT zero + { + name: "pointer nil", + value: struct { + X *int `json:"x,omitzero"` + Y int `json:"y,omitzero"` + }{X: nil, Y: 0}, + expected: "{}", + }, + { + name: "pointer to zero", + value: struct { + X *int `json:"x,omitzero"` + Y int `json:"y,omitzero"` + }{X: intPtr(0), Y: 0}, + expected: `{"x":0}`, + }, + { + name: "pointer to non-zero", + value: struct { + X *int `json:"x,omitzero"` + Y int `json:"y,omitzero"` + }{X: intPtr(42), Y: 0}, + expected: `{"x":42}`, + }, + + // Slice fields - nil is zero, but empty slice is NOT zero (key difference from omitempty) + { + name: "slice nil", + value: struct { + X []int `json:"x,omitzero"` + Y int `json:"y,omitzero"` + }{X: nil, Y: 0}, + expected: "{}", + }, + { + name: "slice empty", + value: struct { + X []int `json:"x,omitzero"` + Y int `json:"y,omitzero"` + }{X: []int{}, Y: 0}, + expected: `{"x":[]}`, + }, + { + name: "slice with values", + value: struct { + X []int `json:"x,omitzero"` + Y int `json:"y,omitzero"` + }{X: []int{1, 2, 3}, Y: 0}, + expected: `{"x":[1,2,3]}`, + }, + + // Comparison: omitempty vs omitzero for empty slice + { + name: "omitempty with empty slice", + value: struct { + X []int `json:"x,omitempty"` + Y int `json:"y,omitempty"` + }{X: []int{}, Y: 0}, + expected: "{}", + }, + { + name: "omitzero with empty slice", + value: struct { + X []int `json:"x,omitzero"` + Y int `json:"y,omitzero"` + }{X: []int{}, Y: 0}, + expected: `{"x":[]}`, + }, + + // Mixed tags + { + name: "mixed omitempty and omitzero", + value: struct { + A int `json:"a,omitempty"` + B int `json:"b,omitzero"` + C []int `json:"c,omitempty"` + D []int `json:"d,omitzero"` + }{ + A: 0, + B: 0, + C: []int{}, + D: []int{}, + }, + expected: `{"d":[]}`, + }, + + // Multiple fields with omitzero + { + name: "multiple fields omitzero", + value: struct { + X int `json:"x,omitzero"` + Y string `json:"y,omitzero"` + Z bool `json:"z,omitzero"` + }{ + X: 0, + Y: "", + Z: false, + }, + expected: "{}", + }, + { + name: "multiple fields omitzero mixed", + value: struct { + X int `json:"x,omitzero"` + Y string `json:"y,omitzero"` + Z bool `json:"z,omitzero"` + }{ + X: 42, + Y: "hello", + Z: true, + }, + expected: `{"x":42,"y":"hello","z":true}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := Marshal(tt.value) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + if !bytes.Equal(b, []byte(tt.expected)) { + t.Errorf("expected %s, got %s", tt.expected, string(b)) + } + }) + } +} + +// TestOmitZeroUnmarshal tests that unmarshal still works correctly with omitzero fields +func TestOmitZeroUnmarshal(t *testing.T) { + tests := []struct { + name string + input string + expected interface{} + }{ + { + name: "unmarshal with omitzero field", + input: `{"x":42}`, + expected: struct { + X int `json:"x,omitzero"` + }{X: 42}, + }, + { + name: "unmarshal omitted field", + input: `{}`, + expected: struct { + X int `json:"x,omitzero"` + }{X: 0}, + }, + { + name: "unmarshal empty slice", + input: `{"x":[]}`, + expected: struct { + X []int `json:"x,omitzero"` + }{X: []int{}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a new instance of the expected type + var result interface{} + switch exp := tt.expected.(type) { + case struct { + X int `json:"x,omitzero"` + }: + var r struct { + X int `json:"x,omitzero"` + } + if err := Unmarshal([]byte(tt.input), &r); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if r.X != exp.X { + t.Errorf("expected X=%d, got X=%d", exp.X, r.X) + } + return + case struct { + X []int `json:"x,omitzero"` + }: + var r struct { + X []int `json:"x,omitzero"` + } + if err := Unmarshal([]byte(tt.input), &r); err != nil { + t.Fatalf("failed to unmarshal: %v", err) + } + if len(r.X) != len(exp.X) { + t.Errorf("expected X length=%d, got X length=%d", len(exp.X), len(r.X)) + } + return + } + _ = result + }) + } +} + +// TestOmitZeroDirectInterface covers pointer-shaped structs (a single +// pointer-kind field) marshaled by value: the runtime stores such structs +// directly in the interface data word, so the head opcode must not treat the +// slot as a struct base address. +func TestOmitZeroDirectInterface(t *testing.T) { + tests := []struct { + name string + value interface{} + expected string + }{ + { + name: "map nil", + value: struct { + X map[string]int `json:"x,omitzero"` + }{X: nil}, + expected: "{}", + }, + { + name: "map empty non-nil", + value: struct { + X map[string]int `json:"x,omitzero"` + }{X: map[string]int{}}, + expected: `{"x":{}}`, + }, + { + name: "map non-empty", + value: struct { + X map[string]int `json:"x,omitzero"` + }{X: map[string]int{"a": 1}}, + expected: `{"x":{"a":1}}`, + }, + { + name: "slice empty non-nil", + value: struct { + X []int `json:"x,omitzero"` + }{X: []int{}}, + expected: `{"x":[]}`, + }, + { + name: "slice non-empty", + value: struct { + X []int `json:"x,omitzero"` + }{X: []int{1, 2}}, + expected: `{"x":[1,2]}`, + }, + { + name: "ptr to zero", + value: struct { + X *int `json:"x,omitzero"` + }{X: intPtr(0)}, + expected: `{"x":0}`, + }, + { + name: "ptr to non-zero", + value: struct { + X *int `json:"x,omitzero"` + }{X: intPtr(7)}, + expected: `{"x":7}`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := Marshal(tt.value) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + if !bytes.Equal(b, []byte(tt.expected)) { + t.Errorf("expected %s, got %s", tt.expected, string(b)) + } + }) + } +} + +// TestOmitZeroPtrHead covers marshaling a struct pointer whose fields use omitzero. +func TestOmitZeroPtrHead(t *testing.T) { + type T struct { + X int `json:"x,omitzero"` + Y string `json:"y,omitzero"` + } + tests := []struct { + name string + value interface{} + expected string + }{ + {name: "nil ptr", value: (*T)(nil), expected: "null"}, + {name: "ptr to zero struct", value: &T{}, expected: "{}"}, + {name: "ptr to non-zero", value: &T{X: 5, Y: "hi"}, expected: `{"x":5,"y":"hi"}`}, + {name: "ptr mixed", value: &T{X: 0, Y: "hi"}, expected: `{"y":"hi"}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := Marshal(tt.value) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + if !bytes.Equal(b, []byte(tt.expected)) { + t.Errorf("expected %s, got %s", tt.expected, string(b)) + } + }) + } +} + +// customZeroer implements IsZero() with semantics that deliberately disagree +// with reflect.Value.IsZero, proving the custom method is preferred. +type customZeroer struct { + zero bool +} + +func (c customZeroer) IsZero() bool { return c.zero } + +// TestOmitZeroIsZeroer covers fields whose type implements IsZero() bool. +func TestOmitZeroIsZeroer(t *testing.T) { + cases := []struct { + name string + value interface{} + expected string + }{ + { + name: "custom zeroer reports zero", + value: struct { + X customZeroer `json:"x,omitzero"` + Y int `json:"y,omitzero"` + }{X: customZeroer{zero: true}, Y: 0}, + expected: "{}", + }, + { + name: "custom zeroer reports non-zero", + value: struct { + X customZeroer `json:"x,omitzero"` + Y int `json:"y,omitzero"` + }{X: customZeroer{zero: false}, Y: 0}, + expected: `{"x":{}}`, + }, + { + name: "time.Time zero value", + value: struct { + X time.Time `json:"x,omitzero"` + Y int `json:"y,omitzero"` + }{X: time.Time{}, Y: 0}, + expected: "{}", + }, + { + name: "time.Time non-zero", + value: struct { + X time.Time `json:"x,omitzero"` + Y int `json:"y,omitzero"` + }{X: time.Date(2026, 8, 29, 12, 0, 0, 0, time.UTC), Y: 0}, + expected: `{"x":"2026-08-29T12:00:00Z"}`, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + b, err := Marshal(tt.value) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + if !bytes.Equal(b, []byte(tt.expected)) { + t.Errorf("expected %s, got %s", tt.expected, string(b)) + } + }) + } +} + +// TestOmitZeroMapFields covers map-kind includes through every opcode path: +// pointer-marshal (indirect head) and non-head fields, where the map value +// must be stored rather than its address. +func TestOmitZeroMapFields(t *testing.T) { + type singleMap struct { + X map[string]int `json:"x,omitzero"` + } + type multiField struct { + A int `json:"a,omitzero"` + X map[string]int `json:"x,omitzero"` + } + tests := []struct { + name string + value interface{} + expected string + }{ + {name: "ptr marshal nil map", value: &singleMap{}, expected: "{}"}, + {name: "ptr marshal empty map", value: &singleMap{X: map[string]int{}}, expected: `{"x":{}}`}, + {name: "ptr marshal non-empty map", value: &singleMap{X: map[string]int{"a": 1}}, expected: `{"x":{"a":1}}`}, + {name: "field nil map", value: multiField{A: 1}, expected: `{"a":1}`}, + {name: "field empty map", value: multiField{A: 1, X: map[string]int{}}, expected: `{"a":1,"x":{}}`}, + {name: "field non-empty map", value: multiField{A: 1, X: map[string]int{"k": 2}}, expected: `{"a":1,"x":{"k":2}}`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + b, err := Marshal(tt.value) + if err != nil { + t.Fatalf("failed to marshal: %v", err) + } + if !bytes.Equal(b, []byte(tt.expected)) { + t.Errorf("expected %s, got %s", tt.expected, string(b)) + } + }) + } +} + +// Benchmark omitzero +func BenchmarkOmitZero(b *testing.B) { + type testStruct struct { + X int `json:"x,omitzero"` + Y string `json:"y,omitzero"` + Z []int `json:"z,omitzero"` + W bool `json:"w,omitzero"` + } + + value := testStruct{ + X: 0, + Y: "", + Z: nil, + W: false, + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := Marshal(value) + if err != nil { + b.Fatalf("failed to marshal: %v", err) + } + } +} + +// Helper function to create an int pointer +func intPtr(v int) *int { + return &v +}