diff --git a/encode.go b/encode.go index bd7aa186..d8085624 100644 --- a/encode.go +++ b/encode.go @@ -65,6 +65,10 @@ var dblQuotedReplacer = strings.NewReplacer( "\x7f", `\u007f`, ) +type zeroer interface { + IsZero() bool +} + var ( marshalToml = reflect.TypeOf((*Marshaler)(nil)).Elem() marshalText = reflect.TypeOf((*encoding.TextMarshaler)(nil)).Elem() @@ -116,7 +120,7 @@ func Marshal(v any) ([]byte, error) { // - bool false // // If omitzero is given all int and float types with a value of 0 will be -// skipped. +// skipped, as well as values with an `IsZero() bool` method. // // Encoding Go values without a corresponding TOML representation will return an // error. Examples of this includes maps with non-string keys, slices with nil @@ -125,7 +129,9 @@ func Marshal(v any) ([]byte, error) { // is okay, as is []map[string][]string). // // NOTE: only exported keys are encoded due to the use of reflection. Unexported -// keys are silently discarded. +// keys are silently discarded. Go also makes a runtime type distinction between +// fields belonging to addressable and non-addressable values, which impacts whether +// calls to Encode will notice and use Marshaler and TextMarshaler. type Encoder struct { Indent string // string for a single indentation level; default is two spaces. hasWritten bool // written any output to w yet? @@ -664,6 +670,10 @@ func getOptions(tag reflect.StructTag) tagOptions { } func isZero(rv reflect.Value) bool { + switch v := rv.Interface().(type) { + case zeroer: + return v.IsZero() + } switch rv.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: return rv.Int() == 0 diff --git a/encode_test.go b/encode_test.go index f6afb5c5..f4965f7e 100644 --- a/encode_test.go +++ b/encode_test.go @@ -274,14 +274,51 @@ slice = ["XXX"] }) } +type AnEnum int + +func (e *AnEnum) MarshalText() ([]byte, error) { + switch *e { + case 0: + return []byte("zero"), nil + case 1: + return []byte("one"), nil + case 2: + return []byte("two"), nil + } + return nil, fmt.Errorf("invalid AnEnum value %d", int(*e)) +} + +func (e *AnEnum) IsZero() bool { + return *e == 0 +} + +// This test is surprising. We'd all probably be happier if it failed. +func TestEncodeNonAddressableField(t *testing.T) { + type simple struct { + AnEnumValue AnEnum `toml:"anEnumValue,omitzero"` + } + + // NB: value needs to be "addressable" for its type alias fields to + // implement interfaces correctly under reflection + value := simple{AnEnum(1)} + expected := "anEnumValue = 1" // would normally be "one", via MarshalText + + encodeExpected(t, "unaddressable struct fields do not implement interfaces", value, expected, nil) +} + func TestEncodeOmitZero(t *testing.T) { type simple struct { - Number int `toml:"number,omitzero"` - Real float64 `toml:"real,omitzero"` - Unsigned uint `toml:"unsigned,omitzero"` + Number int `toml:"number,omitzero"` + Real float64 `toml:"real,omitzero"` + Unsigned uint `toml:"unsigned,omitzero"` + AnEnumValue AnEnum `toml:"anEnumValue,omitzero"` } - value := simple{0, 0.0, uint(0)} + enumZero := AnEnum(0) + enumOne := AnEnum(1) + // NB: value needs to be "addressable" for its type alias fields to + // implement interfaces correctly under reflection + value := &simple{0, 0.0, uint(0), enumZero} expected := "" encodeExpected(t, "simple with omitzero, all zero", value, expected, nil) @@ -289,13 +326,46 @@ func TestEncodeOmitZero(t *testing.T) { value.Number = 10 value.Real = 20 value.Unsigned = 5 + value.AnEnumValue = enumOne expected = `number = 10 real = 20.0 unsigned = 5 +anEnumValue = "one" ` encodeExpected(t, "simple with omitzero, non-zero", value, expected, nil) } +func TestEncodeOmitZeroArray(t *testing.T) { + type simple struct { + Number int `toml:"number,omitzero"` + Real float64 `toml:"real,omitzero"` + Unsigned uint `toml:"unsigned,omitzero"` + AnEnumValue AnEnum `toml:"anEnumValue,omitzero"` + } + type list struct { + Simples []simple + } + + enumZero := AnEnum(0) + enumOne := AnEnum(1) + value := list{Simples: []simple{{0, 0.0, uint(0), enumZero}}} + expected := "[[Simples]]" + + encodeExpected(t, "list of struct with omitzero, all zero", value, expected, nil) + + value.Simples[0].Number = 10 + value.Simples[0].Real = 20 + value.Simples[0].Unsigned = 5 + value.Simples[0].AnEnumValue = enumOne + expected = `[[Simples]] + number = 10 + real = 20.0 + unsigned = 5 + anEnumValue = "one" +` + encodeExpected(t, "list of struct with omitzero, non-zero", value, expected, nil) +} + func TestEncodeOmitemptyEmptyName(t *testing.T) { type simple struct { S []int `toml:",omitempty"`