diff --git a/CHANGELOG.md b/CHANGELOG.md index 234e6be..4a49340 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -24,7 +24,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 toml-test tagged JSON from stdin and writes the TOML document it describes. The compliance suite now runs the encoder as well as the decoder, 214 encoder cases against the tagged JSON of the valid corpus. -- `Encoder.InlineTables(threshold)`: a sub-table whose single-line rendering is +- `InlineTables(threshold)`: a sub-table whose single-line rendering is at most `threshold` bytes is written as an inline table instead of a header section, which shortens a document of small tables. An array of tables keeps its header form, because its inline form would re-parse as a value array. @@ -47,7 +47,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the plain type takes an offset date-time as it always did; code that asserts the tree's type, and `UnmarshalTOML` implementations that expect a `time.Time`, need the new type. -- `Decoder.MaxDepth(depth)` and `Decoder.MaxInputSize(size)` bound the parse a +- `MaxNestingDepth(depth)` and `MaxInputSize(size)` options bound the parse a `Decode` performs, and every parse carries a nesting limit in any case (10000 levels, which no hand-written document approaches): a document that nests arrays or inline tables deeper used to run the stack out and is now @@ -60,12 +60,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - `ParseAs[T](data)`, the generic one-line decode, and `NewSchema[T]()`, which precompiles the struct schema and the interface flags for a hot path before the first document arrives. -- `Encoder.EmitFieldComments()` prints the comment a field's `toml` tag +- `EmitFieldComments(true)` prints the comment a field's `toml` tag carries in a `comment=` option above the field's line or header, the comments a round trip through the Go type would otherwise drop. Go doc comments are not visible to reflection, so the tag is the channel that carries the text. -- `Decoder.LocalTimeLocation(loc)` lets a local date-time fill a plain +- `LocalTimeLocation(loc)` lets a local date-time fill a plain `time.Time` destination in the location given, relabelled rather than shifted: `07:32` in the document is `07:32` in the zone. Without the option the wrapper types remain the only destinations a local kind fills. @@ -78,9 +78,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 order on decode and sorts on encode. It works as a decode target on its own, in a struct field, and as the element of an array of tables; its values are untyped, so a nested table stays a `map[string]any`. -- `UnmarshalWithOptions(data, v, opts)` decodes with a `DecodeOptions` struct - in one call, the options a `Decoder` sets without building one: unknown - keys, `Number` literals, and the parse limits. +- `Unmarshal(data, v, opts...)` and the other entries take variadic options, + the shape encoding/json/v2 uses: `RejectUnknownFields`, + `NumbersAsLiterals`, `MaxNestingDepth`, `MaxInputSize`, + `LocalTimeLocation`. `MarshalWrite(w, v, opts...)` and + `UnmarshalRead(r, v, opts...)` are the streaming forms. - `Marshal` carries a nesting limit of 10000 levels, the parser's own figure: cyclic data, which used to run the stack out, is now rejected with an error that names the limit and the path it was met at. @@ -126,7 +128,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 the input. An input that is not valid UTF-8 names the offset of the first invalid byte in its message. The new fields are additive: a `SyntaxError` built from a line and a message alone is unchanged. -- `Decoder.UseNumber()` decodes the integers and floats of the document into +- `NumbersAsLiterals(true)` decodes the integers and floats of the document into `Number`, which carries the literal the document wrote, so `0x1f`, `1_000`, `+1.0` and `inf` survive a round trip with their spelling instead of the normalised `31`, `1000` and `1.0`. Typed destinations take the evaluated @@ -136,11 +138,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed -- `Encoder.GroupByKind(bool)` is renamed to `Encoder.Layout(kind)` and takes - a `LayoutKind`: `LayoutKindGrouped`, the default, or - `LayoutKindDeclaration` for the declaration order. `UseLiteralMultiline` - is renamed to `LiteralMultiline`. The behaviour is unchanged; 2.0 is the - only chance a rename has, and the migrator updates the calls mechanically. +- The stateful `Decoder` and `Encoder` of 1.x are replaced by variadic + options on the entries, the shape encoding/json/v2 uses: `Layout(kind)` + with `LayoutKindGrouped` or `LayoutKindDeclaration`, `OmitEmptyArrays`, + `LiteralMultiline(threshold)`, `InlineTables(threshold)`, + `EmitFieldComments`, `RejectUnknownFields`, `NumbersAsLiterals`, + `MaxNestingDepth`, `MaxInputSize`, `LocalTimeLocation`. - `DecodeError` and `EncodeError` carry one `Path` type, a list of segments (`"items"`, `"[0]"`, `"weight"`) with a `String()` rendering the TOML notation, `items[0].weight`. The decode error used to hold a bare @@ -275,9 +278,12 @@ destination field of type `time.Time` keeps working. `ParseMap` gives the plain `map[string]any` tree the old `Parse` returned. The document is writable, and `Marshal` writes it back with its comments. -**Renamed API.** `Encoder.GroupByKind(bool)` is `Encoder.Layout(kind)` with -`LayoutKindGrouped` (the old default) and `LayoutKindDeclaration` (the old -`false`); `UseLiteralMultiline` is `LiteralMultiline`. +**Options instead of Decoder and Encoder.** The stateful types of 1.x are +gone; the entries take variadic options, the shape encoding/json/v2 uses. +`NewDecoder().DisallowUnknownFields().Decode(data, &cfg)` becomes +`Unmarshal(data, &cfg, RejectUnknownFields(true))`, and the encoder +methods become options: `Layout(LayoutKindDeclaration)` replaces +`GroupByKind(false)`, `LiteralMultiline` replaces `UseLiteralMultiline`. **Tag options.** `omitempty` follows encoding/json: it now also drops empty strings, zero numbers, `false`, nil pointers and nil interfaces. `required` @@ -293,7 +299,7 @@ match on the old messages would not see. **Decoding shapes.** `map[string]any` values merge into a non-empty map destination; untagged embedded maps beyond the first stay empty; numbers can -stay literals under `UseNumber`; local date-times can decode into +stay literals under `NumbersAsLiterals`; local date-times can decode into `time.Time` under `LocalTimeLocation`. All three are opt-in or additive except where noted above. @@ -442,7 +448,7 @@ uses only the standard library and passes the entire nested structs, slices, and `map[string]T`. - `toml:"name"` field tags, case-insensitive name fallback, and `toml:"-"` to skip a field. -- `Decoder` with `DisallowUnknownFields` for strict decoding that rejects keys +- `RejectUnknownFields(true)` option for strict decoding that rejects keys without a destination field, at every struct depth. - `Unmarshaler` interface (`UnmarshalTOML(data any) error`) for types that take full control of their decode. diff --git a/README.md b/README.md index ffb9dc4..80b628c 100644 --- a/README.md +++ b/README.md @@ -15,7 +15,7 @@ the entire official [toml-test](https://github.com/toml-lang/toml-test) suite: optional as of 1.1; arrays and inline tables, multi-line as of 1.1. - **Decoding and encoding**: `Parse` for an untyped tree, `Unmarshal` and `Marshal` for structs and maps, mirroring `encoding/json`. -- **Strict decoding**: `NewDecoder().DisallowUnknownFields()` rejects keys that +- **Strict decoding**: `RejectUnknownFields(true)` rejects keys that match no destination field, at every struct depth. - **Custom types**: `Marshaler` and `Unmarshaler` let a type control its own TOML representation in both directions, and `encoding.TextMarshaler` and @@ -101,9 +101,7 @@ which is the layout that re-parses to the same tree. ### Strict decoding ```go -err := interpres.NewDecoder(). - DisallowUnknownFields(). - Decode(data, &cfg) +err := interpres.Unmarshal(data, &cfg, interpres.RejectUnknownFields(true)) ``` A key with no matching field becomes an error instead of a silent drop. @@ -132,16 +130,20 @@ func (ip *IP) UnmarshalTOML(data any) error { The value `MarshalTOML` returns is encoded in place of the receiver; `UnmarshalTOML` receives the parsed value verbatim. -### Encoder options +### Options ```go -out, err := interpres.NewEncoder(). - Layout(interpres.LayoutKindDeclaration), // preserve declaration order - OmitEmptyArrays(). // skip empty scalar arrays - LiteralMultiline(80), // long multi-line strings as literal blocks - Marshal(cfg) +out, err := interpres.Marshal(cfg, + interpres.Layout(interpres.LayoutKindDeclaration), // preserve declaration order + interpres.OmitEmptyArrays(true), // skip empty scalar arrays + interpres.LiteralMultiline(80), // long multi-line strings as literal blocks +) ``` +The decode and encode calls take variadic options, the shape +encoding/json/v2 uses for its own. `UnmarshalRead(r, v, opts...)` and +`MarshalWrite(w, v, opts...)` are the streaming forms. + ### Cancellation ```go @@ -151,8 +153,7 @@ defer cancel() out, err := interpres.MarshalContext(ctx, cfg) ``` -`ParseContext`, `UnmarshalContext`, `(*Decoder).DecodeContext` and -`(*Encoder).MarshalContext` follow the same pattern. +`ParseContext`, `UnmarshalContext` and `MarshalContext` follow the same pattern. The full rules for field matching, numeric conversion and emission live in [docs/API.md](docs/API.md). diff --git a/bench_test.go b/bench_test.go index 925a909..e9ce622 100644 --- a/bench_test.go +++ b/bench_test.go @@ -109,11 +109,10 @@ func BenchmarkMarshal(b *testing.B) { } func BenchmarkStrictDecode(b *testing.B) { - dec := NewDecoder().DisallowUnknownFields() b.ReportAllocs() for b.Loop() { var cfg benchConfig - if err := dec.Decode(benchDoc, &cfg); err != nil { + if err := Unmarshal(benchDoc, &cfg, RejectUnknownFields(true)); err != nil { b.Fatal(err) } } @@ -145,12 +144,11 @@ type benchLongDoc struct { } func BenchmarkStrictDecodeLong(b *testing.B) { - dec := NewDecoder().DisallowUnknownFields() b.ReportAllocs() b.SetBytes(int64(len(longDoc))) for b.Loop() { var doc benchLongDoc - if err := dec.Decode(longDoc, &doc); err != nil { + if err := Unmarshal(longDoc, &doc, RejectUnknownFields(true)); err != nil { b.Fatal(err) } } diff --git a/decode.go b/decode.go index ba6c484..e790e39 100644 --- a/decode.go +++ b/decode.go @@ -528,7 +528,7 @@ func setDuration(dst reflect.Value, s string) error { return nil } -// setNumber stores a Number, the literal UseNumber keeps. A Number destination +// setNumber stores a Number, the literal NumbersAsLiterals keeps. A Number destination // takes the literal as it is; every other destination takes the evaluated // value through the ordinary rules, so an integer field, a float field and a // duration field all read a Number the way they read the evaluated kind. diff --git a/decode_test.go b/decode_test.go index 29c925e..6000799 100644 --- a/decode_test.go +++ b/decode_test.go @@ -169,7 +169,7 @@ func TestDecoderDecodeContextHonoursCancellation(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() var cfg map[string]any - err := NewDecoder().DecodeContext(ctx, []byte("a = 1\n"), &cfg) + err := UnmarshalContext(ctx, []byte("a = 1\n"), &cfg) if !errors.Is(err, context.Canceled) { t.Fatalf("DecodeContext returned %v, want context.Canceled", err) } @@ -194,7 +194,7 @@ count = 3 if out.Title != "x" || out.Count != 3 { t.Errorf("out = %#v", out) } - if err := NewDecoder().DecodeContext(context.Background(), in, &map[string]any{}); err != nil { + if err := Unmarshal(in, &map[string]any{}); err != nil { t.Fatalf("Decoder.DecodeContext: %v", err) } } @@ -695,8 +695,7 @@ func TestUnmarshalStrictEmbeddedMapStaysStrict(t *testing.T) { RoundTripExtra Name string `toml:"name"` } - dec := NewDecoder().DisallowUnknownFields() - err := dec.Decode([]byte("name = \"n\"\nrogue = 1\n"), &Cfg{}) + err := Unmarshal([]byte("name = \"n\"\nrogue = 1\n"), &Cfg{}, RejectUnknownFields(true)) if err == nil || !strings.Contains(err.Error(), "unknown field") { t.Fatalf("expected unknown field error, got: %v", err) } @@ -980,10 +979,10 @@ func TestDecoderMaxDepth(t *testing.T) { var cfg struct { V any `toml:"v"` } - if err := NewDecoder().MaxDepth(4).Decode(deep(4), &cfg); err != nil { + if err := Unmarshal(deep(4), &cfg, MaxNestingDepth(4)); err != nil { t.Fatalf("at the limit: %v", err) } - err := NewDecoder().MaxDepth(4).Decode(deep(5), &cfg) + err := Unmarshal(deep(5), &cfg, MaxNestingDepth(4)) if err == nil { t.Fatal("expected a nesting error") } @@ -997,10 +996,10 @@ func TestDecoderMaxInputSize(t *testing.T) { var cfg struct { V string `toml:"v"` } - if err := NewDecoder().MaxInputSize(len(doc)).Decode(doc, &cfg); err != nil { + if err := Unmarshal(doc, &cfg, MaxInputSize(len(doc))); err != nil { t.Fatalf("at the limit: %v", err) } - err := NewDecoder().MaxInputSize(len(doc)-1).Decode(doc, &cfg) + err := Unmarshal(doc, &cfg, MaxInputSize(len(doc)-1)) if err == nil { t.Fatal("expected a size error") } @@ -1112,7 +1111,7 @@ frac = 2.5 `) t.Run("the tree keeps the literal", func(t *testing.T) { var tree map[string]any - if err := NewDecoder().UseNumber().Decode(data, &tree); err != nil { + if err := Unmarshal(data, &tree, NumbersAsLiterals(true)); err != nil { t.Fatal(err) } for lit, key := range map[string]string{ @@ -1136,8 +1135,7 @@ frac = 2.5 Frac float64 `toml:"frac"` Rate time.Duration } - dec := NewDecoder().UseNumber() - if err := dec.Decode([]byte("hex = 0x1f\nplain = 42\nfrac = 2.5\nRate = 1_000\n"), &cfg); err != nil { + if err := Unmarshal([]byte("hex = 0x1f\nplain = 42\nfrac = 2.5\nRate = 1_000\n"), &cfg, NumbersAsLiterals(true)); err != nil { t.Fatal(err) } if cfg.Hex != "0x1f" { @@ -1156,14 +1154,14 @@ frac = 2.5 t.Run("invalid numbers are still parse errors", func(t *testing.T) { for _, in := range []string{"a = 01\n", "a = 1__0\n", "a = 1x\n"} { var tree map[string]any - if err := NewDecoder().UseNumber().Decode([]byte(in), &tree); err == nil { + if err := Unmarshal([]byte(in), &tree, NumbersAsLiterals(true)); err == nil { t.Errorf("%q decoded without an error", in) } } }) t.Run("without UseNumber the tree holds the evaluated kinds", func(t *testing.T) { var tree map[string]any - if err := NewDecoder().Decode([]byte("hex = 0x1f\nfrac = 2.5\n"), &tree); err != nil { + if err := Unmarshal([]byte("hex = 0x1f\nfrac = 2.5\n"), &tree); err != nil { t.Fatal(err) } if v, ok := tree["hex"].(int64); !ok || v != 31 { @@ -1507,9 +1505,8 @@ func TestLocalTimeLocation(t *testing.T) { Date LocalDate `toml:"date"` Wall LocalDateTime `toml:"wall"` } - dec := NewDecoder().LocalTimeLocation(zone) in := []byte("when = 1979-05-27T07:32:00\ndate = 1979-05-27\nwall = 1979-05-27T07:32:00\n") - if err := dec.Decode(in, &cfg); err != nil { + if err := Unmarshal(in, &cfg, LocalTimeLocation(zone)); err != nil { t.Fatal(err) } if got := cfg.When.Format("15:04:05 MST"); got != "07:32:00 CET" { @@ -1523,8 +1520,7 @@ func TestLocalTimeLocation(t *testing.T) { var cfg struct { Wall LocalDateTime `toml:"wall"` } - dec := NewDecoder().LocalTimeLocation(zone) - if err := dec.Decode([]byte("wall = 1979-05-27T07:32:00\n"), &cfg); err != nil { + if err := Unmarshal([]byte("wall = 1979-05-27T07:32:00\n"), &cfg, LocalTimeLocation(zone)); err != nil { t.Fatal(err) } if cfg.Wall.Hour() != 7 { @@ -1567,7 +1563,7 @@ func TestCancelInsideValue(t *testing.T) { b.WriteString("]\n") ctx := &errAfterN{Context: context.Background(), n: 2} var tree map[string]any - err := NewDecoder().DecodeContext(ctx, []byte(b.String()), &tree) + err := UnmarshalContext(ctx, []byte(b.String()), &tree) if err == nil { t.Fatal("a cancelled context did not stop the parse inside the value") } @@ -1585,7 +1581,7 @@ func TestCancellationSynctest(t *testing.T) { cancel() start := time.Now() var tree map[string]any - err := NewDecoder().DecodeContext(ctx, []byte("a = 1\n"), &tree) + err := UnmarshalContext(ctx, []byte("a = 1\n"), &tree) if !errors.Is(err, context.Canceled) { t.Errorf("err = %v, want context.Canceled", err) } @@ -1644,7 +1640,7 @@ func TestErrorMessagesGolden(t *testing.T) { } b.WriteString("\n") var tree map[string]any - err := NewDecoder().MaxDepth(10).Decode([]byte(b.String()), &tree) + err := Unmarshal([]byte(b.String()), &tree, MaxNestingDepth(10)) return err }, want: "interpres: line 1: nesting exceeds the limit of 10", @@ -1663,7 +1659,7 @@ func TestErrorMessagesGolden(t *testing.T) { var cfg struct { Known int `toml:"known"` } - return NewDecoder().DisallowUnknownFields().Decode([]byte("mystery = 1\n"), &cfg) + return Unmarshal([]byte("mystery = 1\n"), &cfg, RejectUnknownFields(true)) }, want: `interpres: unknown field "mystery" for struct { Known int "toml:\"known\"" }`, }, diff --git a/docs/API.md b/docs/API.md index 99282b1..8ea9708 100644 --- a/docs/API.md +++ b/docs/API.md @@ -73,12 +73,37 @@ if err := interpres.Valid(data); err != nil { } ``` -### `func UnmarshalWithOptions(data []byte, v any, opts DecodeOptions) error` +### Options -The one-shot form of a configured `Decoder`: the same options as -`NewDecoder` sets, in a `DecodeOptions` struct, applied to a single call. -The zero value takes the defaults: unknown keys ignored, numbers evaluated -as `int64` and `float64`, no size limit and the 10000-level nesting default. +The decode and encode calls take variadic options, the shape +encoding/json/v2 uses for its own. Each is a function value over the private +settings of one call, and they compose by listing: + +```go +cfg, err := interpres.Unmarshal(data, &cfg2, + interpres.RejectUnknownFields(true), + interpres.NumbersAsLiterals(true)) +``` + +Decode options: + +| Option | Default | Effect | +|---|---|---| +| `RejectUnknownFields(v bool)` | off | a key with no matching struct field is an error | +| `NumbersAsLiterals(v bool)` | off | integers and floats decode into `Number`, which carries the literal; see [Numbers as literals](#numbers-as-literals) | +| `MaxNestingDepth(depth int)` | `10000` | bound how deeply arrays and inline tables may nest | +| `MaxInputSize(size int)` | no limit | bound the size of the document, in bytes | +| `LocalTimeLocation(loc)` | nil | the zone a local date-time is carried in when it decodes into a `time.Time` | + +Encode options: + +| Option | Default | Effect | +|---|---|---| +| `Layout(kind LayoutKind)` | `LayoutKindGrouped` | group entries as scalars, then sub-tables, then arrays of tables; `LayoutKindDeclaration` preserves declaration order | +| `OmitEmptyArrays(v bool)` | off | skip `key = []` for empty scalar arrays | +| `LiteralMultiline(threshold int)` | `0` | emit multi-line strings of at least `threshold` bytes as literal `'''...'''` | +| `InlineTables(threshold int)` | `0` | write a sub-table inline when its single-line form is at most `threshold` bytes | +| `EmitFieldComments(v bool)` | off | print the `comment=` tag option of a field above its line or header | ### `func ParseAs[T any](data []byte) (T, error)` @@ -261,13 +286,13 @@ shape `json.MarshalAppend` has. A failed encoding leaves `buf` untouched. When decoding into a struct, these values convert onto the destination's concrete types: any integer or unsigned width, floats, slices, nested structs -and `map[string]T`. `Decoder.UseNumber` replaces the two numeric rows of the +and `map[string]T`. `NumbersAsLiterals` replaces the two numeric rows of the table with `Number`, which keeps the literal; see [Numbers as literals](#numbers-as-literals). ### Target constraints -`Unmarshal` and `(*Decoder).Decode` write into a non-nil pointer: +`Unmarshal`, `UnmarshalRead` and `UnmarshalContext` write into a non-nil pointer: - `*struct`, matched per the field rules below - `*map[string]any` or `*map[string]T`, keys become map keys and values decode @@ -329,7 +354,7 @@ offending key or index, for example `p: interpres: integer 300 overflows uint8`. ### Numbers as literals -`NewDecoder().UseNumber()` decodes every integer and float into `Number`, a +`NumbersAsLiterals(true)` decodes every integer and float into `Number`, a string type that carries the literal the document wrote: `0x1f`, `1_000`, `+1.0`, `inf`. The shape is validated as strictly as ever, so `01` and `1__0` remain parse errors; only the evaluated value is replaced by the literal. A @@ -338,7 +363,7 @@ default tree normalises `0x1f` to `31` and `+1.0` to `1.0`. ```go var tree map[string]any -err := interpres.NewDecoder().UseNumber().Decode(data, &tree) +err := interpres.Unmarshal(data, &tree, interpres.NumbersAsLiterals(true)) lit := tree["rate"].(interpres.Number) // "1_000" ``` @@ -366,7 +391,7 @@ error. The date-time types take a bare timestamp and never a quoted string, so a document that writes a date-time with quotes does not decode into them, and neither `encoding.TextUnmarshaler` nor the embedded `time.Time` changes that. -`NewDecoder().LocalTimeLocation(loc)` lets a local date-time fill a plain +`LocalTimeLocation(loc)` lets a local date-time fill a plain `time.Time` destination as well: the wall-clock value is carried in the location given, relabelled rather than shifted, so `07:32` in the document is `07:32` in the zone. Without the option the wrapper types are the only @@ -475,13 +500,11 @@ as an ordinary inline table, whose keys are sorted. ### Strict decoding -By default unknown keys are dropped silently. A `Decoder` built with -`DisallowUnknownFields` rejects them instead: +By default unknown keys are dropped silently. The `RejectUnknownFields` +option rejects them instead: ```go -err := interpres.NewDecoder(). - DisallowUnknownFields(). - Decode(data, &cfg) +err := interpres.Unmarshal(data, &cfg, interpres.RejectUnknownFields(true)) ``` A typo such as `database_urls` then fails with @@ -494,7 +517,7 @@ one, so it does not depend on map iteration order. ### Direct decoding For a struct destination whose type graph carries no untagged embedded map and -no custom decode hook, `Unmarshal` and `(*Decoder).Decode` parse straight into +no custom decode hook, the decode parses straight into the destination: the table skeleton is resolved against the struct schema while the document scans, and no intermediate value tree is kept. Values still flow through the ordinary assignment rules, so every conversion, hook and error the @@ -510,7 +533,7 @@ observable behaviour is always the tree path's, exactly. Nothing changes for ### Cancellation -`ParseContext`, `UnmarshalContext` and `(*Decoder).DecodeContext` accept a +`ParseContext`, `UnmarshalContext` and `MarshalContext` accept a `context.Context`. An already-cancelled context short-circuits with `context.Canceled` before any work begins; afterwards the context is checked every 64 top-level statements, and inside a value too: an array, an inline @@ -537,7 +560,7 @@ sequenceDiagram ### Input constraints -`Marshal` and `(*Encoder).Marshal` accept a `struct`, a `map[string]V`, or a +`Marshal` and `MarshalWrite` accept a `struct`, a `map[string]V`, or a non-nil pointer to one, where `V` is any value `Marshal` itself understands. A different top-level value fails: @@ -587,7 +610,7 @@ its key whether the table it came from was written inline or under a header. tables is an error under `inline`, because the inline form would re-parse as a value array and change the value's Go type. - `comment=text` carries a comment for the field, which - `NewEncoder().EmitFieldComments()` prints above the field's line or + `EmitFieldComments(true)` prints above the field's line or header, each line of a multi-line text with its own `# ` marker. Go doc comments are not visible to reflection, so the tag is the channel that carries the text; without the encoder option the tag is ignored. @@ -628,7 +651,7 @@ parsed as keys of the sub-table. instead, emitting each header immediately before its content: ```go -out, err := interpres.NewEncoder().Layout(interpres.LayoutKindDeclaration).Marshal(cfg) +out, err := interpres.Marshal(cfg, interpres.Layout(interpres.LayoutKindDeclaration)) ``` The output remains parseable, but a scalar declared after a sub-table lands @@ -724,7 +747,7 @@ switches strings that contain a newline and are at least `threshold` bytes long to the literal `'''...'''` form, which carries the newlines verbatim: ```go -out, err := interpres.NewEncoder().LiteralMultiline(80).Marshal(cfg) +out, err := interpres.Marshal(cfg, interpres.LiteralMultiline(80)) ``` Single-line strings keep the basic form regardless of the threshold, and a @@ -758,7 +781,7 @@ single-line rendering is at most `threshold` bytes, and as a table header section when it is longer. A document of small tables therefore grows shorter: ```go -out, err := interpres.NewEncoder().InlineTables(60).Marshal(cfg) +out, err := interpres.Marshal(cfg, interpres.InlineTables(60)) ``` With `60` and a table of three short entries, the same value is written @@ -777,7 +800,7 @@ header is not read back as part of that header's section. ### Cancellation -`MarshalContext` and `(*Encoder).MarshalContext` accept a `context.Context`. The +`MarshalContext` accepts a `context.Context`. The context is checked before any work and every 64 fields during the reflection walk. @@ -814,24 +837,29 @@ sequenceDiagram Marshal-->>Caller: bytes, error ``` -## Coming from encoding/json +## Coming from encoding/json and encoding/json/v2 -The API follows the shapes encoding/json made familiar, with the differences -TOML asks for: +The API follows the shapes encoding/json made familiar and the option style +encoding/json/v2 made current, with the differences TOML asks for: -| encoding/json | interpres | Notes | +| encoding/json or encoding/json/v2 | interpres | Notes | |---|---|---| | `json.Unmarshal(data, v)` | `Unmarshal(data, v)` | the same shape; the value mapping is TOML's | | `json.Marshal(v)` | `Marshal(v)` | the same shape; the output is TOML 1.1 | -| `json.MarshalAppend(buf, v)` | `MarshalAppend(buf, v)` | the same shape | -| `(*json.Decoder).DisallowUnknownFields` | `(*Decoder).DisallowUnknownFields` | the same effect; the one-shot form is `UnmarshalWithOptions` | -| `json.Number`, `(*json.Decoder).UseNumber` | `Number`, `(*Decoder).UseNumber` | the TOML literal carries its radix and separators, so `0x1f` stays `0x1f` | +| `json.MarshalAppend(buf, v)` | `MarshalAppend(buf, v)` | the same shape, options included | +| `json.MarshalWrite(w, v)` | `MarshalWrite(w, v)` | the same shape, options included | +| `json.UnmarshalRead(r, v)` | `UnmarshalRead(r, v)` | the same shape, options included | +| `json/v2 RejectUnknownMembers` | `RejectUnknownFields(true)` | the same effect under TOML vocabulary | +| `(*json.Decoder).DisallowUnknownFields` | `RejectUnknownFields(true)` | the variadic option replaces the stateful decoder | +| `json.Number`, `StringifyNumbers` | `Number`, `NumbersAsLiterals(true)` | the TOML literal carries its radix and separators, so `0x1f` stays `0x1f` | +| `json/v2 MarshalOptions` fields | `MarshalOption` values | `Layout`, `OmitEmptyArrays`, `LiteralMultiline`, `InlineTables`, `EmitFieldComments` | +| `json/v2 JoinOptions` | listing | options compose by listing them in the call | | `json.MarshalIndent` | none | TOML is the presentation format; the `-json` mode of interpres-decode prints plain JSON | | tag `json:"name,omitempty"` | tag `toml:"name,omitempty"` | the empty-value rules match encoding/json as of 2.0 | | tag `json:"name,omitzero"` | tag `toml:"name,omitzero"` | the same, `IsZero()` honoured | | tag `json:"name,inline"` (v2) | tag `toml:"name,inline"` | forces the inline table form on encode | -| `json.Marshaler` (`MarshalJSON`) | `Marshaler` (`MarshalTOML`) | the TOML method returns a value the encoder renders, not bytes | -| `json.Unmarshaler` (`UnmarshalJSON`) | `Unmarshaler` (`UnmarshalTOML`) | the data arrives decoded, not as bytes | +| `json/v2 Marshalers` | `Marshaler` (`MarshalTOML`) | the TOML method returns a value the encoder renders, not bytes | +| `json/v2 Unmarshalers` | `Unmarshaler` (`UnmarshalTOML`) | the data arrives decoded, not as bytes | | `encoding.TextMarshaler`, `TextUnmarshaler` | honoured, the same | a type that renders itself as text becomes a TOML string, both ways | | `*json.UnmarshalTypeError` | `*DecodeError` | the path is segments with a `String()` renderer, not a dotted string | | `*json.SyntaxError` | `*SyntaxError` | the TOML error adds the byte `Offset` and the `Column` to the line | @@ -887,21 +915,40 @@ The path both error wrappers carry, one segment per level from the document root. `String()` renders the TOML notation: keys join with dots, an index attaches to the previous segment in brackets, `items[0].weight`. -### `type Decoder` +### Options -Configurable strictness for decoding, constructed with `NewDecoder`. Set up -with the chainable methods, then call `Decode` or `DecodeContext` any number -of times. A configured `Decoder` holds no per-call state and is safe for -concurrent use. For a single document, `UnmarshalWithOptions(data, v, -DecodeOptions{...})` sets the same options without the Decoder; its zero -value takes the defaults. +The decode and encode entries take variadic options, the shape +encoding/json/v2 uses for its own. Each option is a stateless function value +over the private settings of one call; they compose by listing in the call, +and there is no stateful Decoder or Encoder to share or guard. -| Method | Default | Effect | +Decode options: + +| Option | Default | Effect | |---|---|---| -| `DisallowUnknownFields()` | off | a key with no matching struct field is an error | -| `UseNumber()` | off | integers and floats decode into `Number`, which carries the literal; see [Numbers as literals](#numbers-as-literals) | -| `MaxDepth(depth int)` | `10000` | bound how deeply arrays and inline tables may nest | +| `RejectUnknownFields(v bool)` | off | a key with no matching struct field is an error | +| `NumbersAsLiterals(v bool)` | off | integers and floats decode into `Number`, which carries the literal; see [Numbers as literals](#numbers-as-literals) | +| `MaxNestingDepth(depth int)` | `10000` | bound how deeply arrays and inline tables may nest | | `MaxInputSize(size int)` | no limit | bound the size of the document, in bytes | +| `LocalTimeLocation(loc)` | nil | the zone a local date-time is carried in when it decodes into a `time.Time` | + +Encode options: + +| Option | Default | Effect | +|---|---|---| +| `Layout(kind LayoutKind)` | `LayoutKindGrouped` | group entries as scalars, then sub-tables, then arrays of tables; `LayoutKindDeclaration` preserves declaration order | +| `OmitEmptyArrays(v bool)` | off | skip `key = []` for empty scalar arrays | +| `LiteralMultiline(threshold int)` | `0` | emit multi-line strings of at least `threshold` bytes as literal `'''...'''` | +| `InlineTables(threshold int)` | `0` | write a sub-table inline when its single-line form is at most `threshold` bytes | +| `EmitFieldComments(v bool)` | off | print the `comment=` tag option of a field above its line or header | + +```go +out, err := interpres.MarshalContext(ctx, cfg, + interpres.Layout(interpres.LayoutKindDeclaration), + interpres.OmitEmptyArrays(true), + interpres.LiteralMultiline(80), + interpres.InlineTables(60)) +``` The nesting limit protects the stack, because the parser is a recursive descent: a deeper document is rejected with a `SyntaxError` naming the limit @@ -910,33 +957,6 @@ default but take no options. The size limit is off by default, because the caller already holds the bytes and the size is therefore a policy, not a protection the library can impose on its own. -### `type Encoder` - -Configurable emission policy, constructed with `NewEncoder`. The option state -is private; set it with the chainable methods, each of which returns the -encoder: - -| Method | Default | Effect | -|---|---|---| -| `Layout(kind LayoutKind)` | `LayoutKindGrouped` | group entries as scalars, then sub-tables, then arrays of tables; `LayoutKindDeclaration` preserves declaration order | -| `OmitEmptyArrays()` | off | skip `key = []` for empty scalar arrays | -| `LiteralMultiline(threshold int)` | `0` | emit multi-line strings of at least `threshold` bytes as literal `'''...'''` | -| `InlineTables(threshold int)` | `0` | write a sub-table inline when its single-line form is at most `threshold` bytes | -| `EmitFieldComments()` | off | print the `comment=` tag option of a field above its line or header | - -```go -out, err := interpres.NewEncoder(). - Layout(interpres.LayoutKindDeclaration). - OmitEmptyArrays(). - LiteralMultiline(80). - InlineTables(60). - MarshalContext(ctx, cfg) -``` - -A configured `Encoder` holds no per-call state; each `Marshal` or -`MarshalContext` call copies the options and is safe for concurrent use, as -long as no setter races with a call. - ### `type Document`, `type Table`, `type Entry` See [Documents](#documents). A `Document` is what `Parse` returns, and @@ -956,7 +976,7 @@ wins when a type implements both. ### `type Number string` -The literal a number was written with, what `UseNumber` decodes into and what +The literal a number was written with, what `NumbersAsLiterals` decodes into and what `Marshal` writes back as it is. See [Numbers as literals](#numbers-as-literals). diff --git a/docs/BENCHMARKING.md b/docs/BENCHMARKING.md index b795420..00831f4 100644 --- a/docs/BENCHMARKING.md +++ b/docs/BENCHMARKING.md @@ -11,9 +11,9 @@ The benchmarks live in `bench_test.go`, next to the code they measure: |---|---| | `BenchmarkParse` | `ParseMap` over a representative configuration document | | `BenchmarkMarshal` | `Marshal` of the tree `ParseMap` produced from the same document | -| `BenchmarkStrictDecode` | `Decode` into a struct under `DisallowUnknownFields` | +| `BenchmarkStrictDecode` | `Unmarshal` into a struct under `RejectUnknownFields` (the targeted parse) | | `BenchmarkParseLong` | `ParseMap` over a generated document with about 2000 array-of-tables entries | -| `BenchmarkStrictDecodeLong` | `Decode` into a typed document under `DisallowUnknownFields`, over the same long document | +| `BenchmarkStrictDecodeLong` | `Unmarshal` into a typed document under `RejectUnknownFields`, over the same long document | | `BenchmarkMarshalLong` | `Marshal` of the tree `ParseMap` produced from the long document | ## Running diff --git a/encode.go b/encode.go index d894bfa..ba43a58 100644 --- a/encode.go +++ b/encode.go @@ -111,12 +111,22 @@ func getEncoderBuf() *bytes.Buffer { return b } +// encodeConfig carries the public emission options one Marshal call applies; +// the names and defaults are documented on the MarshalOption constructors. +type encodeConfig struct { + layout LayoutKind + omitEmptyArrays bool + literalMultilineAt int + inlineTablesAt int + emitFieldComments bool +} + // encoder produces a TOML document from a Go value via a small intermediate // representation that preserves the order in which fields were declared. type encoder struct { buf *bytes.Buffer ctx context.Context - opts Encoder + opts encodeConfig // inlineDepth is the nesting level inside inline tables, which decides // their indentation. @@ -329,7 +339,7 @@ type entry struct { type tomlDoc struct { entries []entry ctx context.Context // inherited from encoder; nil-safe - opts Encoder // inherited from encoder; options drive emit-time behaviour + opts encodeConfig // inherited from encoder; options drive emit-time behaviour depth int } diff --git a/encode_test.go b/encode_test.go index 06250cf..756ba07 100644 --- a/encode_test.go +++ b/encode_test.go @@ -91,9 +91,6 @@ func TestMarshalContextHonoursCancellation(t *testing.T) { if _, err := MarshalContext(ctx, C{A: 1}); !errors.Is(err, context.Canceled) { t.Fatalf("MarshalContext returned %v, want context.Canceled", err) } - if _, err := NewEncoder().MarshalContext(ctx, C{A: 1}); !errors.Is(err, context.Canceled) { - t.Fatalf("Encoder.MarshalContext returned %v, want context.Canceled", err) - } } func TestEncoderLayoutGroupedDefault(t *testing.T) { @@ -105,7 +102,7 @@ func TestEncoderLayoutGroupedDefault(t *testing.T) { Host string `toml:"host"` } `toml:"s"` } - out, err := NewEncoder().Marshal(Cfg{Name: "x", S: struct { + out, err := Marshal(Cfg{Name: "x", S: struct { Host string `toml:"host"` }{Host: "h"}}) if err != nil { @@ -131,7 +128,7 @@ func TestEncoderLayoutDeclarationPreservesOrder(t *testing.T) { Server: Inner{Host: "h"}, Debug: true, } - out, err := NewEncoder().Layout(LayoutKindDeclaration).Marshal(in) + out, err := Marshal(in, Layout(LayoutKindDeclaration)) if err != nil { t.Fatalf("marshal: %v", err) } @@ -161,7 +158,7 @@ func TestEncoderLayoutGroupedDefaultOrder(t *testing.T) { Server: Inner{Host: "h"}, Debug: true, } - out, err := NewEncoder().Marshal(in) + out, err := Marshal(in) if err != nil { t.Fatalf("marshal: %v", err) } @@ -176,10 +173,10 @@ func TestEncoderOmitEmptyArrays(t *testing.T) { Tags []string `toml:"tags"` Secrets []string `toml:"secrets"` } - out, err := NewEncoder().OmitEmptyArrays().Marshal(Cfg{ + out, err := Marshal(Cfg{ Tags: []string{"a", "b"}, Secrets: []string{}, - }) + }, OmitEmptyArrays(true)) if err != nil { t.Fatalf("marshal: %v", err) } @@ -193,7 +190,7 @@ func TestEncoderDefaultEmitsEmptyArray(t *testing.T) { type Cfg struct { Tags []string `toml:"tags"` } - out, err := NewEncoder().Marshal(Cfg{Tags: []string{}}) + out, err := Marshal(Cfg{Tags: []string{}}) if err != nil { t.Fatalf("marshal: %v", err) } @@ -211,10 +208,10 @@ func TestEncoderOmitEmptyArrayOfTablesStillSkipped(t *testing.T) { Title string `toml:"title"` Items []Item `toml:"items"` } - out, err := NewEncoder().OmitEmptyArrays().Marshal(Cfg{ + out, err := Marshal(Cfg{ Title: "demo", Items: nil, - }) + }, OmitEmptyArrays(true)) if err != nil { t.Fatalf("marshal: %v", err) } @@ -229,7 +226,7 @@ func TestEncoderLiteralMultiline(t *testing.T) { Long string `toml:"long"` } long := strings.Repeat("a", 50) + "\nline two\nline three" - out, err := NewEncoder().LiteralMultiline(20).Marshal(Cfg{Long: long}) + out, err := Marshal(Cfg{Long: long}, LiteralMultiline(20)) if err != nil { t.Fatalf("marshal: %v", err) } @@ -244,7 +241,7 @@ func TestEncoderLiteralMultilineBelowThreshold(t *testing.T) { type Cfg struct { Short string `toml:"short"` } - out, err := NewEncoder().LiteralMultiline(1000).Marshal(Cfg{Short: "one\ntwo"}) + out, err := Marshal(Cfg{Short: "one\ntwo"}, LiteralMultiline(1000)) if err != nil { t.Fatalf("marshal: %v", err) } @@ -259,7 +256,7 @@ func TestEncoderLiteralMultilineThresholdZero(t *testing.T) { type Cfg struct { S string `toml:"s"` } - out, err := NewEncoder().LiteralMultiline(0).Marshal(Cfg{S: "a\nb\nc\nd"}) + out, err := Marshal(Cfg{S: "a\nb\nc\nd"}, LiteralMultiline(0)) if err != nil { t.Fatalf("marshal: %v", err) } @@ -282,7 +279,7 @@ func TestEncoderLiteralMultilineFallsBackWhenUnsafe(t *testing.T) { {"lone carriage return", "first\rsecond\nthird"}, } for _, c := range cases { - out, err := NewEncoder().LiteralMultiline(5).Marshal(map[string]any{"s": c.in}) + out, err := Marshal(map[string]any{"s": c.in}, LiteralMultiline(5)) if err != nil { t.Fatalf("%s: marshal: %v", c.name, err) } @@ -482,11 +479,10 @@ func TestEncoderChainedOptions(t *testing.T) { I Inner `toml:"i"` } long := strings.Repeat("x", 200) - out, err := NewEncoder(). - Layout(LayoutKindDeclaration). - OmitEmptyArrays(). - LiteralMultiline(50). - Marshal(Cfg{S: "short", I: Inner{V: long}}) + out, err := Marshal(Cfg{S: "short", I: Inner{V: long}}, + Layout(LayoutKindDeclaration), + OmitEmptyArrays(true), + LiteralMultiline(50)) if err != nil { t.Fatalf("marshal: %v", err) } @@ -1302,7 +1298,7 @@ func TestEncoderEquivalenceToMarshal(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - b, err := NewEncoder().Marshal(in) + b, err := Marshal(in) if err != nil { t.Fatalf("encoder marshal: %v", err) } @@ -1710,7 +1706,7 @@ func TestEncoderInlineTables(t *testing.T) { // With the option both fit the threshold and become inline tables, nested // ones included. - out, err := NewEncoder().InlineTables(60).Marshal(cfg) + out, err := Marshal(cfg, InlineTables(60)) if err != nil { t.Fatalf("marshal: %v", err) } @@ -1720,7 +1716,7 @@ func TestEncoderInlineTables(t *testing.T) { } // A threshold below the rendering keeps the header form. - out, err = NewEncoder().InlineTables(10).Marshal(cfg) + out, err = Marshal(cfg, InlineTables(10)) if err != nil { t.Fatalf("marshal: %v", err) } @@ -1749,7 +1745,7 @@ func TestEncoderInlineTablesOrderAndRoundTrip(t *testing.T) { if err != nil { t.Fatalf("marshal: %v", err) } - compact, err := NewEncoder().InlineTables(20).Marshal(cfg) + compact, err := Marshal(cfg, InlineTables(20)) if err != nil { t.Fatalf("marshal: %v", err) } @@ -1785,7 +1781,7 @@ func TestEncoderInlineTablesKeepsArraysOfTables(t *testing.T) { Small inlineTLS `toml:"small"` } cfg := Cfg{Items: []Item{{N: 1}}, Small: inlineTLS{On: true}} - out, err := NewEncoder().InlineTables(60).Marshal(cfg) + out, err := Marshal(cfg, InlineTables(60)) if err != nil { t.Fatalf("marshal: %v", err) } @@ -1973,7 +1969,7 @@ func TestMarshalNumber(t *testing.T) { t.Fatalf("output %q", out) } var back map[string]any - if err := NewDecoder().UseNumber().Decode(out, &back); err != nil { + if err := Unmarshal(out, &back, NumbersAsLiterals(true)); err != nil { t.Fatal(err) } if got, ok := back["rate"].(Number); !ok || got != "1_000" { @@ -2073,7 +2069,7 @@ func TestMarshalCyclicData(t *testing.T) { }) } -func TestUnmarshalWithOptions(t *testing.T) { +func TestUnmarshalOptionsShape(t *testing.T) { data := []byte("host = \"db\"\nextra = 1\n") type Config struct { Host string `toml:"host,required"` @@ -2083,7 +2079,7 @@ func TestUnmarshalWithOptions(t *testing.T) { Host string `toml:"host"` Extra int `toml:"extra"` } - if err := UnmarshalWithOptions(data, &cfg, DecodeOptions{}); err != nil { + if err := Unmarshal(data, &cfg); err != nil { t.Fatal(err) } if cfg.Host != "db" || cfg.Extra != 1 { @@ -2091,7 +2087,7 @@ func TestUnmarshalWithOptions(t *testing.T) { } }) t.Run("strict and required work in one call", func(t *testing.T) { - err := UnmarshalWithOptions(data, &Config{}, DecodeOptions{DisallowUnknownFields: true}) + err := Unmarshal(data, &Config{}, RejectUnknownFields(true)) want := `interpres: unknown field "extra" for interpres.Config` if err == nil || err.Error() != want { t.Errorf("err = %v, want %q", err, want) @@ -2100,7 +2096,7 @@ func TestUnmarshalWithOptions(t *testing.T) { t.Run("UseNumber keeps the literal", func(t *testing.T) { var tree map[string]any in := []byte("n = 1_000\n") - if err := UnmarshalWithOptions(in, &tree, DecodeOptions{UseNumber: true}); err != nil { + if err := Unmarshal(in, &tree, NumbersAsLiterals(true)); err != nil { t.Fatal(err) } if got, ok := tree["n"].(Number); !ok || got != "1_000" { @@ -2118,10 +2114,10 @@ func TestUnmarshalWithOptions(t *testing.T) { nested.WriteString("]") } var tree map[string]any - if err := UnmarshalWithOptions([]byte(nested.String()), &tree, DecodeOptions{MaxDepth: 10}); err == nil { + if err := Unmarshal([]byte(nested.String()), &tree, MaxNestingDepth(10)); err == nil { t.Error("a document over MaxDepth decoded, want an error") } - if err := UnmarshalWithOptions([]byte("a = 1\n"), &tree, DecodeOptions{MaxInputSize: 2}); err == nil { + if err := Unmarshal([]byte("a = 1\n"), &tree, MaxInputSize(2)); err == nil { t.Error("a document over MaxInputSize decoded, want an error") } }) @@ -2253,7 +2249,7 @@ func TestEmitFieldComments(t *testing.T) { } }) t.Run("on, the comments print above their lines", func(t *testing.T) { - out, err := NewEncoder().EmitFieldComments().Marshal(cfg) + out, err := Marshal(cfg, EmitFieldComments(true)) if err != nil { t.Fatal(err) } @@ -2278,7 +2274,7 @@ func TestEmitFieldComments(t *testing.T) { type Nested struct { Inner Inner `toml:"inner,comment=The inner table"` } - out, err := NewEncoder().EmitFieldComments().Marshal(Nested{Inner: Inner{1}}) + out, err := Marshal(Nested{Inner: Inner{1}}, EmitFieldComments(true)) if err != nil { t.Fatal(err) } diff --git a/example_test.go b/example_test.go index facea37..8f2d881 100644 --- a/example_test.go +++ b/example_test.go @@ -70,12 +70,11 @@ func ExampleMarshal() { // port = 9090 } -func ExampleDecoder() { +func ExampleNumbersAsLiterals() { var tree map[string]any - err := interpres.NewDecoder(). - DisallowUnknownFields(). - UseNumber(). - Decode([]byte("rate = 1_000\n"), &tree) + err := interpres.Unmarshal([]byte("rate = 1_000\n"), &tree, + interpres.RejectUnknownFields(true), + interpres.NumbersAsLiterals(true)) if err != nil { log.Fatal(err) } @@ -83,16 +82,15 @@ func ExampleDecoder() { // Output: 1_000 1_000 } -func ExampleEncoder() { +func ExampleInlineTables() { type Config struct { Title string `toml:"title"` Extras map[string]string `toml:"extras,inline"` } - out, err := interpres.NewEncoder(). - Layout(interpres.LayoutKindDeclaration). - LiteralMultiline(80). - InlineTables(40). - Marshal(Config{Title: "demo", Extras: map[string]string{"b": "two", "a": "one"}}) + out, err := interpres.Marshal(Config{Title: "demo", Extras: map[string]string{"b": "two", "a": "one"}}, + interpres.Layout(interpres.LayoutKindDeclaration), + interpres.LiteralMultiline(80), + interpres.InlineTables(40)) if err != nil { log.Fatal(err) } diff --git a/examples/basic/main.go b/examples/basic/main.go index 89adfff..04825cd 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -134,7 +134,7 @@ func Run(stdout, stderr io.Writer) int { } fmt.Fprintf(stdout, "\n--- marshal (group by kind, default) ---\n%s", out) - out2, err := interpres.NewEncoder().Layout(interpres.LayoutKindDeclaration).Marshal(cfg) + out2, err := interpres.Marshal(cfg, interpres.Layout(interpres.LayoutKindDeclaration)) if err != nil { fmt.Fprintln(stderr, "marshal:", err) return 1 diff --git a/fuzz_target_test.go b/fuzz_target_test.go index 71c4ecf..8af59e9 100644 --- a/fuzz_target_test.go +++ b/fuzz_target_test.go @@ -109,7 +109,7 @@ func FuzzTargetedDecode(f *testing.F) { f.Fuzz(func(t *testing.T, data []byte) { doc := fuzzDocument(data) var tgt fuzzDoc - tgtErr := NewDecoder().Decode(doc, &tgt) + tgtErr := Unmarshal(doc, &tgt) if tgtErr != nil { // A document with several decode-stage findings reports a different // one per run (the tree decode walks its maps in random order), so the diff --git a/interpres.go b/interpres.go index 2d07dba..9bfaadc 100644 --- a/interpres.go +++ b/interpres.go @@ -17,7 +17,7 @@ // tree := doc.Map() // // A Decoder allows strict decoding that rejects keys without a matching -// struct field, mirroring (*json.Decoder).DisallowUnknownFields. +// struct field, mirroring the RejectUnknownFields option of encoding/json/v2. package interpres import ( @@ -259,6 +259,11 @@ func parseWithOptions(ctx context.Context, data []byte, opts parseOptions, wantD // Unmarshal parses a TOML document and stores the result in the value pointed // to by v. v is typically a pointer to a struct or to a map[string]any. // +// Unmarshal parses a TOML document and stores the result in the value pointed +// to by v. v is typically a pointer to a struct or to a map[string]any. +// Options tune the call; with none, unknown keys are ignored, numbers are +// evaluated, and the nesting default applies. +// // Struct fields are matched to TOML keys by the `toml:"name"` tag, or by a // case-insensitive match on the field name when no tag is present. A tag of // "-" skips the field. @@ -269,8 +274,8 @@ func parseWithOptions(ctx context.Context, data []byte, opts parseOptions, wantD // bare integer as its nanosecond count. // // Unmarshal is equivalent to UnmarshalContext with context.Background. -func Unmarshal(data []byte, v any) error { - return UnmarshalContext(context.Background(), data, v) +func Unmarshal(data []byte, v any, opts ...UnmarshalOption) error { + return UnmarshalContext(context.Background(), data, v, opts...) } // ParseAs decodes a TOML document into T in one call, the generic shorthand @@ -304,120 +309,76 @@ func NewSchema[T any]() { } // UnmarshalContext is the cancellable variant of Unmarshal. -func UnmarshalContext(ctx context.Context, data []byte, v any) error { - dec := newDecoder() - dec.ctx = ctx - if canTargetDecode(v) { - // The targeted parse fills struct destinations without the - // intermediate tree; a document or destination it cannot model falls - // back to the tree path, whose contracts it keeps. - if err := parseIntoTargeted(ctx, data, dec, false, 0, v); err != errTargetFallback { - return err - } - } - // Only a destination that can reach an OrderedMap needs the node tree the - // written key order is read from; every other decode skips building it. - tree, doc, err := parseWithOptions(ctx, data, parseOptions{}, typeWantsOrder(reflect.TypeOf(v))) - if err != nil { - return err - } - dec.nodes = indexNodes(doc.Root()) - return dec.decode(tree, v) +func UnmarshalContext(ctx context.Context, data []byte, v any, opts ...UnmarshalOption) error { + return settingsFor(opts).decode(ctx, data, v) } -// A Decoder decodes a TOML document into a Go value with configurable -// strictness and configurable limits on the parse it performs. -type Decoder struct { +// UnmarshalRead reads the document from r and decodes it into v, the +// streaming-shaped entry the json/v2 vocabulary uses. The reader is +// consumed in full, because the parser scans its source in place; the +// options and the behaviour are Unmarshal's. +func UnmarshalRead(r io.Reader, v any, opts ...UnmarshalOption) error { + data, err := io.ReadAll(r) + if err != nil { + return fmt.Errorf("interpres: read: %w", err) + } + return Unmarshal(data, v, opts...) +} + +// An UnmarshalOption configures one Unmarshal, UnmarshalContext, +// UnmarshalRead or ParseAs call. Options are function values over the +// private decode settings, the shape encoding/json/v2 uses for its own +// options, and compose by simple listing: +// +// err := interpres.Unmarshal(data, &cfg, +// interpres.RejectUnknownFields(true), +// interpres.NumbersAsLiterals(true)) +// +// A destination that the direct skeleton cannot model falls back to the +// tree path, so every option means the same thing on every document. +type UnmarshalOption func(*decodeSettings) + +// decodeSettings is the option carrier of one decode call. +type decodeSettings struct { disallowUnknown bool useNumber bool maxDepth int maxInputSize int localLoc *time.Location + ctx context.Context } -// NewDecoder returns a Decoder. -func NewDecoder() *Decoder { return &Decoder{} } - -// DisallowUnknownFields causes Decode to return an error when the document -// contains a key with no matching destination struct field. -func (d *Decoder) DisallowUnknownFields() *Decoder { - d.disallowUnknown = true - return d +func settingsFor(opts []UnmarshalOption) *decodeSettings { + s := &decodeSettings{ctx: context.Background()} + for _, opt := range opts { + opt(s) + } + return s } -// UseNumber causes the numbers of the document to reach the value tree as a -// Number carrying the literal the document wrote, so 0x1f, 1_000, +1.0 and -// inf survive a round trip with their spelling intact. A destination of a -// concrete numeric kind still takes the evaluated value; the literal is kept -// only where a Number, or an any, receives it. -func (d *Decoder) UseNumber() *Decoder { - d.useNumber = true - return d -} - -// LocalTimeLocation sets the zone a local date-time is placed in when it -// decodes into a time.Time destination. Without the option a local date-time -// fills only its own wrapper type (LocalDateTime, LocalDate, LocalTime), -// whose embedded time.Time is UTC; with the option, a time.Time destination -// takes the value too, carried in the location given. A nil location restores -// the default. -func (d *Decoder) LocalTimeLocation(loc *time.Location) *Decoder { - d.localLoc = loc - return d -} - -// MaxDepth bounds how deeply arrays and inline tables may nest in a document -// this decoder accepts. The parser is a recursive descent, so a document that -// nests without bound would exhaust the stack; one that nests deeper than the -// limit is rejected with a SyntaxError naming it instead. Use 0 or any -// negative value for the default of 10000, which no hand-written document -// approaches. -func (d *Decoder) MaxDepth(depth int) *Decoder { - d.maxDepth = depth - return d -} - -// MaxInputSize bounds the size of a document this decoder accepts, in bytes; a -// larger one is rejected before parsing starts. Use 0 or any negative value for -// no limit, which is the default: the caller already holds the bytes, so the -// size is a policy the caller sets rather than a protection the library -// imposes on its own. Parse and ParseContext take no limit beyond the nesting -// default. -func (d *Decoder) MaxInputSize(size int) *Decoder { - d.maxInputSize = size - return d -} - -// Decode parses data and stores the result in the value pointed to by v, -// honouring the decoder's strictness settings. -// -// Decode is equivalent to DecodeContext with context.Background. -func (d *Decoder) Decode(data []byte, v any) error { - return d.DecodeContext(context.Background(), data, v) -} - -// DecodeContext is the cancellable variant of Decode. -func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error { +// decode runs the decode the settings describe: the targeted parse when the +// destination takes it, the tree path otherwise or on fallback. +func (s *decodeSettings) decode(ctx context.Context, data []byte, v any) error { dec := newDecoder() - dec.disallowUnknown = d.disallowUnknown + dec.disallowUnknown = s.disallowUnknown dec.ctx = ctx - dec.loc = d.localLoc + dec.loc = s.localLoc if canTargetDecode(v) { // The targeted parse fills struct destinations without the // intermediate tree; a document or destination it cannot model falls // back to the tree path, whose contracts it keeps. The size limit is // checked here, the targeted parse being the parse itself. - if d.maxInputSize > 0 && len(data) > d.maxInputSize { - return fmt.Errorf("interpres: input is %d bytes, over the limit of %d", len(data), d.maxInputSize) + if s.maxInputSize > 0 && len(data) > s.maxInputSize { + return fmt.Errorf("interpres: input is %d bytes, over the limit of %d", len(data), s.maxInputSize) } - if err := parseIntoTargeted(ctx, data, dec, d.useNumber, d.maxDepth, v); err != errTargetFallback { + if err := parseIntoTargeted(ctx, data, dec, s.useNumber, s.maxDepth, v); err != errTargetFallback { return err } } opts := parseOptions{ - maxDepth: d.maxDepth, - maxInputSize: d.maxInputSize, - useNumber: d.useNumber, + maxDepth: s.maxDepth, + maxInputSize: s.maxInputSize, + useNumber: s.useNumber, } tree, doc, err := parseWithOptions(ctx, data, opts, typeWantsOrder(reflect.TypeOf(v))) if err != nil { @@ -427,33 +388,50 @@ func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error { return dec.decode(tree, v) } -// DecodeOptions gathers the options a one-shot decode call can set, the -// struct-shaped alternative to building a Decoder for a single document. The -// zero value decodes with the defaults: unknown keys ignored, numbers -// evaluated, and no limit beyond the nesting default. -type DecodeOptions struct { - // DisallowUnknownFields rejects a key with no matching struct field. - DisallowUnknownFields bool - // UseNumber keeps the numbers of the document as Number literals. - UseNumber bool - // MaxDepth bounds how deeply arrays and inline tables may nest; 0 takes - // the default of 10000. - MaxDepth int - // MaxInputSize bounds the document size in bytes; 0 takes no limit. - MaxInputSize int +// RejectUnknownFields makes the decode fail when the document contains a +// key with no matching destination struct field. Off by default: unknown +// keys are ignored. +func RejectUnknownFields(v bool) UnmarshalOption { + return func(s *decodeSettings) { s.disallowUnknown = v } } -// UnmarshalWithOptions decodes data into v with the options set, the one-shot -// form of building a Decoder. See DecodeOptions for the fields and their -// defaults. -func UnmarshalWithOptions(data []byte, v any, opts DecodeOptions) error { - dec := &Decoder{ - disallowUnknown: opts.DisallowUnknownFields, - useNumber: opts.UseNumber, - maxDepth: opts.MaxDepth, - maxInputSize: opts.MaxInputSize, - } - return dec.DecodeContext(context.Background(), data, v) +// NumbersAsLiterals keeps the numbers of the document as a Number carrying +// the literal the document wrote, so 0x1f, 1_000, +1.0 and inf survive a +// round trip with their spelling intact. A destination of a concrete numeric +// kind still takes the evaluated value; the literal is kept only where a +// Number, or an any, receives it. Off by default: numbers evaluate to +// int64 and float64. +func NumbersAsLiterals(v bool) UnmarshalOption { + return func(s *decodeSettings) { s.useNumber = v } +} + +// LocalTimeLocation sets the zone a local date-time is placed in when it +// decodes into a time.Time destination. Without the option a local date-time +// fills only its own wrapper type (LocalDateTime, LocalDate, LocalTime), +// whose embedded time.Time is UTC; with the option, a time.Time destination +// takes the value too, carried in the location given. A nil location restores +// the default. +func LocalTimeLocation(loc *time.Location) UnmarshalOption { + return func(s *decodeSettings) { s.localLoc = loc } +} + +// MaxNestingDepth bounds how deeply arrays and inline tables may nest in a +// document the decode accepts. The parser is a recursive descent, so a +// document that nests without bound would exhaust the stack; one that nests +// deeper than the limit is rejected with a SyntaxError naming it instead. +// Use 0 or any negative value for the default of 10000, which no +// hand-written document approaches. +func MaxNestingDepth(depth int) UnmarshalOption { + return func(s *decodeSettings) { s.maxDepth = depth } +} + +// MaxInputSize bounds the size of a document the decode accepts, in bytes; a +// larger one is rejected before parsing starts. Use 0 or any negative value +// for no limit, which is the default: the caller already holds the bytes, so +// the size is a policy the caller sets rather than a protection the library +// imposes on its own. +func MaxInputSize(size int) UnmarshalOption { + return func(s *decodeSettings) { s.maxInputSize = size } } // Marshaler is the interface implemented by types that can produce a custom @@ -543,9 +521,13 @@ type UnmarshalerContext interface { // string quoting style, and the choice between `[table]` headers and inline // tables are not preserved. // +// Marshal returns the TOML encoding of v. Options tune the emission; with +// none, the layout groups entries by kind, empty arrays emit and sub-tables +// take the header form. +// // Marshal is equivalent to MarshalContext with context.Background. -func Marshal(v any) ([]byte, error) { - return MarshalContext(context.Background(), v) +func Marshal(v any, opts ...MarshalOption) ([]byte, error) { + return MarshalContext(context.Background(), v, opts...) } // A Statement is one top-level statement of a document, what Statements @@ -612,10 +594,10 @@ func Statements(r io.Reader) iter.Seq2[Statement, error] { } // MarshalAppend appends the TOML encoding of v to buf and returns the extended -// buffer, the shape json.MarshalAppend has. A failed encoding leaves buf -// untouched and comes back with a nil slice. -func MarshalAppend(buf []byte, v any) ([]byte, error) { - out, err := Marshal(v) +// buffer, the shape json/v2's MarshalAppendTo and json's MarshalAppend have. +// A failed encoding leaves buf untouched and comes back with a nil slice. +func MarshalAppend(buf []byte, v any, opts ...MarshalOption) ([]byte, error) { + out, err := Marshal(v, opts...) if err != nil { return nil, err } @@ -623,11 +605,25 @@ func MarshalAppend(buf []byte, v any) ([]byte, error) { } // MarshalContext is the cancellable variant of Marshal. -func MarshalContext(ctx context.Context, v any) ([]byte, error) { +func MarshalContext(ctx context.Context, v any, opts ...MarshalOption) ([]byte, error) { if err := ctx.Err(); err != nil { return nil, err } - return NewEncoder().MarshalContext(ctx, v) + return settingsForEncode(opts).marshal(ctx, v) +} + +// MarshalWrite encodes v and writes the document to w, the streaming-shaped +// entry the json/v2 vocabulary uses. The options and the behaviour are +// Marshal's. +func MarshalWrite(w io.Writer, v any, opts ...MarshalOption) error { + out, err := Marshal(v, opts...) + if err != nil { + return err + } + if _, err := w.Write(out); err != nil { + return fmt.Errorf("interpres: write: %w", err) + } + return nil } // A LayoutKind names the layout the encoder writes a document's entries in. @@ -642,48 +638,58 @@ const ( LayoutKindDeclaration ) -// An Encoder encodes Go values into TOML. +// A MarshalOption configures one Marshal, MarshalContext, MarshalAppend or +// MarshalWrite call. Options are function values over the private encode +// settings, the shape encoding/json/v2 uses for its own, and compose by +// simple listing: // -// All options default to the behaviour that passes the toml-test compliance -// suite in both directions: -// -// Layout: LayoutKindGrouped (scalars first, then tables, then -// arrays of tables) -// OmitEmptyArrays: false (a nil/empty []string slice emits [] as a value; -// a nil/empty []Item struct slice is still skipped) -// LiteralMultilineAt: 0 (always emit the escaped basic form, never a -// literal one) -// InlineTablesAt: 0 (always emit a table header, never an inline -// table) -// -// Use the chainable option methods to opt out. The option state is private; -// callers that need the underlying knobs reach for the methods rather than -// reading or mutating fields. -type Encoder struct { - layout LayoutKind // default LayoutKindGrouped; set via (*Encoder).Layout - omitEmptyArrays bool // default false; set via (*Encoder).OmitEmptyArrays - literalMultilineAt int // default 0; set via (*Encoder).LiteralMultiline - inlineTablesAt int // default 0; set via (*Encoder).InlineTables - emitFieldComments bool // default false; set via (*Encoder).EmitFieldComments +// out, err := interpres.Marshal(cfg, +// interpres.Layout(interpres.LayoutKindDeclaration), +// interpres.InlineTables(60)) +type MarshalOption func(*encodeSettings) + +// encodeSettings is the option carrier of one encode call. +type encodeSettings struct { + ctx context.Context + cfg encodeConfig } -// NewEncoder returns an Encoder with default options. -func NewEncoder() *Encoder { return &Encoder{layout: LayoutKindGrouped} } +func settingsForEncode(opts []MarshalOption) *encodeSettings { + s := &encodeSettings{ctx: context.Background(), cfg: encodeConfig{layout: LayoutKindGrouped}} + for _, opt := range opts { + opt(s) + } + return s +} + +// marshal runs the encode the settings describe. +func (s *encodeSettings) marshal(ctx context.Context, v any) ([]byte, error) { + enc := newEncoder() + enc.ctx = ctx + enc.opts = s.cfg + if err := enc.encode(v); err != nil { + enc.release() + return nil, err + } + // The output leaves the pooled buffer as a copy, so the next Marshal + // reuses the buffer without touching what the caller holds. + out := slices.Clone(enc.buf.Bytes()) + enc.release() + return out, nil +} // Layout sets the layout the encoder writes a document's entries in: // LayoutKindGrouped, the default, reorders them scalars first, then tables, // then arrays of tables; LayoutKindDeclaration preserves declaration order. -func (e *Encoder) Layout(kind LayoutKind) *Encoder { - e.layout = kind - return e +func Layout(kind LayoutKind) MarshalOption { + return func(s *encodeSettings) { s.cfg.layout = kind } } // OmitEmptyArrays opts in to skipping empty (non-nil, length 0) TOML arrays // of scalars. The default emits them as "key = []". Nil slices and empty // arrays of tables are already always omitted. -func (e *Encoder) OmitEmptyArrays() *Encoder { - e.omitEmptyArrays = true - return e +func OmitEmptyArrays(v bool) MarshalOption { + return func(s *encodeSettings) { s.cfg.omitEmptyArrays = v } } // LiteralMultiline sets the length threshold at which a multi-line string @@ -691,9 +697,8 @@ func (e *Encoder) OmitEmptyArrays() *Encoder { // Use 0 or any negative value to disable (always escaped). The literal form // is selected only when the value contains an internal newline; otherwise the // single-line basic form is used regardless of this setting. -func (e *Encoder) LiteralMultiline(threshold int) *Encoder { - e.literalMultilineAt = threshold - return e +func LiteralMultiline(threshold int) MarshalOption { + return func(s *encodeSettings) { s.cfg.literalMultilineAt = threshold } } // InlineTables sets the size limit, in bytes of the single-line rendering, at @@ -709,9 +714,8 @@ func (e *Encoder) LiteralMultiline(threshold int) *Encoder { // With LayoutKindDeclaration the layout is already for presentation only, and an // inlined table follows the same rule as any other value line: it lands in the // section of the header that precedes it. -func (e *Encoder) InlineTables(threshold int) *Encoder { - e.inlineTablesAt = threshold - return e +func InlineTables(threshold int) MarshalOption { + return func(s *encodeSettings) { s.cfg.inlineTablesAt = threshold } } // EmitFieldComments turns on printing the comment a field's `toml` tag @@ -724,30 +728,6 @@ func (e *Encoder) InlineTables(threshold int) *Encoder { // that carries the text. Off by default, and a field without a `comment=` // option prints none. Multi-line comments carry newlines in the tag, each // line printed with its own "# " marker. -func (e *Encoder) EmitFieldComments() *Encoder { - e.emitFieldComments = true - return e -} - -// Marshal encodes v to TOML bytes. It is equivalent to calling Marshal with v. -// -// Marshal is equivalent to MarshalContext with context.Background. -func (e *Encoder) Marshal(v any) ([]byte, error) { - return e.MarshalContext(context.Background(), v) -} - -// MarshalContext is the cancellable variant of Marshal. -func (e *Encoder) MarshalContext(ctx context.Context, v any) ([]byte, error) { - enc := newEncoder() - enc.ctx = ctx - enc.opts = *e - if err := enc.encode(v); err != nil { - enc.release() - return nil, err - } - // The output leaves the pooled buffer as a copy, so the next Marshal - // reuses the buffer without touching what the caller holds. - out := slices.Clone(enc.buf.Bytes()) - enc.release() - return out, nil +func EmitFieldComments(v bool) MarshalOption { + return func(s *encodeSettings) { s.cfg.emitFieldComments = v } } diff --git a/interpres_test.go b/interpres_test.go index fa3918e..78a762b 100644 --- a/interpres_test.go +++ b/interpres_test.go @@ -317,7 +317,7 @@ func TestDisallowUnknownFields(t *testing.T) { } var strict C - err := NewDecoder().DisallowUnknownFields().Decode(data, &strict) + err := Unmarshal(data, &strict, RejectUnknownFields(true)) if err == nil { t.Fatal("expected error for unknown field, got nil") } @@ -333,7 +333,7 @@ func TestDisallowUnknownFieldsReportsSmallestKey(t *testing.T) { data := []byte("known = \"x\"\nzeta = 1\nalpha = 2\nmu = 3\n") for range 20 { var c C - err := NewDecoder().DisallowUnknownFields().Decode(data, &c) + err := Unmarshal(data, &c, RejectUnknownFields(true)) if err == nil { t.Fatal("expected error for unknown fields") } diff --git a/number.go b/number.go index 32a966e..c210507 100644 --- a/number.go +++ b/number.go @@ -11,7 +11,8 @@ import ( ) // A Number holds a TOML number as the literal the document wrote it with: -// 0x1f, 1_000, +1.0, inf. Decoder.UseNumber decodes integers and floats into +// 0x1f, 1_000, +1.0, inf. The NumbersAsLiterals option decodes integers and +// floats into // it, so a round trip through the value tree keeps the spelling instead of a // normalised one, and Marshal writes the literal back as it is. // diff --git a/orderedmap_test.go b/orderedmap_test.go index e6e4ae9..4d59ab2 100644 --- a/orderedmap_test.go +++ b/orderedmap_test.go @@ -97,7 +97,7 @@ func TestMarshalOrderedMap(t *testing.T) { m := NewOrderedMap() m.Set("zebra", int64(1)) m.Set("alpha", int64(2)) - out, err := NewEncoder().InlineTables(60).Marshal(map[string]any{"t": m}) + out, err := Marshal(map[string]any{"t": m}, InlineTables(60)) if err != nil { t.Fatal(err) } diff --git a/parser.go b/parser.go index 786fc30..ae05190 100644 --- a/parser.go +++ b/parser.go @@ -726,7 +726,7 @@ func (p *parser) parseAtom() (any, error) { if err != nil { return nil, p.errf("%s", err) } - // The token's shape is validated either way; UseNumber only keeps the + // The token's shape is validated either way; NumbersAsLiterals only keeps the // literal instead of the evaluated value. if p.useNumber { return Number(tok), nil diff --git a/target_test.go b/target_test.go index 4882988..5256b07 100644 --- a/target_test.go +++ b/target_test.go @@ -54,7 +54,7 @@ func TestTargetedStrictFindings(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var cfg targetCfg - err := NewDecoder().DisallowUnknownFields().Decode([]byte(tt.doc), &cfg) + err := Unmarshal([]byte(tt.doc), &cfg, RejectUnknownFields(true)) if err == nil { t.Fatalf("no error, want %q", tt.want) } @@ -102,7 +102,7 @@ func TestTargetedParseErrors(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var cfg targetCfg - err := NewDecoder().Decode([]byte(tt.doc), &cfg) + err := Unmarshal([]byte(tt.doc), &cfg) if err == nil { t.Fatalf("no error, want %q", tt.want) } @@ -121,7 +121,7 @@ func TestTargetedSilentShapes(t *testing.T) { // sink's own x is a different key, the tree's shape exactly. var cfg, ref targetCfg in := []byte("[tab]\nx = 1\n[tab.nested]\n") - if err := NewDecoder().Decode(in, &cfg); err != nil { + if err := Unmarshal(in, &cfg); err != nil { t.Fatalf("decode: %v", err) } if err := treeDecodeInto(in, &ref); err != nil { @@ -137,7 +137,7 @@ func TestTargetedSilentShapes(t *testing.T) { t.Run("unknown keys are ignored without strict", func(t *testing.T) { var cfg, ref targetCfg in := []byte("num = 5\nz1 = 1\n[zz]\nk = 1\n") - if err := NewDecoder().Decode(in, &cfg); err != nil { + if err := Unmarshal(in, &cfg); err != nil { t.Fatalf("decode: %v", err) } if err := treeDecodeInto(in, &ref); err != nil { @@ -153,7 +153,7 @@ func TestTargetedSilentShapes(t *testing.T) { t.Run("an inline table into a map field", func(t *testing.T) { var cfg targetCfg in := []byte("lims = { cpu = 4, deep = { a = true } }\n") - if err := NewDecoder().Decode(in, &cfg); err != nil { + if err := Unmarshal(in, &cfg); err != nil { t.Fatalf("decode: %v", err) } if cfg.Lims["cpu"] != int64(4) { @@ -162,7 +162,7 @@ func TestTargetedSilentShapes(t *testing.T) { }) t.Run("an overflow falls back to the decode error", func(t *testing.T) { var cfg targetCfg - err := NewDecoder().Decode([]byte("small = 300\n"), &cfg) + err := Unmarshal([]byte("small = 300\n"), &cfg) want := "interpres: small: integer 300 overflows uint8" if err == nil || err.Error() != want { t.Errorf("err = %v, want %q", err, want) @@ -185,7 +185,7 @@ func TestTargetedSilentShapes(t *testing.T) { var cfg struct { Rate Number `toml:"rate"` } - if err := NewDecoder().UseNumber().Decode([]byte("rate = 1_000\n"), &cfg); err != nil { + if err := Unmarshal([]byte("rate = 1_000\n"), &cfg, NumbersAsLiterals(true)); err != nil { t.Fatal(err) } if cfg.Rate != "1_000" { @@ -195,7 +195,7 @@ func TestTargetedSilentShapes(t *testing.T) { t.Run("dotted keys fill a map field", func(t *testing.T) { var cfg targetCfg in := []byte("lims.a.b = true\nlims.c = 3\n") - if err := NewDecoder().Decode(in, &cfg); err != nil { + if err := Unmarshal(in, &cfg); err != nil { t.Fatalf("decode: %v", err) } if cfg.Lims["c"] != int64(3) { @@ -205,7 +205,7 @@ func TestTargetedSilentShapes(t *testing.T) { t.Run("an inline table cannot be extended", func(t *testing.T) { var cfg targetCfg in := []byte("lims = { a = 1 }\n[lims.deep]\nb = 2\n") - err := NewDecoder().Decode(in, &cfg) + err := Unmarshal(in, &cfg) if err == nil || !strings.Contains(err.Error(), "cannot extend inline table") { t.Errorf("err = %v, want the inline-table extension error", err) } @@ -233,8 +233,7 @@ func TestTargetedShapesMatrix(t *testing.T) { for i, doc := range docs { var ref, tgt targetCfg refErr := treeDecodeInto([]byte(doc), &ref) - dec := NewDecoder() - tgtErr := dec.Decode([]byte(doc), &tgt) + tgtErr := Unmarshal([]byte(doc), &tgt) if (refErr == nil) != (tgtErr == nil) { t.Errorf("doc %d: error presence disagrees: tree %v, targeted %v", i, refErr, tgtErr) continue @@ -283,7 +282,7 @@ func TestTargetedFallbackContracts(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var cfg targetCfg - err := NewDecoder().Decode([]byte(tt.doc), &cfg) + err := Unmarshal([]byte(tt.doc), &cfg) if err == nil || err.Error() != tt.want { t.Errorf("err = %v, want %q", err, tt.want) } @@ -297,7 +296,7 @@ func TestTargetedFallbackContracts(t *testing.T) { func TestTargetedHeaderOnAssignedScalarArray(t *testing.T) { var cfg targetCfg in := []byte("arr = []\n[[arr]]\nx = 1\n") - err := NewDecoder().Decode(in, &cfg) + err := Unmarshal(in, &cfg) want := "interpres: line 2: key \"arr\" is not an array of tables" if err == nil || err.Error() != want { t.Errorf("err = %v, want %q", err, want) @@ -362,7 +361,7 @@ func TestTargetedBranchParity(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var cfg targetCfg - err := NewDecoder().DisallowUnknownFields().Decode([]byte(tt.doc), &cfg) + err := Unmarshal([]byte(tt.doc), &cfg, RejectUnknownFields(true)) if tt.want == "" { if err != nil { t.Fatalf("err = %v, want nil", err) @@ -386,7 +385,7 @@ func TestTargetedDecodeHookFields(t *testing.T) { } var cfg Cfg in := []byte("ip = \"192.0.2.1\"\ndur = \"1h30m\"\nunm = \"hello\"\n") - if err := NewDecoder().Decode(in, &cfg); err != nil { + if err := Unmarshal(in, &cfg); err != nil { t.Fatal(err) } if cfg.IP.String() != "192.0.2.1" { @@ -410,7 +409,7 @@ func TestTargetedOddShapes(t *testing.T) { } var cfg, ref Cfg doc := []byte("when = 1979-05-27 07:32:00Z\n") - if err := NewDecoder().Decode(doc, &cfg); err != nil { + if err := Unmarshal(doc, &cfg); err != nil { t.Fatal(err) } if err := treeDecodeInto(doc, &ref); err != nil { @@ -426,7 +425,7 @@ func TestTargetedOddShapes(t *testing.T) { } var cfg, ref Cfg doc := []byte("m = { a = 1 }\n") - err := NewDecoder().Decode(doc, &cfg) + err := Unmarshal(doc, &cfg) refErr := treeDecodeInto(doc, &ref) if err == nil || refErr == nil { t.Fatalf("err = %v, refErr = %v, want both to fail", err, refErr) @@ -438,7 +437,7 @@ func TestTargetedOddShapes(t *testing.T) { t.Run("a repeated dotted map key is a duplicate", func(t *testing.T) { var cfg targetCfg doc := []byte("lims.a.b = 1\nlims.a.b = 2\n") - err := NewDecoder().Decode(doc, &cfg) + err := Unmarshal(doc, &cfg) want := "interpres: line 2: duplicate key \"b\"" if err == nil || err.Error() != want { t.Errorf("err = %v, want %q", err, want) @@ -447,7 +446,7 @@ func TestTargetedOddShapes(t *testing.T) { t.Run("an underscored integer takes the token path", func(t *testing.T) { var cfg targetCfg doc := []byte("num = 1_000\n") - if err := NewDecoder().Decode(doc, &cfg); err != nil { + if err := Unmarshal(doc, &cfg); err != nil { t.Fatal(err) } if cfg.Num != 1000 { @@ -459,7 +458,7 @@ func TestTargetedOddShapes(t *testing.T) { Big int64 `toml:"big"` } doc := []byte("big = 999999999999999999\n") - if err := NewDecoder().Decode(doc, &cfg); err != nil { + if err := Unmarshal(doc, &cfg); err != nil { t.Fatal(err) } if cfg.Big != 999999999999999999 { @@ -506,7 +505,7 @@ func TestTargetedMapTableShapes(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { var cfg, ref targetCfg - err := NewDecoder().Decode([]byte(tt.doc), &cfg) + err := Unmarshal([]byte(tt.doc), &cfg) refErr := treeDecodeInto([]byte(tt.doc), &ref) if (err == nil) != (refErr == nil) { t.Fatalf("error presence disagrees: tree %v, targeted %v", refErr, err)