21 KiB
API
The library exports the surface below from the sourcedock.dev/petrbalvin/interpres/v2
package. The snippets assume:
import "sourcedock.dev/petrbalvin/interpres/v2"
The parser implements TOML 1.1: date-times and times without seconds, the
\e and \xHH escape sequences, and multi-line inline tables with comments
and trailing commas. The encoder emits TOML 1.1.
Functions
func Parse(data []byte) (map[string]any, error)
Decodes a TOML document into an untyped tree, using the value mapping in the
Decoding section below. Returns *SyntaxError on a malformed
document. Input that is not valid UTF-8 is rejected before the parser runs.
Equivalent to ParseContext(context.Background(), data).
tree, err := interpres.Parse([]byte("title = \"x\"\nport = 8080\n"))
func ParseContext(ctx context.Context, data []byte) (map[string]any, error)
The cancellable variant of Parse. An already-cancelled context returns
ctx.Err() before any work. During parsing the context is checked every 64
top-level statements, so a long document aborts without running to completion.
func Unmarshal(data []byte, v any) error
Parses data and stores the result in the value pointed to by v, typically a
pointer to a struct or to map[string]any. Equivalent to
UnmarshalContext(context.Background(), data, v).
var cfg Config
if err := interpres.Unmarshal(data, &cfg); err != nil {
return err
}
func UnmarshalContext(ctx context.Context, data []byte, v any) error
The cancellable variant of Unmarshal.
func Marshal(v any) ([]byte, error)
Encodes a struct or map[string]V value, or a non-nil pointer to one, into a
TOML document. The emission rules are in the Encoding section
below. Equivalent to MarshalContext(context.Background(), v).
out, err := interpres.Marshal(cfg)
func MarshalContext(ctx context.Context, v any) ([]byte, error)
The cancellable variant of Marshal. The context is checked before any work
and every 64 fields during the reflection walk.
Decoding
Value mapping
Parse and Unmarshal map TOML values to Go types as follows:
| TOML value | Go type in the parsed tree |
|---|---|
| string | string |
| integer | int64 |
| float | float64 |
| boolean | bool |
| offset date-time | time.Time |
| local date-time | LocalDateTime |
| local date | LocalDate |
| local time | LocalTime |
| array | []any |
| table, inline table | map[string]any |
| array of tables | []map[string]any |
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.
Target constraints
Unmarshal and (*Decoder).Decode write into a non-nil pointer:
*struct, matched per the field rules below*map[string]anyor*map[string]T, keys become map keys and values decode intoTrecursively*any, receives the whole parsed tree unchanged
Anything else returns interpres: decode target must be a non-nil pointer.
Field matching
For a struct destination, a TOML key matches a field as follows:
- The
toml:"name"tag, using the part before any comma. The literal-excludes the field. - Without a tag, the lower-cased field name.
- An anonymous (embedded) field without a tag is inlined: the decoder walks into the embedded struct and matches its own fields against the same keys, mirroring how the encoder flattens it. A nil embedded pointer struct is allocated on demand. An untagged embedded map receives the keys no field claims.
- The key itself is lower-cased before lookup, so the match is
case-insensitive on both sides:
DATABASEURLmatches a field namedDatabaseUrl.
The match is exact after lower-casing. No separator is inserted, so a TOML key
database_url does not match a field named DatabaseUrl; tag such a field
(toml:"database_url") or use the lower-cased name as the key. When two
fields resolve to the same name, the shallower one wins; at equal depth, the
one declared later wins.
Unknown keys are ignored by default, landing in an untagged embedded map when the struct has one; Strict decoding rejects them instead.
Numeric conversion
The parser produces int64 for every integer and float64 for every float.
The decoder converts to the destination type with explicit overflow checks:
| Destination kind | Rule |
|---|---|
int, int8, int16, int32, int64 |
the int64 value must not overflow the destination |
uint, uint8, uint16, uint32, uint64 |
the value must be non-negative and must not overflow the destination's own width, uint on a 32-bit platform included; uint64 accepts any non-negative int64 |
float32, float64 |
copied verbatim, except that a finite value beyond the float32 range is an overflow error rather than a silent infinity; an integer also coerces, so TOML 5 decodes into 5.0 |
bool, string |
exact kind match only, no coercion across kinds |
time.Time |
offset date-times only; no implicit conversion to or from the local variants |
A conversion that the rules do not allow produces an error wrapped with the
offending key or index, for example p: interpres: integer 300 overflows uint8.
Date-time values
Offset date-times decode into time.Time and keep their offset. The local
variants decode into LocalDateTime, LocalDate and LocalTime, whose
embedded time.Time is normalised to UTC (midnight UTC for a local date, the
zero date for a local time). Every kind may omit the seconds as of TOML 1.1
(07:32, 1979-05-27T07:32); such a value carries a zero second, and the
canonical rendering writes full seconds. There is no implicit conversion
between the offset and local kinds; assigning one to the other is an error.
The four 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.
Arrays of tables
A [[a]] block parses into a []map[string]any element of the tree. When the
destination is a slice, each element decodes into the slice's element type
([]struct or []map[string]V); a mismatch on one element surfaces as an
error wrapped with [i]: and the element index.
Custom decoding: Unmarshaler
A type that wants full control of its decode implements:
type Unmarshaler interface {
UnmarshalTOML(data any) error
}
data is whatever the parser produced for that key: string, bool, int64,
float64, time.Time, LocalDateTime, LocalDate, LocalTime, []any, or
map[string]any. The method inspects the value and mutates its own receiver;
the decoder keeps whatever state the receiver stored.
The method is usually on a pointer receiver (*T). The decoder invokes it when
the destination type or its pointer implements the interface, so a
pointer-receiver implementation on an addressable struct field is found
automatically, and a nil pointer destination is allocated first. An error
returned from UnmarshalTOML halts the decode and propagates wrapped with the
key path, for example addr: unmarshal: not a string.
Custom decoding: encoding.TextUnmarshaler
A destination type that implements encoding.TextUnmarshaler receives a TOML
string as its text content, the rule encoding/json follows:
func (ip *IP) UnmarshalText(text []byte) error
The decoder looks for the method on the destination and on its address, so a
pointer-receiver UnmarshalText is invoked on an addressable struct field, and
the elements of a slice destination are reached the same way. The text path
applies to TOML strings only: every other value kind keeps its own rule, so
r = 1 does not reach a receiver that expects text. An error from
UnmarshalText halts the decode and propagates with the key path and the
prefix unmarshal text:, for example addr: unmarshal text: not an address.
UnmarshalTOML wins over UnmarshalText when
a type implements both, and the four date-time
types are excluded: a quoted string stays a string and
never becomes a time.Time or one of the local wrappers.
Durations
TOML has no duration type, so time.Duration has a rule of its own. The
encoder writes the canonical Go form in a TOML string, 1h30m0s, and the
decoder reads that string back with time.ParseDuration. A bare integer is
still the nanosecond count it has always been, so from_int = 5400000000000
and from_text = "1h30m" decode to the same duration. Text that
time.ParseDuration rejects, d = "90" among it, fails with
interpres: invalid duration "90".
Strict decoding
By default unknown keys are dropped silently. A Decoder built with
DisallowUnknownFields rejects them instead:
err := interpres.NewDecoder().
DisallowUnknownFields().
Decode(data, &cfg)
A typo such as database_urls then fails with
interpres: unknown field "database_urls" for main.Config instead of a silent
default-zero run. Strictness applies to every struct the decode reaches, at any
depth, including struct elements inside slices; map destinations accept every
key by nature. When several keys are unknown, the message names the smallest
one, so it does not depend on map iteration order.
Cancellation
ParseContext, UnmarshalContext and (*Decoder).DecodeContext 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.
Flow
sequenceDiagram
participant Caller
participant Unmarshal as Unmarshal
participant Parser as parser
participant Decoder as decoder
Caller->>Unmarshal: data, v
Unmarshal->>Parser: ParseContext(ctx, data)
Parser-->>Unmarshal: tree or *SyntaxError
Unmarshal->>Decoder: decode(tree, reflect value)
Decoder-->>Unmarshal: nil or wrapped field error
Unmarshal-->>Caller: error
Encoding
Input constraints
Marshal and (*Encoder).Marshal 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:
| Input | Error |
|---|---|
| a bare scalar or array | interpres: top-level value must be a struct or map[string]V, got <type> |
a nil any |
interpres: cannot marshal nil value |
| a nil pointer | interpres: cannot marshal nil pointer |
Field matching
Struct fields become TOML keys as follows:
- The
toml:"name"tag, using the part before any comma. The literal-skips the field. - Without a tag, the lower-cased field name. The key emitted for a field named
DatabaseUrlisdatabaseurl; tag the field to emitdatabase_url. - An anonymous (embedded) field without a tag is inlined into the parent table; with a tag it is a regular field under that name.
Keys that match [A-Za-z0-9_-]+ are emitted bare, all others quoted. A
map[string]V emits its keys in sorted order for deterministic output, and a
nil map emits nothing.
Tag options
The part of a toml tag after the first comma carries options. Both options
shape emission only; the decoder ignores them.
omitzeroskips the field when its value is the zero value of its type. A type with anIsZero() boolmethod (time.Time among them) decides through that method, so a zerotime.Timeor an all-zero struct disappears from the output.omitemptyskips the field when it holds an empty collection: a nil or empty slice or array, or a nil or empty map. Strings and other scalars are not covered byomitempty; useomitzerofor those.
type Config struct {
Host string `toml:"host,omitzero"`
Started time.Time `toml:"started,omitzero"`
Tags []string `toml:"tags,omitempty"`
}
Options combine after the name: toml:"name,omitempty,omitzero" is valid, and
an unknown option is ignored.
Untagged embedded fields round-trip: the decoder inlines embedded structs and routes unclaimed keys into an embedded map exactly where the encoder flattened them.
Group-by-kind layout
By default every table is emitted with its entries grouped by kind:
- scalars (
string,int64,float64,bool,time.Time,LocalDateTime,LocalDate,LocalTime) - sub-tables (structs and
map[string]Vvalues) - arrays of tables (
[]structand[]map[string]V)
Within each group the order follows struct field declaration order, or sorted
key order for maps. This is the only layout that reliably re-parses to the same
tree: once a [header] is written, later scalars at the parent level would be
parsed as keys of the sub-table.
Preserving declaration order
GroupByKind(false) on an Encoder walks the entries in declaration order
instead, emitting each header immediately before its content:
out, err := interpres.NewEncoder().GroupByKind(false).Marshal(cfg)
The output remains parseable, but a scalar declared after a sub-table lands under that sub-table's header when the document is read back. Use this layout for presentation only, not when the output must round-trip.
Custom encoding: Marshaler
A type that wants a non-default TOML shape implements:
type Marshaler interface {
MarshalTOML() (any, error)
}
The returned value is encoded as if it had been passed in place of the
receiver, so it may be a scalar, a slice, an array of tables, or another
struct or map, including the Marshaler result of another type; the encoder
recurses. An error returned from MarshalTOML fails the marshal wrapped with
the key path, for example interpres: server.port: bad timestamp. A result
of nil with a nil error fails the same way with
MarshalTOML returned a nil value: nil has no TOML representation, so
dropping the field silently is not an option.
type Port int
func (p Port) MarshalTOML() (any, error) {
return int64(p), nil
}
Custom encoding: encoding.TextMarshaler
A type that implements encoding.TextMarshaler is encoded as a TOML string
holding the text the method returns, which is the rule encoding/json follows:
func (ip IP) MarshalText() ([]byte, error)
The encoder looks for the method on the value and on its address, so a
pointer-receiver MarshalText is found on a struct field of an addressable
value (pass a pointer to Marshal) and always on a slice element. net.IP,
netip.Addr and user types follow this rule, and a struct that implements the
interface becomes a string rather than a table. MarshalTOML wins when a type
implements both, the four date-time types keep their bare
timestamp form, and text that is not valid UTF-8 is an error rather than a
replacement character.
A duration carries no text method of its own; see Durations for its rule.
Arrays
An array whose every element is a table ([]struct, []map[string]V, after
pointer dereference) emits as an array of tables. TOML also lets one array mix
tables with scalars; such an array emits as a plain value array, with the
table elements rendered as inline tables:
tree, _ := interpres.Parse([]byte(`arr = [1, {a = 2}, "x"]`))
out, _ := interpres.Marshal(tree) // arr = [1, {a = 2}, "x"]
A []any holding only tables keeps the value-array form as well, because that
is the shape Parse gives a value array of inline tables; emitting it as
[[headers]] would re-parse as []map[string]any and change the value's type
across a round-trip.
Empty arrays
A nil slice is always omitted. An empty (length 0) array of tables is always
omitted, because TOML forbids an empty [[a]]. Other empty arrays emit as
key = [] by default; OmitEmptyArrays() skips them as well, so
[]string{} is treated like a nil slice.
Long strings
By default every string is emitted as a basic "..." string with the escapes
TOML requires, and a string containing a newline is emitted as an escaped
multi-line basic string. UseLiteralMultiline(threshold) switches strings that
contain a newline and are at least threshold bytes long to the literal
'''...''' form, which carries the newlines verbatim:
out, err := interpres.NewEncoder().UseLiteralMultiline(80).Marshal(cfg)
Single-line strings keep the basic form regardless of the threshold, and a
threshold of 0 or less disables the option. A string the literal form cannot
carry verbatim (an embedded run of three single quotes, a control character
other than tab or newline, or a carriage return outside a CRLF pair) also keeps
the basic form, so the output always re-parses to the same value.
Cancellation
MarshalContext and (*Encoder).MarshalContext accept a context.Context. The
context is checked before any work and every 64 fields during the reflection
walk.
What is not preserved
The output is not byte-identical to any document that produced the value:
- comments are dropped, and whitespace inside expressions is normalised
- map keys are emitted in sorted order
- the choice between
[table]headers and inline tables is not preserved - strings use the basic quoted form unless the literal option above applies
- floats always carry a
.or an exponent, so a float1is emitted as1.0and stays distinguishable from the integer1across a round-trip; negative zero is normalised to0.0
The output is guaranteed to re-parse through Parse into an equivalent value
tree. Marshal cannot encode cyclic data structures.
Flow
sequenceDiagram
participant Caller
participant Marshal as Marshal
participant Walk as reflection walk
participant Emit as emitter
Caller->>Marshal: v any
Marshal->>Walk: build tomlDoc from struct or map
Walk-->>Marshal: tomlDoc or wrapped error
Marshal->>Emit: emitDoc(doc)
Emit-->>Marshal: bytes or error
Marshal-->>Caller: bytes, error
Types
type SyntaxError struct{ Line int; Msg string }
Describes a malformed TOML document; Line is 1-based and Error() renders as
interpres: line N: msg. Read the structured fields with a type assertion or
errors.AsType:
if se, ok := errors.AsType[*interpres.SyntaxError](err); ok {
fmt.Println(se.Line, se.Msg)
}
type DecodeError struct{ Path []string; Err error }
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, 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 the fields instead of parsing
the message:
if de, ok := errors.AsType[*interpres.DecodeError](err); ok {
fmt.Println(de.Path, de.Err)
}
type EncodeError struct{ Path string; Err error }
Wraps an encoding failure with the key path of the value that failed, in the
document's own notation: server.ports[2]. Read it with errors.AsType the
same way.
type Decoder
Configurable strictness for decoding, constructed with NewDecoder. Set up
with DisallowUnknownFields, then call Decode or DecodeContext any number
of times. A configured Decoder holds no per-call state and is safe for
concurrent use.
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 |
|---|---|---|
GroupByKind(v bool) |
true |
group entries as scalars, then sub-tables, then arrays of tables; false preserves declaration order |
OmitEmptyArrays() |
off | skip key = [] for empty scalar arrays |
UseLiteralMultiline(threshold int) |
0 |
emit multi-line strings of at least threshold bytes as literal '''...''' |
out, err := interpres.NewEncoder().
GroupByKind(false).
OmitEmptyArrays().
UseLiteralMultiline(80).
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 Marshaler interface{ MarshalTOML() (any, error) }
See Custom encoding.
type Unmarshaler interface{ UnmarshalTOML(data any) error }
See Custom decoding.
Date-time wrappers
type LocalDateTime struct{ time.Time } // 1979-05-27T07:32:00
type LocalDate struct{ time.Time } // 1979-05-27
type LocalTime struct{ time.Time } // 07:32:00.999999
Each carries a String() method returning the TOML-canonical rendering, with
the fractional second zero-padded to nanosecond precision when present. The
types are produced by Parse and accepted by Marshal.
Errors
The entry points return:
*SyntaxErrorfor a malformed document, with the 1-based line*DecodeErrorfor a decoding failure, with the key path inPath*EncodeErrorfor an encoding failure, with the key path inPath- a plain error for the rest: a non-pointer decode target, a cancelled context, a key that is not valid UTF-8
Decode and encode failures carry the key path or element index in the typed
wrappers above, so errors.Is and errors.AsType see through them and the
path reads from a field instead of the message text.
Notes
The exported surface is documented in godoc form in the source, and go doc .
run from the module root is the authority on signatures and types. This file
explains what the surface is for and how the parts fit together.