diff --git a/CHANGELOG.md b/CHANGELOG.md index f9bcc8e..d66203c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,43 @@ All notable changes to **interpres** are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [1.1.0] — 2026-06-26 + +Adds TOML emission to the previously read-only library, completing the +`encoding/json`-style API. + +### Added + +**Marshalling** + +- `Marshal(v any) ([]byte, error)` — encode a struct or `map[string]V` value + to a TOML 1.0 document. +- `Encoder` and `NewEncoder`, mirroring the `Decoder` shape for symmetry with + `Unmarshal`. +- `Marshaler` interface (`MarshalTOML() (any, error)`) for types that need a + custom TOML shape; the returned value is encoded normally. +- Struct fields are matched by `toml:"name"` tag (case-insensitive fallback to + field name; `toml:"-"` skips). Anonymous (embedded) fields without a tag + are inlined. +- Slices and arrays of structs or maps become `[[a]]` arrays of tables; other + slices and arrays become TOML arrays. Empty or nil arrays of tables are + omitted; empty arrays of scalars emit as `key = []`. +- Scalars encode as bool, int64, float64, string, `time.Time` (offset + date-time), `LocalDateTime`, `LocalDate`, or `LocalTime`. +- `time.Time`, `LocalDateTime`, `LocalDate`, and `LocalTime` now have + `String()` methods that return their TOML-canonical rendering + (zero-padded to nanosecond precision when a fractional second is present). + +**Encoding policy** + +The emitter groups fields at every TOML level: scalars first, then +sub-tables, then arrays of tables. Within each group the order matches struct +field declaration order, or sorted key order for maps. The output is +guaranteed to re-parse to an equivalent value tree via `Parse`, but is not +byte-identical to any input that produced the value — comments, whitespace, +map key order, and basic-vs-literal string quoting are not preserved. See +the "Encoding" section of the README for the full set of rules. + ## [1.0.0] — 2026-06-20 First stable release: a dependency-free TOML 1.0 parser for Go that uses only diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index f648037..8698ae6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -52,12 +52,14 @@ Run this before and after any parser change; it must stay at zero failures. ``` interpres/ -├── interpres.go # public API: Parse, Unmarshal, Decoder, SyntaxError +├── interpres.go # public API: Parse, Unmarshal, Marshal, Decoder, Encoder, SyntaxError ├── parser.go # recursive-descent parser → map[string]any ├── number.go # strict numeric token parsing ├── datetime.go # date-time types and parsing ├── decode.go # reflection mapping of the tree onto Go values -├── interpres_test.go # test suite +├── encode.go # reflection-based marshal of Go values to TOML +├── interpres_test.go # parser/decoder test suite +├── encode_test.go # encoder test suite ├── cmd/interpres-decode/ # toml-test harness adapter └── examples/basic/ # runnable usage example ``` diff --git a/README.md b/README.md index c7f0a8d..854e162 100644 --- a/README.md +++ b/README.md @@ -55,12 +55,48 @@ port = 9090 } ``` +### Encode from a struct + +```go +out, err := interpres.Marshal(cfg) +if err != nil { + panic(err) +} +fmt.Println(string(out)) +``` + +writes the same struct back to TOML: + +```toml +title = "example" + +[server] +host = "127.0.0.1" +port = 9090 +``` + ### Untyped tree ```go -tree, err := interpres.Parse(data) // map[string]any +tree, err := interpres.Parse(data) // []byte → map[string]any +out, err := interpres.Marshal(tree) // map[string]any → []byte ``` +### Custom encoding + +Types that need a non-default TOML shape can implement `Marshaler`: + +```go +type Port int + +func (p Port) MarshalTOML() (any, error) { + return int64(p), nil +} +``` + +The returned value is encoded just like any other value passed to `Marshal`, +which means it can be any type `Marshal` itself understands. + ### Strict decoding Reject keys that have no matching struct field — useful for catching typos in @@ -95,6 +131,49 @@ skips the field. When decoding into a struct, these map onto the destination's concrete types (any integer/unsigned/float width, slices, nested structs, and `map[string]T`). +## Encoding + +`Marshal` produces valid TOML 1.0 from any `struct` or `map[string]V` value, +applying these rules: + +- The top-level value must be a struct or `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; `toml:"-"` skips). Anonymous (embedded) fields without a tag + are inlined. +- Maps emit keys in sorted order for deterministic output. +- **Output is grouped by kind at every level**: scalars come first, then + sub-tables, then arrays of tables. Within each group the order matches + struct field declaration order (or, for maps, sorted key order). This means + in TOML terms a struct that mixes scalars and sub-tables always lays out + scalars at the top of the section followed by the table headers — there is + no way to interleave them and still produce a document that re-parses to + the same tree. +- Slices and arrays of structs or maps become `[[a]]` arrays of tables. Empty + or nil arrays of tables are omitted (TOML forbids an empty `[[a]]`); empty + arrays of scalars emit as `key = []`. +- Other slices and arrays become TOML arrays, including mixed-type `[]any`. +- Scalars map to TOML scalars: bool, int64, float64, string, `time.Time` + (offset date-time), and the local variants (`LocalDateTime`, `LocalDate`, + `LocalTime`). +- Strings are always emitted as basic `"..."` strings with the escapes TOML + requires (`\"`, `\\`, control characters as `\uXXXX`). +- Keys are emitted as bare keys when they match `[A-Za-z0-9_-]+`, quoted + otherwise. +- Floats always carry a `.` or an exponent (so `1` is always emitted as + `1.0`), which preserves the distinction between an integer-typed value and + a float-typed value across a `Marshal` → `Parse` round-trip. +- Values implementing `Marshaler` are encoded by calling `MarshalTOML` and + using its result. +- `nil` pointer fields are omitted. + +`Marshal` cannot encode cyclic data structures. The output is not guaranteed +to be byte-identical to any TOML document that produced the input value: +comments, whitespace, key order (for maps), the choice between `[table]` +headers and inline tables, and the basic-vs-literal quoting style are not +preserved. The emitted document is, however, guaranteed to be re-parsable by +`interpres.Parse` back into an equivalent value tree. + ## TOML 1.0 support `interpres` implements the full [TOML 1.0](https://toml.io/en/v1.0.0) grammar: diff --git a/datetime.go b/datetime.go index bbd40e1..ad432a0 100644 --- a/datetime.go +++ b/datetime.go @@ -1,6 +1,7 @@ package interpres import ( + "fmt" "regexp" "strings" "time" @@ -22,6 +23,32 @@ type LocalDate struct{ time.Time } // The embedded time.Time uses the zero date. type LocalTime struct{ time.Time } +// String returns the TOML-canonical rendering of the local date-time, e.g. +// "1979-05-27T07:32:00" or "...:00.000000123" when the time has a fractional +// second. The fractional component is zero-padded to nanosecond precision. +func (ldt LocalDateTime) String() string { + base := ldt.Format("2006-01-02T15:04:05") + if ns := ldt.Nanosecond(); ns > 0 { + return base + "." + fmt.Sprintf("%09d", ns) + } + return base +} + +// String returns the TOML-canonical rendering of the local date, e.g. +// "1979-05-27". +func (ld LocalDate) String() string { return ld.Format("2006-01-02") } + +// String returns the TOML-canonical rendering of the local time, e.g. +// "07:32:00" or "...:00.000000123" when the time has a fractional second. +// The fractional component is zero-padded to nanosecond precision. +func (lt LocalTime) String() string { + base := lt.Format("15:04:05") + if ns := lt.Nanosecond(); ns > 0 { + return base + "." + fmt.Sprintf("%09d", ns) + } + return base +} + var ( offsetDateTimeLayouts = []string{ "2006-01-02T15:04:05.999999999Z07:00", diff --git a/encode.go b/encode.go new file mode 100644 index 0000000..011fe14 --- /dev/null +++ b/encode.go @@ -0,0 +1,626 @@ +package interpres + +import ( + "bytes" + "fmt" + "math" + "reflect" + "sort" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +var ( + localDateTimeType = reflect.TypeOf(LocalDateTime{}) + localDateType = reflect.TypeOf(LocalDate{}) + localTimeType = reflect.TypeOf(LocalTime{}) + timeGoType = reflect.TypeOf(time.Time{}) +) + +// 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 +} + +func newEncoder() *encoder { return &encoder{} } + +func (e *encoder) bytes() []byte { return e.buf.Bytes() } + +// encode converts v into a TOML document. v must be a struct or a +// map[string]V (or a non-nil pointer to one). +func (e *encoder) encode(v any) error { + rv := reflect.ValueOf(v) + if !rv.IsValid() { + return fmt.Errorf("interpres: cannot marshal nil value") + } + if rv.Kind() == reflect.Pointer { + if rv.IsNil() { + return fmt.Errorf("interpres: cannot marshal nil pointer") + } + rv = rv.Elem() + } + doc := &tomlDoc{} + switch rv.Kind() { + case reflect.Struct: + if err := buildStructDoc(rv, doc, ""); err != nil { + return err + } + case reflect.Map: + if err := buildMapDoc(rv, doc, ""); err != nil { + return err + } + default: + return fmt.Errorf("interpres: top-level value must be a struct or map[string]V, got %s", rv.Type()) + } + return e.emitDoc(doc, nil) +} + +// --- intermediate representation ----------------------------------------- + +// tomlDoc holds the entries of one TOML table, partitioned by kind. Fields +// declared at the same TOML level are grouped: scalars come first, then +// sub-tables, then arrays of tables. The order WITHIN each group matches +// either struct field order (for structs) or sorted key order (for maps). +type tomlDoc struct { + scalars []tomlKV + tables []tomlTable + arrays []tomlArray +} + +type tomlKV struct { + key string + val any +} + +type tomlTable struct { + key string + doc *tomlDoc +} + +type tomlArray struct { + key string + docs []*tomlDoc +} + +func (d *tomlDoc) addScalar(key string, val any) { + d.scalars = append(d.scalars, tomlKV{key: key, val: val}) +} + +func (d *tomlDoc) addTable(key string, sub *tomlDoc) { + d.tables = append(d.tables, tomlTable{key: key, doc: sub}) +} + +func (d *tomlDoc) addArray(key string, subs []*tomlDoc) { + d.arrays = append(d.arrays, tomlArray{key: key, docs: subs}) +} + +// --- reflection walk: struct --------------------------------------------- + +func buildStructDoc(v reflect.Value, doc *tomlDoc, ctx string) error { + t := v.Type() + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + if f.PkgPath != "" { + continue + } + if f.Anonymous { + tag, _ := f.Tag.Lookup("toml") + if tag == "-" { + continue + } + if tag == "" { + fv := followPtr(v.Field(i)) + if !fv.IsValid() { + continue + } + switch fv.Kind() { + case reflect.Struct: + if isScalarStruct(fv.Type()) { + name := strings.ToLower(f.Name) + if err := doc.appendScalar(name, fv.Interface(), ctx); err != nil { + return err + } + continue + } + if err := buildStructDoc(fv, doc, ctx); err != nil { + return err + } + continue + case reflect.Map: + if err := buildMapDoc(fv, doc, ctx); err != nil { + return err + } + continue + } + } + } + name := fieldName(f) + if name == "-" { + continue + } + if err := addField(doc, name, v.Field(i), ctx); err != nil { + return err + } + } + return nil +} + +// fieldName returns the TOML key for a struct field, honouring the `toml` +// tag (name or `-`) and falling back to a lower-cased field name. +func fieldName(f reflect.StructField) string { + if tag, ok := f.Tag.Lookup("toml"); ok { + name := strings.Split(tag, ",")[0] + if name == "-" { + return "-" + } + if name != "" { + return name + } + } + return strings.ToLower(f.Name) +} + +// --- reflection walk: map ------------------------------------------------ + +func buildMapDoc(v reflect.Value, doc *tomlDoc, ctx string) error { + if v.Type().Key().Kind() != reflect.String { + return fmt.Errorf("interpres: map key must be string, got %s", v.Type().Key()) + } + keys := v.MapKeys() + sort.Slice(keys, func(i, j int) bool { return keys[i].String() < keys[j].String() }) + for _, k := range keys { + if err := addField(doc, k.String(), v.MapIndex(k), ctx); err != nil { + return err + } + } + return nil +} + +// --- reflection walk: field dispatch ------------------------------------- + +func addField(doc *tomlDoc, name string, v reflect.Value, ctx string) error { + if v.CanInterface() { + if m, ok := v.Interface().(Marshaler); ok { + mv, err := m.MarshalTOML() + if err != nil { + return fmt.Errorf("interpres: %s.%s: %w", ctx, name, err) + } + v = reflect.ValueOf(mv) + } + } + v = followPtr(v) + if !v.IsValid() { + return nil + } + if v.Kind() == reflect.Interface { + if v.IsNil() { + return nil + } + v = v.Elem() + } + switch v.Kind() { + case reflect.Struct: + if isScalarStruct(v.Type()) { + return doc.appendScalar(name, v.Interface(), ctx) + } + return addSubTable(doc, name, v, ctx) + case reflect.Map: + return addSubTable(doc, name, v, ctx) + case reflect.Slice, reflect.Array: + return addArrayValue(doc, name, v, ctx) + default: + val, err := normalizeValue(v) + if err != nil { + return fmt.Errorf("interpres: %s.%s: %w", ctx, name, err) + } + return doc.appendScalar(name, val, ctx) + } +} + +// appendScalar wraps addScalar with a uniform error path. +func (d *tomlDoc) appendScalar(name string, val any, ctx string) error { + d.addScalar(name, val) + return nil +} + +func addSubTable(doc *tomlDoc, name string, v reflect.Value, ctx string) error { + sub := &tomlDoc{} + switch v.Kind() { + case reflect.Struct: + if err := buildStructDoc(v, sub, joinKey(ctx, name)); err != nil { + return err + } + case reflect.Map: + if err := buildMapDoc(v, sub, joinKey(ctx, name)); err != nil { + return err + } + } + doc.addTable(name, sub) + return nil +} + +func addArrayValue(doc *tomlDoc, name string, v reflect.Value, ctx string) error { + if v.Kind() == reflect.Slice && v.IsNil() { + // A nil slice has no explicit representation in TOML — skip. + return nil + } + n := v.Len() + if n == 0 { + if isTableElementType(v.Type().Elem()) { + // Empty array of tables has no valid TOML form — skip. + return nil + } + return doc.appendScalar(name, []any{}, ctx) + } + + if isTableElementValue(v.Index(0)) { + subs := make([]*tomlDoc, n) + for i := 0; i < n; i++ { + ev := followPtr(v.Index(i)) + if !ev.IsValid() { + return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i) + } + sub := &tomlDoc{} + switch ev.Kind() { + case reflect.Struct: + if isScalarStruct(ev.Type()) { + return fmt.Errorf("interpres: %s.%s[%d]: heterogeneous array contains scalar", ctx, name, i) + } + if err := buildStructDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil { + return err + } + case reflect.Map: + if err := buildMapDoc(ev, sub, joinKey(ctx, fmt.Sprintf("%s[%d]", name, i))); err != nil { + return err + } + default: + return fmt.Errorf("interpres: %s.%s: heterogeneous array, expected table", ctx, name) + } + subs[i] = sub + } + doc.addArray(name, subs) + return nil + } + + // Regular array of scalars. + items := make([]any, n) + for i := 0; i < n; i++ { + ev := followPtr(v.Index(i)) + if !ev.IsValid() { + return fmt.Errorf("interpres: %s.%s[%d]: nil element", ctx, name, i) + } + if ev.CanInterface() { + if m, ok := ev.Interface().(Marshaler); ok { + mv, err := m.MarshalTOML() + if err != nil { + return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err) + } + ev = reflect.ValueOf(mv) + ev = followPtr(ev) + } + } + val, err := normalizeValue(ev) + if err != nil { + return fmt.Errorf("interpres: %s.%s[%d]: %w", ctx, name, i, err) + } + items[i] = val + } + return doc.appendScalar(name, items, ctx) +} + +// normalizeValue converts a reflect.Value into one of the canonical scalar or +// nested-array representations the emitter understands. Slices and arrays are +// recursively normalised so that nested arrays (e.g. [][]int) work. +func normalizeValue(v reflect.Value) (any, error) { + if v.CanInterface() { + if m, ok := v.Interface().(Marshaler); ok { + return m.MarshalTOML() + } + } + switch v.Kind() { + case reflect.String: + return v.String(), nil + case reflect.Bool: + return v.Bool(), nil + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int(), nil + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + u := v.Uint() + if u > math.MaxInt64 { + return nil, fmt.Errorf("unsigned value %d overflows int64", u) + } + return int64(u), nil + case reflect.Float32, reflect.Float64: + return v.Float(), nil + case reflect.Slice, reflect.Array: + items := make([]any, v.Len()) + for i := 0; i < v.Len(); i++ { + val, err := normalizeValue(v.Index(i)) + if err != nil { + return nil, fmt.Errorf("[%d]: %w", i, err) + } + items[i] = val + } + return items, nil + } + if !v.IsValid() { + return nil, fmt.Errorf("invalid value") + } + return nil, fmt.Errorf("cannot encode %s", v.Type()) +} + +// followPtr unwraps pointer and interface layers. Returns a zero Value if a +// nil pointer or nil interface is encountered. +func followPtr(v reflect.Value) reflect.Value { + for { + switch v.Kind() { + case reflect.Pointer, reflect.Interface: + if v.IsNil() { + return reflect.Value{} + } + v = v.Elem() + continue + } + return v + } +} + +// isScalarStruct reports whether t is a struct type that the encoder treats +// as a TOML scalar (time.Time, LocalDateTime, LocalDate, LocalTime). +func isScalarStruct(t reflect.Type) bool { + return t == timeGoType || isLocalDateType(t) +} + +func isLocalDateType(t reflect.Type) bool { + return t == localDateTimeType || t == localDateType || t == localTimeType +} + +func isTableElementType(t reflect.Type) bool { + switch t.Kind() { + case reflect.Struct: + return !isScalarStruct(t) + case reflect.Map: + return t.Key().Kind() == reflect.String + } + return false +} + +func isTableElementValue(v reflect.Value) bool { + v = followPtr(v) + if !v.IsValid() { + return false + } + return isTableElementType(v.Type()) +} + +func joinKey(ctx, name string) string { + if ctx == "" { + return name + } + return ctx + "." + name +} + +// --- emission ------------------------------------------------------------ + +// writeHeaderSep writes a single newline before a table or array-of-tables +// header so the output has a blank line between sections, unless the buffer +// is empty (i.e. this is the very first header). +func (e *encoder) writeHeaderSep() { + if e.buf.Len() == 0 { + return + } + e.buf.WriteByte('\n') +} + +func (e *encoder) emitDoc(doc *tomlDoc, prefix []string) error { + for _, kv := range doc.scalars { + if err := e.writeKV(kv.key, kv.val); err != nil { + return err + } + } + for _, t := range doc.tables { + path := append(append([]string{}, prefix...), t.key) + e.writeHeaderSep() + e.buf.WriteByte('[') + writeKeyPath(&e.buf, path) + e.buf.WriteString("]\n") + if err := e.emitDoc(t.doc, path); err != nil { + return err + } + } + for _, a := range doc.arrays { + path := append(append([]string{}, prefix...), a.key) + for _, sub := range a.docs { + e.writeHeaderSep() + e.buf.WriteString("[[") + writeKeyPath(&e.buf, path) + e.buf.WriteString("]]\n") + if err := e.emitDoc(sub, path); err != nil { + return err + } + } + } + return nil +} + +func (e *encoder) writeKV(key string, val any) error { + if !utf8.ValidString(key) { + return fmt.Errorf("interpres: key %q is not valid UTF-8", key) + } + e.writeKey(key) + e.buf.WriteString(" = ") + if err := e.writeValue(val); err != nil { + return err + } + e.buf.WriteByte('\n') + return nil +} + +func writeKeyPath(buf *bytes.Buffer, path []string) { + for i, p := range path { + if i > 0 { + buf.WriteByte('.') + } + if isBareKey(p) { + buf.WriteString(p) + continue + } + writeQuotedString(buf, p) + } +} + +func (e *encoder) writeKey(key string) { + if isBareKey(key) { + e.buf.WriteString(key) + return + } + writeQuotedString(&e.buf, key) +} + +// writeQuotedString writes s as a TOML basic string (double-quoted) to buf. +// Returns an error only if s is not valid UTF-8; invalid byte sequences +// within a valid UTF-8 string are encoded as \ufffd replacement characters. +func writeQuotedString(buf *bytes.Buffer, s string) error { + if !utf8.ValidString(s) { + return fmt.Errorf("interpres: string is not valid UTF-8") + } + buf.WriteByte('"') + for i := 0; i < len(s); { + r, size := utf8.DecodeRuneInString(s[i:]) + if r == utf8.RuneError && size == 1 { + buf.WriteString(`\ufffd`) + i++ + continue + } + i += size + writeEscapedRune(buf, r) + } + buf.WriteByte('"') + return nil +} + +// writeEscapedRune writes a single rune to buf, escaping it as required by +// TOML basic-string rules. +func writeEscapedRune(buf *bytes.Buffer, r rune) { + switch r { + case '\\': + buf.WriteString(`\\`) + case '"': + buf.WriteString(`\"`) + case '\b': + buf.WriteString(`\b`) + case '\t': + buf.WriteString(`\t`) + case '\n': + buf.WriteString(`\n`) + case '\f': + buf.WriteString(`\f`) + case '\r': + buf.WriteString(`\r`) + default: + if r < 0x20 || r == 0x7f { + fmt.Fprintf(buf, `\u%04X`, r) + } else { + buf.WriteRune(r) + } + } +} + +func isBareKey(s string) bool { + if s == "" { + return false + } + for i := 0; i < len(s); i++ { + c := s[i] + if !((c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') || c == '_' || c == '-') { + return false + } + } + return true +} + +func (e *encoder) writeValue(val any) error { + switch v := val.(type) { + case string: + return e.writeStringVal(v) + case bool: + e.buf.WriteString(strconv.FormatBool(v)) + return nil + case int64: + e.buf.WriteString(strconv.FormatInt(v, 10)) + return nil + case float64: + return e.writeFloat(v) + case time.Time: + e.buf.WriteString(v.Format(time.RFC3339Nano)) + return nil + case LocalDateTime: + e.buf.WriteString(v.String()) + return nil + case LocalDate: + e.buf.WriteString(v.String()) + return nil + case LocalTime: + e.buf.WriteString(v.String()) + return nil + case []any: + e.buf.WriteByte('[') + for i, item := range v { + if i > 0 { + e.buf.WriteString(", ") + } + if err := e.writeValue(item); err != nil { + return err + } + } + e.buf.WriteByte(']') + return nil + case nil: + return fmt.Errorf("interpres: cannot encode nil value") + default: + return fmt.Errorf("interpres: cannot encode %T", val) + } +} + +func (e *encoder) writeStringVal(s string) error { + return writeQuotedString(&e.buf, s) +} + +func (e *encoder) writeFloat(v float64) error { + switch { + case math.IsNaN(v): + e.buf.WriteString("nan") + case math.IsInf(v, 1): + e.buf.WriteString("inf") + case math.IsInf(v, -1): + e.buf.WriteString("-inf") + case v == 0: + // Normalise negative zero to positive zero (TOML has no -0). + e.buf.WriteString("0.0") + default: + s := strconv.FormatFloat(v, 'g', -1, 64) + // TOML forbids leading zeros in the exponent digits. + if idx := strings.LastIndexAny(s, "eE"); idx >= 0 { + mant := s[:idx] + exp := s[idx+1:] // e.g. "+06", "-05" + sign := "" + if len(exp) > 0 && (exp[0] == '+' || exp[0] == '-') { + sign = string(exp[0]) + exp = exp[1:] + } + exp = strings.TrimLeft(exp, "0") + if exp == "" { + exp = "0" + } + s = mant + "e" + sign + exp + } + if !strings.ContainsAny(s, ".eE") { + s += ".0" + } + e.buf.WriteString(s) + } + return nil +} diff --git a/encode_test.go b/encode_test.go new file mode 100644 index 0000000..9a7a4fc --- /dev/null +++ b/encode_test.go @@ -0,0 +1,679 @@ +package interpres + +import ( + "math" + "reflect" + "strings" + "testing" + "time" +) + +func TestMarshalScalars(t *testing.T) { + type Cfg struct { + Title string `toml:"title"` + Count int `toml:"count"` + Unsigned uint64 `toml:"unsigned"` + Ratio float64 `toml:"ratio"` + Enabled bool `toml:"enabled"` + Disabled bool `toml:"disabled"` + } + out, err := Marshal(Cfg{ + Title: "demo", + Count: 42, + Unsigned: 99, + Ratio: 3.14, + Enabled: true, + Disabled: false, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "title = \"demo\"\ncount = 42\nunsigned = 99\nratio = 3.14\nenabled = true\ndisabled = false\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalFloatSpecials(t *testing.T) { + type Cfg struct { + PosInf float64 `toml:"pos_inf"` + NegInf float64 `toml:"neg_inf"` + NaN float64 `toml:"nan"` + Zero float64 `toml:"zero"` + IntVal float64 `toml:"int_val"` + NegZ float64 `toml:"neg_zero"` + } + out, err := Marshal(Cfg{ + PosInf: math.Inf(1), + NegInf: math.Inf(-1), + NaN: math.NaN(), + Zero: 0, + IntVal: 7, + NegZ: math.Copysign(0, -1), + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "pos_inf = inf\nneg_inf = -inf\nnan = nan\nzero = 0.0\nint_val = 7.0\nneg_zero = 0.0\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalStringEscapes(t *testing.T) { + cases := []struct { + name string + in string + want string // the TOML scalar value (without "s = " prefix) + }{ + {"plain", "hello", `"hello"`}, + {"quote", `say "hi"`, `"say \"hi\""`}, + {"backslash", `a\b`, `"a\\b"`}, + {"newline", "line1\nline2", `"line1\nline2"`}, + {"tab", "col1\tcol2", `"col1\tcol2"`}, + {"cr", "line\rmore", `"line\rmore"`}, + {"control", "a\x01b", `"a\u0001b"`}, + {"unicode", "\u201csmart\u201d", `"“smart”"`}, // printable unicode; not escaped + {"empty", "", `""`}, + {"slash_only", "a/b", `"a/b"`}, + } + for _, c := range cases { + out, err := Marshal(struct { + S string `toml:"s"` + }{S: c.in}) + if err != nil { + t.Fatalf("%s: marshal: %v", c.name, err) + } + got := strings.TrimSuffix(string(out), "\n") + want := "s = " + c.want + if got != want { + t.Errorf("%s:\ngot: %s\nwant: %s", c.name, got, want) + } + } +} + +func TestMarshalDateTime(t *testing.T) { + type Cfg struct { + Offset time.Time `toml:"offset"` + Local LocalDateTime `toml:"local"` + Day LocalDate `toml:"day"` + Clock LocalTime `toml:"clock"` + } + out, err := Marshal(Cfg{ + Offset: time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC), + Local: LocalDateTime{Time: time.Date(2026, 6, 26, 7, 32, 0, 0, time.UTC)}, + Day: LocalDate{Time: time.Date(2026, 6, 26, 0, 0, 0, 0, time.UTC)}, + Clock: LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "offset = 2026-06-26T10:00:00Z\nlocal = 2026-06-26T07:32:00\nday = 2026-06-26\nclock = 07:32:00\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalDateTimeFractional(t *testing.T) { + out, err := Marshal(struct { + LDT LocalDateTime `toml:"ldt"` + LT LocalTime `toml:"lt"` + }{ + LDT: LocalDateTime{Time: time.Date(2026, 6, 26, 7, 32, 0, 123456789, time.UTC)}, + LT: LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 123, time.UTC)}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "ldt = 2026-06-26T07:32:00.123456789\nlt = 07:32:00.000000123\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalArraysOfScalars(t *testing.T) { + type Cfg struct { + Tags []string `toml:"tags"` + Ports []int `toml:"ports"` + Mixed []any `toml:"mixed"` + Empty []int `toml:"empty"` + EmptyS []string `toml:"empty_s"` + } + out, err := Marshal(Cfg{ + Tags: []string{"a", "b"}, + Ports: []int{80, 443}, + Mixed: []any{int64(1), "x", true}, + Empty: nil, + EmptyS: []string{}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "tags = [\"a\", \"b\"]\nports = [80, 443]\nmixed = [1, \"x\", true]\nempty_s = []\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalNestedArrays(t *testing.T) { + type Cfg struct { + Matrix [][]int `toml:"matrix"` + Words [][]string `toml:"words"` + } + out, err := Marshal(Cfg{ + Matrix: [][]int{{1, 2}, {3, 4}}, + Words: [][]string{{"a", "b"}, {"c"}}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "matrix = [[1, 2], [3, 4]]\nwords = [[\"a\", \"b\"], [\"c\"]]\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalFloatExponentNoLeadingZero(t *testing.T) { + // strconv.FormatFloat with 'g' would produce "1e+06" (leading zero in + // exponent). The encoder must strip it so the output is "1e+6". + type Cfg struct { + Large float64 `toml:"large"` + Small float64 `toml:"small"` + } + out, err := Marshal(Cfg{Large: 1e6, Small: 1e-5}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Parse to check the output is valid TOML (for a strict parser that + // rejects leading zeros in exponents). + if _, err := Parse(out); err != nil { + t.Fatalf("marshalled output is not valid TOML:\n%s\nerror: %v", out, err) + } + if string(out) != "large = 1e+6\nsmall = 1e-5\n" { + t.Errorf("output mismatch:\ngot: %q", out) + } +} + +func TestMarshalStructAsTable(t *testing.T) { + type Server struct { + Host string `toml:"host"` + Port int `toml:"port"` + } + type Cfg struct { + Title string `toml:"title"` + Server Server `toml:"server"` + } + out, err := Marshal(Cfg{ + Title: "demo", + Server: Server{Host: "127.0.0.1", Port: 9090}, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "title = \"demo\"\n\n[server]\nhost = \"127.0.0.1\"\nport = 9090\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalArrayOfTables(t *testing.T) { + type Item struct { + Name string `toml:"name"` + Qty int `toml:"qty"` + } + type Cfg struct { + Items []Item `toml:"items"` + } + out, err := Marshal(Cfg{ + Items: []Item{ + {Name: "a", Qty: 1}, + {Name: "b", Qty: 2}, + }, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "[[items]]\nname = \"a\"\nqty = 1\n\n[[items]]\nname = \"b\"\nqty = 2\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalEmptyArrayOfTablesIsSkipped(t *testing.T) { + type Item struct { + Name string `toml:"name"` + } + type Cfg struct { + Title string `toml:"title"` + Items []Item `toml:"items"` + } + out, err := Marshal(Cfg{ + Title: "demo", + Items: nil, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "title = \"demo\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalNestedTablesAndArrays(t *testing.T) { + type SMTP struct { + Host string `toml:"host"` + Port int `toml:"port"` + } + type Form struct { + Name string `toml:"name"` + SMTP SMTP `toml:"smtp"` + } + type Cfg struct { + Port int `toml:"port"` + Forms []Form `toml:"forms"` + } + out, err := Marshal(Cfg{ + Port: 8080, + Forms: []Form{ + {Name: "contact", SMTP: SMTP{Host: "h1", Port: 587}}, + {Name: "feedback", SMTP: SMTP{Host: "h2", Port: 25}}, + }, + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "port = 8080\n\n[[forms]]\nname = \"contact\"\n\n[forms.smtp]\nhost = \"h1\"\nport = 587\n\n[[forms]]\nname = \"feedback\"\n\n[forms.smtp]\nhost = \"h2\"\nport = 25\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalStructTags(t *testing.T) { + type Cfg struct { + Keep string `toml:"keep"` + Rename string `toml:"renamed"` + Skip string `toml:"-"` + Untagged string + } + out, err := Marshal(Cfg{ + Keep: "k", Rename: "r", Skip: "s", Untagged: "u", + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "keep = \"k\"\nrenamed = \"r\"\nuntagged = \"u\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalEmbeddedStructPromoted(t *testing.T) { + type Base struct { + ID int `toml:"id"` + } + type Derived struct { + Base + Name string `toml:"name"` + } + out, err := Marshal(Derived{Base: Base{ID: 1}, Name: "x"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "id = 1\nname = \"x\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalEmbeddedStructAsTable(t *testing.T) { + type Inner struct { + Host string `toml:"host"` + } + type Cfg struct { + Inner Inner `toml:"inner"` + Name string `toml:"name"` + } + out, err := Marshal(Cfg{Inner: Inner{Host: "h"}, Name: "n"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "name = \"n\"\n\n[inner]\nhost = \"h\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalMapKeysSorted(t *testing.T) { + m := map[string]any{ + "zeta": 1, + "alpha": 2, + "mu": 3, + } + out, err := Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "alpha = 2\nmu = 3\nzeta = 1\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalMapWithSubMap(t *testing.T) { + m := map[string]any{ + "meta": map[string]any{"x": 1, "y": 2}, + "a": "z", + } + out, err := Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "a = \"z\"\n\n[meta]\nx = 1\ny = 2\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalMarshaler(t *testing.T) { + type Port int + type Cfg struct { + P Port `toml:"p"` + } + out, err := Marshal(Cfg{P: 8080}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "p = 8080\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalMarshalerReturningScalar(t *testing.T) { + type Wrapped struct { + Value string `toml:"value"` + } + type Alias struct{} + out, err := Marshal(struct { + W Wrapped `toml:"w"` + }{W: Wrapped{Value: "hello"}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "[w]\nvalue = \"hello\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } + _ = Alias{} +} + +func TestMarshalMarshalerReturningDifferentShape(t *testing.T) { + out, err := Marshal(struct { + C Custom `toml:"c"` + }{C: Custom{tag: "x"}}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + // Custom returns a string from MarshalTOML. + want := "c = \"x\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalNilPointerFieldSkipped(t *testing.T) { + type Cfg struct { + Name string `toml:"name"` + Hidden *string `toml:"hidden"` + } + out, err := Marshal(Cfg{Name: "x"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "name = \"x\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalNonNilPointerFollowed(t *testing.T) { + v := "v" + type Cfg struct { + Name string `toml:"name"` + Hidden *string `toml:"hidden"` + } + out, err := Marshal(Cfg{Name: "n", Hidden: &v}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "name = \"n\"\nhidden = \"v\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalTopLevelMustBeStructOrMap(t *testing.T) { + if _, err := Marshal(42); err == nil { + t.Errorf("expected error marshalling int at top level") + } + if _, err := Marshal("hello"); err == nil { + t.Errorf("expected error marshalling string at top level") + } + if _, err := Marshal(nil); err == nil { + t.Errorf("expected error marshalling nil") + } +} + +func TestMarshalMapKeyMustBeString(t *testing.T) { + m := map[int]any{1: "x"} + if _, err := Marshal(m); err == nil { + t.Errorf("expected error for non-string map key") + } +} + +func TestMarshalUnexportedFieldSkipped(t *testing.T) { + type Cfg struct { + Pub string `toml:"pub"` + priv string + } + out, err := Marshal(Cfg{Pub: "p", priv: "s"}) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "pub = \"p\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalBareAndQuotedKeys(t *testing.T) { + type Cfg struct { + Bare string `toml:"bare_key"` + Dash string `toml:"with-dash"` + Num string `toml:"num123"` + Q string `toml:"needs space"` + Dot string `toml:"needs.dot"` + } + out, err := Marshal(Cfg{ + Bare: "a", Dash: "b", Num: "c", Q: "d", Dot: "e", + }) + if err != nil { + t.Fatalf("marshal: %v", err) + } + want := "bare_key = \"a\"\nwith-dash = \"b\"\nnum123 = \"c\"\n\"needs space\" = \"d\"\n\"needs.dot\" = \"e\"\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } +} + +func TestMarshalThenParseRoundTrip(t *testing.T) { + type Server struct { + Host string `toml:"host"` + Port int `toml:"port"` + Enabled bool `toml:"enabled"` + Tags []string `toml:"tags"` + } + type Form struct { + Name string `toml:"name"` + Allowed []string `toml:"allowed"` + } + type Cfg struct { + Title string `toml:"title"` + Count int `toml:"count"` + Ratio float64 `toml:"ratio"` + Server Server `toml:"server"` + Forms []Form `toml:"forms"` + Due time.Time `toml:"due"` + Day LocalDate `toml:"day"` + } + in := Cfg{ + Title: "demo", + Count: 42, + Ratio: 3.14, + Server: Server{ + Host: "127.0.0.1", Port: 9090, Enabled: true, + Tags: []string{"a", "b"}, + }, + Forms: []Form{ + {Name: "contact", Allowed: []string{"x"}}, + {Name: "feedback", Allowed: nil}, + }, + Due: time.Date(2026, 6, 26, 10, 0, 0, 0, time.UTC), + Day: LocalDate{Time: time.Date(2026, 6, 26, 0, 0, 0, 0, time.UTC)}, + } + out, err := Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + tree1, err := Parse(out) + if err != nil { + t.Fatalf("parse of marshalled: %v\noutput:\n%s", err, out) + } + // Decode back into the struct. + var out2 Cfg + if err := Unmarshal(out, &out2); err != nil { + t.Fatalf("unmarshal of marshalled: %v", err) + } + if !reflect.DeepEqual(in, out2) { + t.Errorf("round-trip mismatch:\nin: %#v\nout: %#v", in, out2) + } + _ = tree1 +} + +func TestMarshalRoundTripFromUntypedTree(t *testing.T) { + src := []byte(`title = "demo" +count = 42 +ratio = 3.14 +enabled = true + +[server] +host = "127.0.0.1" +port = 9090 + +[[items]] +name = "a" +qty = 1 + +[[items]] +name = "b" +qty = 2 + +[meta] +created = 2026-06-26T10:00:00Z +`) + tree1, err := Parse(src) + if err != nil { + t.Fatalf("parse src: %v", err) + } + out, err := Marshal(tree1) + if err != nil { + t.Fatalf("marshal: %v", err) + } + tree2, err := Parse(out) + if err != nil { + t.Fatalf("re-parse marshalled: %v\noutput:\n%s", err, out) + } + if !reflect.DeepEqual(tree1, tree2) { + t.Errorf("round-trip mismatch:\nbefore: %#v\nafter: %#v", tree1, tree2) + } +} + +func TestMarshalUintOverflow(t *testing.T) { + type Cfg struct { + Big uint64 `toml:"big"` + } + if _, err := Marshal(Cfg{Big: 1<<63 + 1}); err == nil { + t.Errorf("expected overflow error") + } +} + +func TestMarshalKeyRequiresUTF8(t *testing.T) { + m := map[string]any{"\xff": "x"} + if _, err := Marshal(m); err == nil { + t.Errorf("expected error for invalid UTF-8 key") + } +} + +func TestMarshalStringRequiresUTF8(t *testing.T) { + type Cfg struct { + S string `toml:"s"` + } + if _, err := Marshal(Cfg{S: "abc\xff"}); err == nil { + t.Errorf("expected error for invalid UTF-8 string") + } +} + +func TestLocalDateString(t *testing.T) { + ld := LocalDate{Time: time.Date(1979, 5, 27, 0, 0, 0, 0, time.UTC)} + if got := ld.String(); got != "1979-05-27" { + t.Errorf("LocalDate.String() = %q, want 1979-05-27", got) + } +} + +func TestLocalDateTimeString(t *testing.T) { + ldt := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 0, time.UTC)} + if got := ldt.String(); got != "1979-05-27T07:32:00" { + t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00", got) + } + ldt2 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 5, time.UTC)} + if got := ldt2.String(); got != "1979-05-27T07:32:00.000000005" { + t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000005", got) + } + ldt3 := LocalDateTime{Time: time.Date(1979, 5, 27, 7, 32, 0, 500, time.UTC)} + if got := ldt3.String(); got != "1979-05-27T07:32:00.000000500" { + t.Errorf("LocalDateTime.String() = %q, want 1979-05-27T07:32:00.000000500", got) + } +} + +func TestLocalTimeString(t *testing.T) { + lt := LocalTime{Time: time.Date(0, 1, 1, 7, 32, 0, 0, time.UTC)} + if got := lt.String(); got != "07:32:00" { + t.Errorf("LocalTime.String() = %q, want 07:32:00", got) + } +} + +func TestEncoderEquivalenceToMarshal(t *testing.T) { + type Cfg struct { + Title string `toml:"title"` + Count int `toml:"count"` + } + in := Cfg{Title: "x", Count: 7} + a, err := Marshal(in) + if err != nil { + t.Fatalf("marshal: %v", err) + } + b, err := NewEncoder().Marshal(in) + if err != nil { + t.Fatalf("encoder marshal: %v", err) + } + if !reflect.DeepEqual(a, b) { + t.Errorf("Marshal and Encoder disagree:\n%s\n%s", a, b) + } +} + +type Custom struct { + tag string +} + +func (c Custom) MarshalTOML() (any, error) { return c.tag, nil } diff --git a/examples/basic/main.go b/examples/basic/main.go index 525704a..9a5926c 100644 --- a/examples/basic/main.go +++ b/examples/basic/main.go @@ -1,4 +1,5 @@ -// Command basic demonstrates decoding a TOML document with interpres. +// Command basic demonstrates decoding and encoding a TOML document with +// interpres. package main import ( @@ -48,4 +49,10 @@ func main() { for _, u := range cfg.Users { fmt.Printf("user: %-6s admin=%t\n", u.Name, u.Admin) } + + out, err := interpres.Marshal(cfg) + if err != nil { + log.Fatal(err) + } + fmt.Printf("\n--- marshal ---\n%s", out) } diff --git a/interpres.go b/interpres.go index f7eb320..da48ce1 100644 --- a/interpres.go +++ b/interpres.go @@ -1,11 +1,13 @@ // Package interpres is a dependency-free TOML parser for Go. // -// interpres reads TOML documents into Go values using only the standard -// library. It exposes a small, encoding/json-style API: +// 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) @@ -84,3 +86,57 @@ func (d *Decoder) Decode(data []byte, v any) error { 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 — 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) +} + +// Marshal returns the TOML 1.0 encoding of v. +// +// 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). Anonymous (embedded) fields without +// a tag are inlined. +// - 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. +// - 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. +func Marshal(v any) ([]byte, error) { + return NewEncoder().Marshal(v) +} + +// An Encoder encodes Go values into TOML. +type Encoder struct{} + +// NewEncoder returns an Encoder. +func NewEncoder() *Encoder { return &Encoder{} } + +// Marshal encodes v to TOML bytes. It is equivalent to calling Marshal with v. +func (e *Encoder) Marshal(v any) ([]byte, error) { + enc := newEncoder() + if err := enc.encode(v); err != nil { + return nil, err + } + return enc.bytes(), nil +}