Files
interpres/interpres.go
T

312 lines
12 KiB
Go
Raw Normal View History

2026-08-19 09:47:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Package interpres is a dependency-free TOML parser for Go.
//
// interpres reads and writes TOML documents using only the standard library.
// It exposes a small, encoding/json-style API:
//
// var cfg Config
// err := interpres.Unmarshal(data, &cfg)
//
// out, err := interpres.Marshal(cfg)
//
// or, for an untyped tree:
//
// tree, err := interpres.Parse(data)
//
// A Decoder allows strict decoding that rejects keys without a matching
// struct field, mirroring (*json.Decoder).DisallowUnknownFields.
package interpres
import (
"context"
"errors"
2026-08-19 09:47:00 +02:00
"fmt"
"unicode/utf8"
)
// A SyntaxError describes a malformed TOML document, including the 1-based
// line on which the problem was detected.
type SyntaxError struct {
Line int
Msg string
}
func (e *SyntaxError) Error() string {
return fmt.Sprintf("interpres: line %d: %s", e.Line, e.Msg)
}
// A DecodeError wraps a decoding failure with the key path at which it
// happened. Path lists one segment per level from the document root, the
// outermost key first: a key contributes its name and an array element its
// bracketed index, so the path of the weight field in the first item reads
// ["items", "[0]", "weight"]. The rendered message is unchanged by the type;
// read it programmatically with errors.AsType:
//
// if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
// fmt.Println(de.Path, de.Err)
// }
type DecodeError struct {
// Path is the key path from the document root, outermost key first.
Path []string
// Err is the failure at that path.
Err error
}
func (e *DecodeError) Error() string { return e.Path[0] + ": " + e.Err.Error() }
// Unwrap returns the failure the path points at.
func (e *DecodeError) Unwrap() error { return e.Err }
// newDecodeError wraps err with one path segment. The rest of the path comes
// from the DecodeError err already carries, if any: the decoder wraps each
// key and index on its way down, so the innermost wrap holds the deepest
// segments and each outer wrap prepends one.
func newDecodeError(key string, err error) *DecodeError {
path := make([]string, 0, 4)
path = append(path, key)
if de, ok := errors.AsType[*DecodeError](err); ok {
path = append(path, de.Path...)
}
return &DecodeError{Path: path, Err: err}
}
// An EncodeError wraps an encoding failure with the key path of the value
// that failed, in the notation of a TOML document: fields join with dots and
// an array element carries its bracketed index, so the path of the third
// port under server reads "server.ports[2]". The rendered message is
// unchanged by the type; read it programmatically with errors.AsType.
type EncodeError struct {
// Path is the key path of the failing value.
Path string
// Err is the failure at that path.
Err error
}
func (e *EncodeError) Error() string { return "interpres: " + e.Path + ": " + e.Err.Error() }
// Unwrap returns the failure the path points at.
func (e *EncodeError) Unwrap() error { return e.Err }
2026-08-19 09:47:00 +02:00
// Parse decodes a TOML document into a nested map[string]any.
//
// Values are mapped to Go types as follows: strings to string, integers to
// int64, floats to float64, booleans to bool, date-times to time.Time, arrays
// to []any, and tables (including inline tables) to map[string]any.
//
// Parse is equivalent to ParseContext with context.Background.
func Parse(data []byte) (map[string]any, error) {
return ParseContext(context.Background(), data)
}
// ParseContext decodes a TOML document into a nested map[string]any, obeying
// ctx. The context is checked between top-level statements so cancellation is
// honoured before the parser has done substantial work.
func ParseContext(ctx context.Context, data []byte) (map[string]any, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
if !utf8.Valid(data) {
return nil, &SyntaxError{Line: 1, Msg: "input is not valid UTF-8"}
}
p := &parser{src: []rune(string(data)), line: 1, ctx: ctx}
return p.parse()
}
// 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.
//
// 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.
//
// Unmarshal is equivalent to UnmarshalContext with context.Background.
func Unmarshal(data []byte, v any) error {
return UnmarshalContext(context.Background(), data, v)
}
// UnmarshalContext is the cancellable variant of Unmarshal.
func UnmarshalContext(ctx context.Context, data []byte, v any) error {
tree, err := ParseContext(ctx, data)
if err != nil {
return err
}
return newDecoder().decode(tree, v)
}
// A Decoder decodes a TOML document into a Go value with configurable
// strictness.
type Decoder struct {
disallowUnknown bool
}
// 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
}
// 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 {
tree, err := ParseContext(ctx, data)
if err != nil {
return err
}
dec := newDecoder()
dec.disallowUnknown = d.disallowUnknown
return dec.decode(tree, v)
}
// Marshaler is the interface implemented by types that can produce a custom
// TOML representation of themselves. MarshalTOML returns a value that Marshal
// then encodes as if the returned value had been passed in its place, which
// is useful for emitting a Go type as a different TOML shape (for example, a
// struct as an inline table or a primitive alias as a richer value).
type Marshaler interface {
MarshalTOML() (any, error)
}
// Unmarshaler is the inverse of Marshaler: a type that wants control over
// how it is decoded from a TOML value may implement UnmarshalTOML. The data
// argument is whatever the parser produced for that key: one of string,
// bool, int64, float64, time.Time, LocalDateTime, LocalDate, LocalTime,
// []any, or map[string]any. UnmarshalTOML may parse, inspect, or transform
// the value however it likes, then store the result by mutating its
// receiver through the standard pointer-indirection rules of the reflect
// package (i.e. via reflect.Value.Set or by reassigning fields through a
// pointer the receiver holds).
//
// UnmarshalTOML is invoked from (*Decoder).Decode / Unmarshal when the
// destination type implements the interface. The decoder does not need to
// consult the concrete return value; whatever the receiver stores is kept.
type Unmarshaler interface {
UnmarshalTOML(data any) error
}
2026-09-17 22:06:26 +02:00
// Marshal returns the TOML encoding of v. The output stays within TOML 1.0,
// so it is valid under both TOML 1.0 and 1.1.
2026-08-19 09:47:00 +02:00
//
// Marshal traverses v using reflection and applies the following rules:
//
// - The top-level value must be a struct or a map[string]V. Pointers are
// followed; a nil top-level pointer is an error.
// - Struct fields are matched by `toml:"name"` tag (case-insensitive
// fallback to field name; `-` skips). The tag options `omitzero` (skip
// the zero value of the field's type) and `omitempty` (skip an empty
// slice, array, or map) drop a field from the output on encode; the
// decoder ignores them. Anonymous (embedded) fields without a tag are
// inlined.
2026-08-19 09:47:00 +02:00
// - Maps use sorted keys for deterministic output.
// - Slices and arrays of structs or maps become TOML arrays of tables; a
// nil or empty array of tables is omitted (TOML forbids an empty `[[a]]`),
// while other empty arrays emit as `key = []`.
// - Other slices and arrays become TOML arrays; a table element inside a
// value array (for example an inline table in a mixed array) emits as an
// inline table.
2026-08-19 09:47:00 +02:00
// - Scalars encode as TOML scalars: bool, int64, float64, string, time.Time
// (offset date-time), and LocalDateTime/LocalDate/LocalTime (local
// variants).
// - Values implementing Marshaler are encoded by calling MarshalTOML and
// using its result.
// - nil pointer fields are omitted.
//
// Marshal cannot encode cyclic data structures; passing one will loop until
// the stack overflows. The output is not guaranteed to be byte-identical to
// the input that produced v: comments, whitespace, key order (for maps),
// string quoting style, and the choice between `[table]` headers and inline
// tables are not preserved.
//
// Marshal is equivalent to MarshalContext with context.Background.
func Marshal(v any) ([]byte, error) {
return MarshalContext(context.Background(), v)
}
// MarshalContext is the cancellable variant of Marshal.
func MarshalContext(ctx context.Context, v any) ([]byte, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
return NewEncoder().MarshalContext(ctx, v)
}
// An Encoder encodes Go values into TOML.
//
// All options default to behaviour that preserves byte-for-byte compatibility
// with previous releases and passes the toml-test compliance suite:
//
// GroupByKind: true (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 basic multi-line strings with
// escape sequences, never literal ones)
//
// 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 {
groupByKind bool // default true; set via (*Encoder).GroupByKind
omitEmptyArrays bool // default false; set via (*Encoder).OmitEmptyArrays
literalMultilineAt int // default 0; set via (*Encoder).UseLiteralMultiline
}
// NewEncoder returns an Encoder with default options.
func NewEncoder() *Encoder { return &Encoder{groupByKind: true} }
// GroupByKind toggles whether fields at the same TOML level are reordered
// into the group-by-kind layout (scalars first, then tables, then arrays of
// tables). When set to false, the emitter preserves the source declaration
// order (struct field order, or sorted key order for maps).
func (e *Encoder) GroupByKind(v bool) *Encoder {
e.groupByKind = v
return e
}
// 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
}
// UseLiteralMultiline sets the length threshold at which a multi-line string
// is emitted as a literal triple-quoted string instead of the escaped form.
// 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) UseLiteralMultiline(threshold int) *Encoder {
e.literalMultilineAt = threshold
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 {
return nil, err
}
return enc.bytes(), nil
}