feat!: rework the public API to json/v2-style variadic options
Test / test (push) Successful in 1m49s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-22 18:42:18 +02:00
parent 7ee155d1e9
commit 2e61ad0ba9
18 changed files with 398 additions and 393 deletions
+169 -189
View File
@@ -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 }
}