diff --git a/cmd/jsontoml/main_test.go b/cmd/jsontoml/main_test.go index 7f2455bf..4a4ad072 100644 --- a/cmd/jsontoml/main_test.go +++ b/cmd/jsontoml/main_test.go @@ -26,7 +26,6 @@ func TestConvert(t *testing.T) { }`, expected: `[mytoml] a = 42.0 - `, }, { diff --git a/cmd/tomll/main_test.go b/cmd/tomll/main_test.go index 8f5db521..553bccad 100644 --- a/cmd/tomll/main_test.go +++ b/cmd/tomll/main_test.go @@ -23,7 +23,6 @@ mytoml.a = 42.0 `, expected: `[mytoml] a = 42.0 - `, }, { diff --git a/internal/imported_tests/marshal_imported_test.go b/internal/imported_tests/marshal_imported_test.go index 578cf577..cef7f232 100644 --- a/internal/imported_tests/marshal_imported_test.go +++ b/internal/imported_tests/marshal_imported_test.go @@ -67,6 +67,7 @@ func TestDocMarshal(t *testing.T) { } marshalTestToml := `title = 'TOML Marshal Testing' + [basic_lists] floats = [12.3, 45.6, 78.9] bools = [true, false, true] @@ -89,7 +90,6 @@ name = 'Second' [subdoc.first] name = 'First' - [basic] uint = 5001 bool = true @@ -101,9 +101,9 @@ date = 1979-05-27T07:32:00Z [[subdoclist]] name = 'List.First' + [[subdoclist]] name = 'List.Second' - ` result, err := toml.Marshal(docData) @@ -117,14 +117,15 @@ func TestBasicMarshalQuotedKey(t *testing.T) { expected := `'Z.string-àéù' = 'Hello' 'Yfloat-𝟘' = 3.5 + ['Xsubdoc-àéù'] String2 = 'One' [['W.sublist-𝟘']] String2 = 'Two' + [['W.sublist-𝟘']] String2 = 'Three' - ` require.Equal(t, string(expected), string(result)) @@ -159,8 +160,8 @@ bool = false int = 0 string = '' stringlist = [] -[map] +[map] ` require.Equal(t, string(expected), string(result)) diff --git a/marshaler.go b/marshaler.go index 4eb526bb..2a037c59 100644 --- a/marshaler.go +++ b/marshaler.go @@ -117,6 +117,19 @@ func (enc *Encoder) SetIndentTables(indent bool) *Encoder { // When encoding structs, fields are encoded in order of definition, with their // exact name. // +// Tables and array tables are separated by empty lines. However, consecutive +// subtables definitions are not. For example: +// +// [top1] +// +// [top2] +// [top2.child1] +// +// [[array]] +// +// [[array]] +// [array.child2] +// // Struct tags // // The encoding of each public struct field can be customized by the format @@ -333,13 +346,13 @@ func isNil(v reflect.Value) bool { } } +func shouldOmitEmpty(ctx encoderCtx, options valueOptions, v reflect.Value) bool { + return (ctx.options.omitempty || options.omitempty) && isEmptyValue(v) +} + func (enc *Encoder) encodeKv(b []byte, ctx encoderCtx, options valueOptions, v reflect.Value) ([]byte, error) { var err error - if (ctx.options.omitempty || options.omitempty) && isEmptyValue(v) { - return b, nil - } - if !ctx.inline { b = enc.encodeComment(ctx.indent, options.comment, b) } @@ -365,6 +378,8 @@ func (enc *Encoder) encodeKv(b []byte, ctx encoderCtx, options valueOptions, v r func isEmptyValue(v reflect.Value) bool { switch v.Kind() { + case reflect.Struct: + return isEmptyStruct(v) case reflect.Array, reflect.Map, reflect.Slice, reflect.String: return v.Len() == 0 case reflect.Bool: @@ -381,6 +396,34 @@ func isEmptyValue(v reflect.Value) bool { return false } +func isEmptyStruct(v reflect.Value) bool { + // TODO: merge with walkStruct and cache. + typ := v.Type() + for i := 0; i < typ.NumField(); i++ { + fieldType := typ.Field(i) + + // only consider exported fields + if fieldType.PkgPath != "" { + continue + } + + tag := fieldType.Tag.Get("toml") + + // special field name to skip field + if tag == "-" { + continue + } + + f := v.Field(i) + + if !isEmptyValue(f) { + return false + } + } + + return true +} + const literalQuote = '\'' func (enc *Encoder) encodeString(b []byte, v string, options valueOptions) []byte { @@ -410,7 +453,6 @@ func (enc *Encoder) encodeLiteralString(b []byte, v string) []byte { return b } -//nolint:cyclop func (enc *Encoder) encodeQuotedString(multiline bool, b []byte, v string) []byte { stringQuote := `"` @@ -757,7 +799,13 @@ func (enc *Encoder) encodeTable(b []byte, ctx encoderCtx, t table) ([]byte, erro } ctx.skipTableHeader = false + hasNonEmptyKV := false for _, kv := range t.kvs { + if shouldOmitEmpty(ctx, kv.Options, kv.Value) { + continue + } + hasNonEmptyKV = true + ctx.setKey(kv.Key) b, err = enc.encodeKv(b, ctx, kv.Options, kv.Value) @@ -768,7 +816,20 @@ func (enc *Encoder) encodeTable(b []byte, ctx encoderCtx, t table) ([]byte, erro b = append(b, '\n') } + first := true for _, table := range t.tables { + if shouldOmitEmpty(ctx, table.Options, table.Value) { + continue + } + if first { + first = false + if hasNonEmptyKV { + b = append(b, '\n') + } + } else { + b = append(b, "\n"...) + } + ctx.setKey(table.Key) ctx.options = table.Options @@ -777,8 +838,6 @@ func (enc *Encoder) encodeTable(b []byte, ctx encoderCtx, t table) ([]byte, erro if err != nil { return nil, err } - - b = append(b, '\n') } return b, nil @@ -791,6 +850,10 @@ func (enc *Encoder) encodeTableInline(b []byte, ctx encoderCtx, t table) ([]byte first := true for _, kv := range t.kvs { + if shouldOmitEmpty(ctx, kv.Options, kv.Value) { + continue + } + if first { first = false } else { @@ -905,6 +968,10 @@ func (enc *Encoder) encodeSliceAsArrayTable(b []byte, ctx encoderCtx, v reflect. b = enc.encodeComment(ctx.indent, ctx.options.comment, b) for i := 0; i < v.Len(); i++ { + if i != 0 { + b = append(b, "\n"...) + } + b = append(b, scratch...) var err error diff --git a/marshaler_test.go b/marshaler_test.go index 31b75125..ef444b7d 100644 --- a/marshaler_test.go +++ b/marshaler_test.go @@ -39,21 +39,21 @@ func TestMarshal(t *testing.T) { v: map[string]string{ "hello": "world", }, - expected: "hello = 'world'", + expected: "hello = 'world'\n", }, { desc: "map with new line in key", v: map[string]string{ "hel\nlo": "world", }, - expected: `"hel\nlo" = 'world'`, + expected: "\"hel\\nlo\" = 'world'\n", }, { desc: `map with " in key`, v: map[string]string{ `hel"lo`: "world", }, - expected: `'hel"lo' = 'world'`, + expected: "'hel\"lo' = 'world'\n", }, { desc: "map in map and string", @@ -62,9 +62,9 @@ func TestMarshal(t *testing.T) { "hello": "world", }, }, - expected: ` -[table] -hello = 'world'`, + expected: `[table] +hello = 'world' +`, }, { desc: "map in map in map and string", @@ -75,10 +75,10 @@ hello = 'world'`, }, }, }, - expected: ` -[this] + expected: `[this] [this.is] -a = 'test'`, +a = 'test' +`, }, { desc: "map in map in map and string with values", @@ -90,18 +90,20 @@ a = 'test'`, "also": "that", }, }, - expected: ` -[this] + expected: `[this] also = 'that' + [this.is] -a = 'test'`, +a = 'test' +`, }, { desc: "simple string array", v: map[string][]string{ "array": {"one", "two", "three"}, }, - expected: `array = ['one', 'two', 'three']`, + expected: `array = ['one', 'two', 'three'] +`, }, { desc: "empty string array", @@ -118,14 +120,16 @@ a = 'test'`, v: map[string][][]string{ "array": {{"one", "two"}, {"three"}}, }, - expected: `array = [['one', 'two'], ['three']]`, + expected: `array = [['one', 'two'], ['three']] +`, }, { desc: "mixed strings and nested string arrays", v: map[string][]interface{}{ "array": {"a string", []string{"one", "two"}, "last"}, }, - expected: `array = ['a string', ['one', 'two'], 'last']`, + expected: `array = ['a string', ['one', 'two'], 'last'] +`, }, { desc: "array of maps", @@ -135,9 +139,9 @@ a = 'test'`, {"map2.1": "v2.1"}, }, }, - expected: ` -[[top]] + expected: `[[top]] 'map1.1' = 'v1.1' + [[top]] 'map2.1' = 'v2.1' `, @@ -148,9 +152,9 @@ a = 'test'`, "key1": "value1", "key2": "value2", }, - expected: ` -key1 = 'value1' -key2 = 'value2'`, + expected: `key1 = 'value1' +key2 = 'value2' +`, }, { desc: "simple struct", @@ -159,7 +163,8 @@ key2 = 'value2'`, }{ A: "foo", }, - expected: `A = 'foo'`, + expected: `A = 'foo' +`, }, { desc: "one level of structs within structs", @@ -174,8 +179,7 @@ key2 = 'value2'`, K2: "v2", }, }, - expected: ` -[A] + expected: `[A] K1 = 'v1' K2 = 'v2' `, @@ -190,10 +194,10 @@ K2 = 'v2' }, }, }, - expected: ` -[root] + expected: `[root] [[root.nested]] name = 'Bob' + [[root.nested]] name = 'Alice' `, @@ -203,49 +207,53 @@ name = 'Alice' v: map[string]interface{}{ "a": "'\b\f\r\t\"\\", }, - expected: `a = "'\b\f\r\t\"\\"`, + expected: `a = "'\b\f\r\t\"\\" +`, }, { desc: "string utf8 low", v: map[string]interface{}{ "a": "'Ę", }, - expected: `a = "'Ę"`, + expected: `a = "'Ę" +`, }, { desc: "string utf8 low 2", v: map[string]interface{}{ "a": "'\u10A85", }, - expected: "a = \"'\u10A85\"", + expected: "a = \"'\u10A85\"\n", }, { desc: "string utf8 low 2", v: map[string]interface{}{ "a": "'\u10A85", }, - expected: "a = \"'\u10A85\"", + expected: "a = \"'\u10A85\"\n", }, { desc: "emoji", v: map[string]interface{}{ "a": "'πŸ˜€", }, - expected: "a = \"'πŸ˜€\"", + expected: "a = \"'πŸ˜€\"\n", }, { desc: "control char", v: map[string]interface{}{ "a": "'\u001A", }, - expected: `a = "'\u001A"`, + expected: `a = "'\u001A" +`, }, { desc: "multi-line string", v: map[string]interface{}{ "a": "hello\nworld", }, - expected: `a = "hello\nworld"`, + expected: `a = "hello\nworld" +`, }, { desc: "multi-line forced", @@ -256,7 +264,8 @@ name = 'Alice' }, expected: `A = """ hello -world"""`, +world""" +`, }, { desc: "inline field", @@ -271,8 +280,8 @@ world"""`, "isinline": "no", }, }, - expected: ` -A = {isinline = 'yes'} + expected: `A = {isinline = 'yes'} + [B] isinline = 'no' `, @@ -286,8 +295,7 @@ isinline = 'no' A: []int{1, 2, 3, 4}, B: []int{1, 2, 3, 4}, }, - expected: ` -A = [ + expected: `A = [ 1, 2, 3, @@ -303,8 +311,7 @@ B = [1, 2, 3, 4] }{ A: [][]int{{1, 2}, {3, 4}}, }, - expected: ` -A = [ + expected: `A = [ [1, 2], [3, 4] ] @@ -329,7 +336,8 @@ A = [ }{ A: []*int{nil}, }, - expected: `A = [0]`, + expected: `A = [0] +`, }, { desc: "nil pointer in slice uses zero value", @@ -338,7 +346,8 @@ A = [ }{ A: []*int{nil}, }, - expected: `A = [0]`, + expected: `A = [0] +`, }, { desc: "pointer in slice", @@ -347,7 +356,8 @@ A = [ }{ A: []*int{&someInt}, }, - expected: `A = [42]`, + expected: `A = [42] +`, }, { desc: "inline table in inline table", @@ -358,23 +368,25 @@ A = [ }, }, }, - expected: `A = {A = {A = 'hello'}}`, + expected: `A = {A = {A = 'hello'}} +`, }, { desc: "empty slice in map", v: map[string][]string{ "a": {}, }, - expected: `a = []`, + expected: `a = [] +`, }, { desc: "map in slice", v: map[string][]map[string]string{ "a": {{"hello": "world"}}, }, - expected: ` -[[a]] -hello = 'world'`, + expected: `[[a]] +hello = 'world' +`, }, { desc: "newline in map in slice", @@ -382,7 +394,8 @@ hello = 'world'`, "a\n": {{"hello": "world"}}, }, expected: `[["a\n"]] -hello = 'world'`, +hello = 'world' +`, }, { desc: "newline in map in slice", @@ -398,7 +411,8 @@ hello = 'world'`, }{ A: []struct{}{}, }, - expected: `A = []`, + expected: `A = [] +`, }, { desc: "nil field is ignored", @@ -418,7 +432,8 @@ hello = 'world'`, Public: "shown", private: "hidden", }, - expected: `Public = 'shown'`, + expected: `Public = 'shown' +`, }, { desc: "fields tagged - are ignored", @@ -442,7 +457,8 @@ hello = 'world'`, v: map[string]interface{}{ "hello\nworld": 42, }, - expected: `"hello\nworld" = 42`, + expected: `"hello\nworld" = 42 +`, }, { desc: "new line in parent of nested table key", @@ -452,7 +468,8 @@ hello = 'world'`, }, }, expected: `["hello\nworld"] -inner = 42`, +inner = 42 +`, }, { desc: "new line in nested table key", @@ -465,7 +482,8 @@ inner = 42`, }, expected: `[parent] [parent."in\ner"] -foo = 42`, +foo = 42 +`, }, { desc: "invalid map key", @@ -488,7 +506,8 @@ foo = 42`, }{ T: time.Time{}, }, - expected: `T = 0001-01-01T00:00:00Z`, + expected: `T = 0001-01-01T00:00:00Z +`, }, { desc: "time nano", @@ -497,7 +516,8 @@ foo = 42`, }{ T: time.Date(1979, time.May, 27, 0, 32, 0, 999999000, time.UTC), }, - expected: `T = 1979-05-27T00:32:00.999999Z`, + expected: `T = 1979-05-27T00:32:00.999999Z +`, }, { desc: "bool", @@ -508,9 +528,9 @@ foo = 42`, A: false, B: true, }, - expected: ` -A = false -B = true`, + expected: `A = false +B = true +`, }, { desc: "numbers", @@ -541,8 +561,7 @@ B = true`, K: 42, L: 2.2, }, - expected: ` -A = 1.1 + expected: `A = 1.1 B = 42 C = 42 D = 42 @@ -553,7 +572,8 @@ H = 42 I = 42 J = 42 K = 42 -L = 2.2`, +L = 2.2 +`, }, { desc: "comments", @@ -566,8 +586,7 @@ L = 2.2`, Three: []int{1, 2, 3}, }, }, - expected: ` -# Before table + expected: `# Before table [Table] One = 1 # Before kv @@ -589,7 +608,7 @@ Three = [1, 2, 3] } require.NoError(t, err) - equalStringsIgnoreNewlines(t, e.expected, string(b)) + assert.Equal(t, e.expected, string(b)) // make sure the output is always valid TOML defaultMap := map[string]interface{}{} @@ -664,12 +683,6 @@ func testWithFlags(t *testing.T, flags int, setters flagsSetters, testfn func(t } } -func equalStringsIgnoreNewlines(t *testing.T, expected string, actual string) { - t.Helper() - cutset := "\n" - assert.Equal(t, strings.Trim(expected, cutset), strings.Trim(actual, cutset)) -} - func TestMarshalFloats(t *testing.T) { v := map[string]float32{ "nan": float32(math.NaN()), @@ -709,7 +722,8 @@ func TestMarshalIndentTables(t *testing.T) { v: map[string]interface{}{ "foo": "bar", }, - expected: `foo = 'bar'`, + expected: `foo = 'bar' +`, }, { desc: "one level table", @@ -719,8 +733,7 @@ func TestMarshalIndentTables(t *testing.T) { "two": "value2", }, }, - expected: ` -[foo] + expected: `[foo] one = 'value1' two = 'value2' `, @@ -736,10 +749,11 @@ func TestMarshalIndentTables(t *testing.T) { }, }, }, - expected: ` -root = 'value0' + expected: `root = 'value0' + [level1] one = 'value1' + [level1.level2] two = 'value2' `, @@ -754,7 +768,7 @@ root = 'value0' enc.SetIndentTables(true) err := enc.Encode(e.v) require.NoError(t, err) - equalStringsIgnoreNewlines(t, e.expected, buf.String()) + assert.Equal(t, e.expected, buf.String()) }) } } @@ -799,7 +813,7 @@ func TestMarshalTextMarshaler(t *testing.T) { m := map[string]interface{}{"a": &customTextMarshaler{value: 2}} r, err := toml.Marshal(m) require.NoError(t, err) - equalStringsIgnoreNewlines(t, "a = '::2'", string(r)) + assert.Equal(t, "a = '::2'\n", string(r)) } type brokenWriter struct{} @@ -822,10 +836,10 @@ func TestEncoderSetIndentSymbol(t *testing.T) { enc.SetIndentSymbol(">>>") err := enc.Encode(map[string]map[string]string{"parent": {"hello": "world"}}) require.NoError(t, err) - expected := ` -[parent] ->>>hello = 'world'` - equalStringsIgnoreNewlines(t, expected, w.String()) + expected := `[parent] +>>>hello = 'world' +` + assert.Equal(t, expected, w.String()) } func TestEncoderOmitempty(t *testing.T) { @@ -856,9 +870,9 @@ func TestEncoderOmitempty(t *testing.T) { b, err := toml.Marshal(d) require.NoError(t, err) - expected := `[Struct]` + expected := `` - equalStringsIgnoreNewlines(t, expected, string(b)) + assert.Equal(t, expected, string(b)) } func TestEncoderTagFieldName(t *testing.T) { @@ -873,13 +887,12 @@ func TestEncoderTagFieldName(t *testing.T) { b, err := toml.Marshal(d) require.NoError(t, err) - expected := ` -hello = 'world' + expected := `hello = 'world' '#' = '' Bad = '' ` - equalStringsIgnoreNewlines(t, expected, string(b)) + assert.Equal(t, expected, string(b)) } func TestIssue436(t *testing.T) { @@ -893,12 +906,11 @@ func TestIssue436(t *testing.T) { err = toml.NewEncoder(&buf).Encode(v) require.NoError(t, err) - expected := ` -[[a]] + expected := `[[a]] [a.b] c = 'd' ` - equalStringsIgnoreNewlines(t, expected, buf.String()) + assert.Equal(t, expected, buf.String()) } func TestIssue424(t *testing.T) { @@ -980,7 +992,7 @@ func TestIssue678(t *testing.T) { out, err := toml.Marshal(cfg) require.NoError(t, err) - equalStringsIgnoreNewlines(t, "BigInt = '123'", string(out)) + assert.Equal(t, "BigInt = '123'\n", string(out)) cfg2 := &Config{} err = toml.Unmarshal(out, cfg2) @@ -1020,6 +1032,24 @@ Name = '' require.Equal(t, expected, string(out)) } +func TestIssue786(t *testing.T) { + type Dependencies struct { + Dependencies []string `toml:"dependencies,multiline,omitempty"` + BuildDependencies []string `toml:"buildDependencies,multiline,omitempty"` + OptionalDependencies []string `toml:"optionalDependencies,multiline,omitempty"` + } + + type Test struct { + Dependencies Dependencies `toml:"dependencies,omitempty"` + } + + x := Test{} + b, err := toml.Marshal(x) + require.NoError(t, err) + + require.Equal(t, "", string(b)) +} + func TestMarshalNestedAnonymousStructs(t *testing.T) { type Embedded struct { Value string `toml:"value" json:"value"` @@ -1041,6 +1071,7 @@ func TestMarshalNestedAnonymousStructs(t *testing.T) { } expected := `value = '' + [top] value = '' @@ -1049,7 +1080,6 @@ value = '' [anonymous] value = '' - ` result, err := toml.Marshal(doc) @@ -1073,9 +1103,9 @@ func TestMarshalNestedAnonymousStructs_DuplicateField(t *testing.T) { doc.Value = "shadows" expected := `value = 'shadows' + [top] value = '' - ` result, err := toml.Marshal(doc) @@ -1086,7 +1116,7 @@ value = '' func TestLocalTime(t *testing.T) { v := map[string]toml.LocalTime{ - "a": toml.LocalTime{ + "a": { Hour: 1, Minute: 2, Second: 3, diff --git a/unmarshaler_test.go b/unmarshaler_test.go index 9af5fb35..1a454b84 100644 --- a/unmarshaler_test.go +++ b/unmarshaler_test.go @@ -1876,8 +1876,7 @@ key2 = "missing2" key3 = "missing3" key4 = "value4" `, - expected: ` -2| key1 = "value1" + expected: `2| key1 = "value1" 3| key2 = "missing2" | ~~~~ missing field 4| key3 = "missing3" @@ -1887,8 +1886,7 @@ key4 = "value4" 3| key2 = "missing2" 4| key3 = "missing3" | ~~~~ missing field -5| key4 = "value4" -`, +5| key4 = "value4"`, target: &struct { Key1 string Key4 string @@ -1897,10 +1895,8 @@ key4 = "value4" { desc: "multi-part key", input: `a.short.key="foo"`, - expected: ` -1| a.short.key="foo" - | ~~~~~~~~~~~ missing field -`, + expected: `1| a.short.key="foo" + | ~~~~~~~~~~~ missing field`, }, { desc: "missing table", @@ -1908,24 +1904,19 @@ key4 = "value4" [foo] bar = 42 `, - expected: ` -2| [foo] + expected: `2| [foo] | ~~~ missing table -3| bar = 42 -`, +3| bar = 42`, }, { desc: "missing array table", input: ` [[foo]] -bar = 42 -`, - expected: ` -2| [[foo]] +bar = 42`, + expected: `2| [[foo]] | ~~~ missing table -3| bar = 42 -`, +3| bar = 42`, }, } @@ -1944,7 +1935,7 @@ bar = 42 var tsm *toml.StrictMissingError if errors.As(err, &tsm) { - equalStringsIgnoreNewlines(t, e.expected, tsm.String()) + assert.Equal(t, e.expected, tsm.String()) } else { t.Fatalf("err should have been a *toml.StrictMissingError, but got %s (%T)", err, err) } @@ -2417,7 +2408,6 @@ func TestIssue774(t *testing.T) { expected := `# Array of Secure Copy Configurations [[scp]] Host = 'main.domain.com' - ` require.Equal(t, expected, string(b))