go-toon

module
v1.0.13 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Nov 8, 2026 License: MIT

README ΒΆ

go-toon

CI Go Reference Go Version TOON Spec Ask DeepWiki

go-toon is a Go implementation of Token-Oriented Object Notation (TOON) v4.4. It provides an ordered-node library, format normalization helpers, ordered JSON output, and a toon CLI.

The TOON specification this implementation follows lives at toon-format/spec/SPEC.md.

The library exposes toon.SpecVersion = "4.4".

toon-spec: 4.4

See CHANGELOG.md for release notes.

Demo

Try conversions in the browser:

Features

  • πŸš€ TOON v4.4 compliant β€” encoder, decoder, and validator follow the TOON spec and run against the vendored spec fixtures.
  • 🧩 v4 syntax β€” keyed tabular arrays, nested field groups, # comment lines, and the v4 list-item layout.
  • πŸ“¦ Ordered document model β€” toon.Node preserves field order end-to-end; Go map is never the primary representation.
  • πŸ”„ Format normalization β€” convert JSON, YAML, CSV, and XML into ordered *toon.Node values, plus ordered JSON output that bypasses Go maps.
  • πŸ›‘οΈ Security-first defaults β€” strict decode on, JSON duplicate keys rejected, XML DTDs and external entities blocked, YAML alias cycle detection, configurable resource limits.
  • πŸ”’ Lossless numbers β€” raw decimal token preservation by default, including out-of-range values, with explicit opt-ins for float64 and safe-string fallbacks for unsafe values.
  • 🧰 Production CLI β€” toon encode, decode, and validate with a flag surface mirroring library options.
  • ⚑ Battle-tested β€” race tests, fuzz targets, golden conformance tests, and cross-platform builds for linux/amd64, linux/arm64, darwin/amd64, darwin/arm64, and windows/amd64.

Comparison

Feature go-toon toon-format/toon-go
Primary API style 🧱 Ordered *toon.Node core plus toon/reflect πŸͺΆ Top-level Marshal / Unmarshal
Decode target 🧱 Ordered *toon.Node πŸ—ΊοΈ any, map[string]any, []any
Preserves decoded object order βœ… Yes ❌ No
Lossless number tokens on decode βœ… Yes ⚠️ No, decodes numbers as float64
Struct marshal/unmarshal βœ… Yes, via toon/reflect βœ… Yes, top-level
omitempty struct tags βœ… Yes βœ… Yes
Custom time.Time formatting βœ… Yes βœ… Yes
JSON/YAML/CSV/XML to TOON βœ… Yes ❌ No
Ordered TOON to JSON βœ… Yes ❌ No
CLI βœ… Yes ❌ No
Validation-only API βœ… Yes ❌ No dedicated API
Typed error codes βœ… Yes ❌ No public error-code API
Resource limits βœ… Yes ❌ No public resource limits
Reusable encoder/decoder types ❌ No public core types βœ… Yes
String convenience APIs ❌ No core EncodeString / DecodeString βœ… Yes
External dependencies πŸ“¦ gopkg.in/yaml.v3 βœ… None found

Library API

Core package
import "github.com/shepard-labs/go-toon/toon"
Types
  • toon.Node β€” the ordered document model. A node can represent any TOON value.
  • toon.Field β€” a key-value pair within an ObjectKind node.
  • toon.Number β€” stores the raw string representation of a number for lossless round-trips.
  • toon.Kind β€” the type of node (NullKind, BoolKind, NumberKind, StringKind, ArrayKind, ObjectKind).
Encoding
n := &toon.Node{
    Kind: toon.ObjectKind,
    Object: []toon.Field{
        {Key: "id", Value: &toon.Node{Kind: toon.NumberKind, Number: toon.Number{Raw: "1"}}},
        {Key: "user", Value: &toon.Node{
            Kind: toon.ObjectKind,
            Object: []toon.Field{
                {Key: "name", Value: &toon.Node{Kind: toon.StringKind, String: "Ada"}},
                {Key: "active", Value: &toon.Node{Kind: toon.BoolKind, Bool: true}},
            },
        }},
    },
}

// Encode to bytes
data, err := toon.Encode(n)

// Encode to a writer
var buf bytes.Buffer
err = toon.EncodeToWriter(&buf, n)

// With custom indentation
data, err = toon.Encode(n, func(o *toon.EncodeOptions) {
    o.IndentSize = 4
})

// With a tab or pipe document delimiter (declared in every array header)
data, err = toon.Encode(n, func(o *toon.EncodeOptions) {
    o.Delimiter = toon.Pipe
})

Encoding returns an invalid_utf8 error when a string value or object key contains ill-formed UTF-8 (invalid bytes or unpaired surrogates).

Decoding
// Decode from bytes
n, err := toon.Decode([]byte("id: 1\nuser:\n  name: Ada\n  active: true"))

// Decode from a reader
n, err = toon.DecodeReader(strings.NewReader("id: 1"))

// Validate without decoding
err = toon.Validate([]byte("id: 1"))

// Dotted keys are always literal: "a.b: 1" decodes to the single key "a.b"
n, err = toon.Decode([]byte("a.b: 1"))

// With strict mode disabled (applies only the spec's non-strict recoveries)
n, err = toon.Decode([]byte("id: 1"), func(o *toon.DecodeOptions) {
    o.Strict = false
})

// With resource limits
n, err = toon.Decode([]byte("id: 1"), func(o *toon.DecodeOptions) {
    o.Limits = toon.ResourceLimits{
        MaxDepth:       10,
        MaxNodes:       1000,
        MaxBytes:       1024,
        MaxStringBytes: 1024,
        MaxArrayLength: 100,
    }
})
Error handling
err := toon.Validate([]byte("invalid"))
if err != nil {
    // Extract the error code for branching without string matching
    code := toon.CodeOf(err)
    if code == toon.ErrDuplicateKey {
        // handle duplicate key
    }

    // Or use errors.As
    var toonErr *toon.Error
    if errors.As(err, &toonErr) {
        fmt.Println(toonErr.Line, toonErr.Column, toonErr.Code)
    }
}
Tabular arrays
// Encoding tabular arrays
n := &toon.Node{
    Kind: toon.ObjectKind,
    Object: []toon.Field{
        {Key: "items", Value: &toon.Node{
            Kind: toon.ArrayKind,
            Array: []*toon.Node{
                {Kind: toon.ObjectKind, Object: []toon.Field{
                    {Key: "sku", Value: &toon.Node{Kind: toon.StringKind, String: "A1"}},
                    {Key: "qty", Value: &toon.Node{Kind: toon.NumberKind, Number: toon.Number{Raw: "2"}}},
                    {Key: "price", Value: &toon.Node{Kind: toon.NumberKind, Number: toon.Number{Raw: "9.9900"}}},
                }},
                {Kind: toon.ObjectKind, Object: []toon.Field{
                    {Key: "sku", Value: &toon.Node{Kind: toon.StringKind, String: "B2"}},
                    {Key: "qty", Value: &toon.Node{Kind: toon.NumberKind, Number: toon.Number{Raw: "1"}}},
                    {Key: "price", Value: &toon.Node{Kind: toon.NumberKind, Number: toon.Number{Raw: "14.5"}}},
                }},
            },
        }},
    },
}
data, _ := toon.Encode(n)
// Output: items[2]{sku,qty,price}:\n  A1,2,9.99\n  B2,1,14.5
Keyed tabular arrays

An object with two or more entries whose values are all non-empty objects with the same keys encodes as a keyed tabular array. Each entry row starts with the entry key:

users[2:]{age,city}:
  alice: 30,Oslo
  bob: 25,Rome

At the document root the header has no key:

[2:]{age,city}:
  alice: 30,Oslo
  bob: 25,Rome

Both forms decode back to ordered objects ({"alice": {...}, "bob": {...}}).

Nested field groups

Columns that hold uniform nested objects are written as a field group inside the header, and each row stays flat. Cells are listed depth-first:

users[2]{id,name,geo{lat,lng}}:
  1,Ada,1.5,2.5
  2,Linus,3,4

Groups nest further, for example rows[2]{id,a{b{c,d}}}:.

Comments

A line whose first non-space character is # is a comment. Decoders drop it before any other processing. The encoder never writes comments, and quotes strings that start with #.

# users
name: Ada
# trailing note
age: 36
List items

The first field of an object list item always sits on the hyphen line, and an empty object is a bare -:

items[4]:
  - id: 1
    tags[2]: a,b
  - id: 2
  - [2]: 1,2
  - [0]:
Number modes
// Lossless (default) β€” preserves decimal tokens
n, _ := toon.Decode([]byte("pi: 3.141592653589793238462643383279"))
// n.Object[0].Value.Number.Raw == "3.141592653589793238462643383279"

// Out-of-range values decode as well: the decoder keeps the token text in Raw
n, _ = toon.Decode([]byte("big: 1e500"))
// n.Object[0].Value.Number.Raw == "1e500"

// Lossless encode (default) writes the canonical decimal token
data, _ := toon.Encode(&toon.Node{Kind: toon.NumberKind, Number: toon.Number{Raw: "1e500"}})
// Output: 1e+500

// Float64 β€” rounds through float64; values outside the float64 range return an error
data, err := toon.Encode(&toon.Node{Kind: toon.NumberKind, Number: toon.Number{Raw: "3.141592653589793238462643383279"}},
    func(o *toon.EncodeOptions) { o.NumberMode = toon.NumberFloat64 })
// Output: 3.141592653589793

// String for unsafe values on encode
data, _ = toon.Encode(&toon.Node{Kind: toon.NumberKind, Number: toon.Number{Raw: "1e500"}},
    func(o *toon.EncodeOptions) { o.NumberMode = toon.NumberStringForUnsafe })
// Output: "1e+500" (quoted string)
Utility functions
// Check if a string is a valid unquoted TOON key
toon.IsValidUnquotedKey("myField")  // true
toon.IsValidUnquotedKey("123abc")   // false

// Escape a string for TOON output
toon.EscapeString("hello\nworld")   // "hello\\nworld"

// Check if a string needs quoting
toon.NeedsQuotes("hello", toon.Comma)  // false
toon.NeedsQuotes("hello,world", toon.Comma)  // true

// Parse a primitive token
n, _ := toon.ParsePrimitiveToken("42")  // NumberKind with Raw="42"
n, _ = toon.ParsePrimitiveToken("true") // BoolKind with Bool=true
n, _ = toon.ParsePrimitiveToken("null") // NullKind

// Canonicalize a number token
toon.CanonicalizeNumberToken("1e3")   // "1000"
toon.CanonicalizeNumberToken("1.200") // "1.2"
Error codes
const (
    ErrInvalidIndent           ErrorCode = "invalid_indent"
    ErrInvalidUTF8             ErrorCode = "invalid_utf8"
    ErrTabIndent               ErrorCode = "tab_indent"
    ErrInvalidEscape           ErrorCode = "invalid_escape"
    ErrUnterminatedString      ErrorCode = "unterminated_string"
    ErrArrayCountMismatch      ErrorCode = "array_count_mismatch"
    ErrTabularWidthMismatch    ErrorCode = "tabular_width_mismatch"
    ErrDuplicateKey            ErrorCode = "duplicate_key"
    ErrMalformedHeader         ErrorCode = "malformed_header"
    ErrHeaderDelimiterMismatch ErrorCode = "header_delimiter_mismatch"
    ErrMissingColon            ErrorCode = "missing_colon"
    ErrPathExpansionConflict   ErrorCode = "path_expansion_conflict" // no longer produced
    ErrResourceLimit           ErrorCode = "resource_limit"
    ErrInvalidInputFormat      ErrorCode = "invalid_input_format"
    ErrUnsupportedFeature      ErrorCode = "unsupported_feature"
    ErrUnsupportedKind         ErrorCode = "unsupported_kind"
    ErrCyclicValue             ErrorCode = "cyclic_value"
    ErrUnmarshalType           ErrorCode = "unmarshal_type"
    ErrNonPointerTarget        ErrorCode = "non_pointer_target"
)
Reflection package
import "github.com/shepard-labs/go-toon/toon/reflect"
Marshaling Go values to TOON
type User struct {
    ID    int      `toon:"id"`
    Name  string   `toon:"name"`
    Tags  []string `toon:"tags"`
    Score float64  `toon:"score"`
}

// Marshal to TOON bytes via a writer
var buf bytes.Buffer
err := reflect.Marshal(&buf, User{ID: 42, Name: "Ada", Tags: []string{"a", "b"}, Score: 9.5})
// buf: "id: 42\nname: Ada\ntags[2]: a,b\nscore: 9.5"

// Slices of structs with nested structs encode as nested field groups
type Geo struct{ Lat, Lng float64 }
type Place struct {
    ID  int `toon:"id"`
    Geo Geo `toon:"geo"`
}
buf.Reset()
err = reflect.Marshal(&buf, map[string]any{"places": []Place{{1, Geo{1, 2}}, {2, Geo{3, 4}}}})
// places[2]{id,geo{Lat,Lng}}:
//   1,1,2
//   2,3,4

// Marshal to a *toon.Node
node, err := reflect.NodeFromValue(User{ID: 42, Name: "Ada"})
// node.Kind == toon.ObjectKind

// With custom number mode for non-finite floats
node, err = reflect.NodeFromValue(math.NaN(), func(o *reflect.ValueOptions) {
    o.NumberMode = toon.NumberStringForUnsafe
})
// node.Kind == toon.StringKind, node.String == "NaN"

// With resource limits
_, err = reflect.NodeFromValue([]int{1, 2, 3, 4, 5}, func(o *reflect.ValueOptions) {
    o.Limits = toon.ResourceLimits{MaxArrayLength: 2}
})
// err.Code == toon.ErrResourceLimit

// Struct with skipped and unexported fields
type Record struct {
    Name     string `toon:"name"`
    Optional string `toon:"optional,omitempty"` // skipped when empty
    Skip     string `toon:"-"`                  // skipped
    hidden   string                              // unexported β€” skipped
}
node, _ = reflect.NodeFromValue(Record{Name: "x", Skip: "y", hidden: "z"})
// node.Object = [{Key: "name", Value: String("x")}]
Custom time formatting
type Event struct {
    At time.Time `toon:"at"`
}

var buf bytes.Buffer
err := reflect.Marshal(&buf, Event{At: time.Date(2025, 6, 4, 12, 30, 0, 0, time.UTC)}, func(o *reflect.Options) {
    o.Value.TimeFormatter = func(t time.Time) string {
        return t.UTC().Format("2006-01-02")
    }
})
// buf: "at: 2025-06-04"
Unmarshaling TOON to Go values
// Unmarshal TOON bytes to a Go value
var u User
err := reflect.Unmarshal([]byte("id: 42\nname: Ada\ntags[2]: a,b\nscore: 9.5"), &u)
// u.ID == 42, u.Name == "Ada", u.Tags == []string{"a", "b"}, u.Score == 9.5

// Unmarshal from a reader
var u2 User
err = reflect.UnmarshalReader(strings.NewReader("id: 1\nname: Bob"), &u2)

// Unmarshal from an already-decoded node
n, _ := toon.Decode([]byte("name: Bob\nage: 30"))
type Person struct {
    Name string `toon:"name"`
    Age  int    `toon:"age"`
}
var p Person
err = reflect.UnmarshalNode(n, &p)
// p.Name == "Bob", p.Age == 30

// With strict mode disabled
err = reflect.Unmarshal([]byte("id: 1"), &u, func(o *reflect.UnmarshalOptions) {
    o.Decode.Strict = false
})

// []byte is encoded as base64
var b []byte
n := &toon.Node{Kind: toon.StringKind, String: "SGVsbG8="}
err = reflect.NodeToValue(n, &b)
// b == []byte("Hello")
TextMarshaler / TextUnmarshaler
type Timestamp struct {
    Time time.Time
}

func (t Timestamp) MarshalText() ([]byte, error) {
    return []byte(t.Time.Format(time.RFC3339)), nil
}

func (t *Timestamp) UnmarshalText(b []byte) error {
    var err error
    t.Time, err = time.Parse(time.RFC3339, string(b))
    return err
}

// MarshalText is honored on encode
node, _ := reflect.NodeFromValue(Timestamp{Time: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)})
// node.Kind == toon.StringKind, node.String == "2025-01-01T00:00:00Z"

// UnmarshalText is honored on decode
var ts Timestamp
n := &toon.Node{Kind: toon.StringKind, String: "2025-06-04T12:00:00Z"}
reflect.NodeToValue(n, &ts)
// ts.Time == time.Date(2025, 6, 4, 12, 0, 0, 0, time.UTC)
Cycle detection
type ListNode struct {
    Value int
    Next  *ListNode
}

a := &ListNode{Value: 1}
b := &ListNode{Value: 2, Next: a}
a.Next = b // creates a cycle

_, err := reflect.NodeFromValue(a)
// err.Code == toon.ErrCyclicValue
Formats package
import "github.com/shepard-labs/go-toon/formats"
Parsing JSON
// Parse JSON into a TOON node
n, err := formats.FromJSON(strings.NewReader(`{"name":"Ada","age":36,"active":true}`))
// n.Kind == toon.ObjectKind

// Allow duplicate keys (last value wins)
n, err = formats.FromJSON(strings.NewReader(`{"a":1,"a":2}`), func(o *formats.JSONOptions) {
    o.AllowDuplicateKeys = true
})

// With resource limits
n, err = formats.FromJSON(strings.NewReader(`{"a":1}`), func(o *formats.JSONOptions) {
    o.Limits = toon.ResourceLimits{MaxBytes: 1024}
})
Parsing YAML
// Parse YAML into a TOON node
n, err := formats.FromYAML(strings.NewReader(`name: Ada
age: 36
tags:
- a
- b`))

// Multiple documents β†’ array
n, err = formats.FromYAML(strings.NewReader(`a: 1
---
b: 2`), func(o *formats.YAMLOptions) {
    o.Documents = formats.YAMLDocumentsArray
})
// n.Kind == toon.ArrayKind, n.Array has 2 elements

// Force all scalars to strings
n, err = formats.FromYAML(strings.NewReader(`a: 1`), func(o *formats.YAMLOptions) {
    o.Scalars = formats.YAMLScalarsString
})
// n.Object[0].Value.Kind == toon.StringKind (not NumberKind)
Parsing CSV
// With headers and type inference
n, err := formats.FromCSV(strings.NewReader(`id,name,zip
1,Ada,05
2,Bob,10`), func(o *formats.CSVOptions) {
    o.HeaderMode = formats.CSVHeaderPresent
    o.InferTypes = true
})
// n.Kind == toon.ArrayKind, n.Array[0] == {id: 1, name: "Ada", zip: "05"}

// Without headers, custom delimiter
n, err = formats.FromCSV(strings.NewReader(`1|true
2|false`), func(o *formats.CSVOptions) {
    o.HeaderMode = formats.CSVHeaderAbsent
    o.Delimiter = '|'
    o.InferTypes = false
})
// n.Array[0] == {field1: "1", field2: "true"}
Parsing XML
// Parse XML into a TOON node
n, err := formats.FromXML(strings.NewReader(`<user id="1">Ada</user>`))
// n.Kind == toon.ObjectKind, n.Object = [{Key: "user", Value: { @id: 1, #text: "Ada" }}]

// Preserve mixed content
n, err = formats.FromXML(strings.NewReader(`<p>Hello <b>world</b>!</p>`), func(o *formats.XMLOptions) {
    o.MixedContent = formats.XMLMixedContentPreserve
})
// n.Object = [{Key: "p", Value: [{#text: "Hello "}, {b: "world"}, {#text: "!"}]}]

// Namespace-qualified names
n, err = formats.FromXML(strings.NewReader(`<ns:root xmlns:ns="urn:x"><ns:a>1</ns:a></ns:root>`), func(o *formats.XMLOptions) {
    o.Namespaces = formats.XMLNamespacesQualified
})
// n.Object = [{Key: "ns:root", Value: {ns:a: 1}}]
Serializing TOON to JSON
// Write a TOON node as ordered JSON
n, _ := formats.FromJSON(strings.NewReader(`{"z":1,"a":{"b":2}}`))
var out bytes.Buffer
formats.ToJSON(&out, n)
// out: {"z":1,"a":{"b":2}}

// With indentation
formats.ToJSON(&out, n, func(o *formats.JSONOutputOptions) {
    o.Indent = "  "
})
// out:
// {
//   "z": 1,
//   "a": {
//     "b": 2
//   }
// }
High-level format conversions
// JSON β†’ TOON
var toonOut bytes.Buffer
formats.JSONToTOON(strings.NewReader(`{"a":1,"b":[2,3]}`), &toonOut, formats.JSONToTOONOptions{})
// toonOut: "a: 1\nb[2]: 2,3"

// TOON β†’ JSON
var jsonOut bytes.Buffer
formats.TOONToJSON(strings.NewReader("a: 1"), &jsonOut,
    formats.TOONToJSONOptions{JSON: formats.JSONOutputOptions{Indent: "  "}})
// jsonOut: "{\n  \"a\": 1\n}"

// YAML β†’ TOON
var out bytes.Buffer
formats.YAMLToTOON(strings.NewReader("a: 1\nb: 2"), &out, formats.YAMLToTOONOptions{})
// out: "a: 1\nb: 2"

// XML β†’ TOON
formats.XMLToTOON(strings.NewReader("<a>1</a>"), &out, formats.XMLToTOONOptions{})
// out: "a: 1"

// CSV β†’ TOON
formats.CSVToTOON(strings.NewReader("a\n1\n"), &out,
    formats.CSVToTOONOptions{CSV: formats.CSVOptions{HeaderMode: formats.CSVHeaderPresent, InferTypes: true}})
// out: "[1]{a}:\n  1"

CLI

Build:

go build ./cmd/toon

Encode external formats to TOON:

toon encode input.json
toon encode --input input.yaml --output output.toon
toon encode --format json < input.json
toon encode data.csv --csv-header present --csv-infer-types=true
toon encode data.tsv

Decode TOON to ordered JSON:

toon decode input.toon
toon decode input.toon --indent 4
toon decode input.toon --strict=false

Validate TOON:

toon validate input.toon

Important encode flags:

  • --format json|yaml|xml|csv|auto
  • --delimiter comma|tab|pipe
  • --indent N
  • --stats
  • --csv-header auto|present|absent
  • --csv-delimiter CHAR
  • --csv-infer-types true|false
  • --csv-root-key NAME
  • --yaml-docs error|array
  • --yaml-scalars core|string
  • --xml-attr-prefix @
  • --xml-text-key '#text'
  • --xml-infer-types true|false
  • --xml-mixed-content compact|preserve
  • --xml-namespaces local|qualified|uri

Defaults And Security

  • TOON decode strict mode is enabled by default, including when formats.TOONToJSONOptions.Decode sets other fields such as Limits; set formats.TOONToJSONOptions.NonStrict to turn it off.
  • Strict and non-strict decode both reject ill-formed UTF-8 (invalid_utf8); non-strict mode applies only the five recoveries listed in SPEC Β§14.4.
  • Dotted keys are always literal; there is no key folding or path expansion.
  • Number handling defaults to lossless raw tokens.
  • Out-of-range numbers (for example 1e500) decode with their token text kept in Number.Raw; the encoder's number mode decides how they are written (see Number Modes).
  • Encoding a string value or object key that contains ill-formed UTF-8 returns an invalid_utf8 error.
  • # comment lines are dropped by the decoder; the encoder never emits comments.
  • JSON input containing a lone surrogate escape is rejected instead of being replaced with U+FFFD.
  • JSON duplicate keys are rejected by default.
  • CSV library callers must choose header-present or header-absent mode explicitly; the CLI treats auto as header-present for normal CSV workflows.
  • XML DTDs are rejected and external entity processing is not enabled.
  • YAML aliases are resolved through the YAML AST with cycle rejection.
  • Resource limits are configurable for input bytes, string bytes, node count, depth, and array length.
  • CLI safe defaults set MaxDepth to 512 and MaxStringBytes to 64 MiB.
  • toon/reflect decode requires a non-nil pointer destination (toon.ErrNonPointerTarget otherwise).
  • toon/reflect cycles on encode are rejected with toon.ErrCyclicValue; type mismatches on decode surface as toon.ErrUnmarshalType.
  • toon/reflect []byte (and named []byte types) marshal as base64 strings and unmarshal from base64 strings.
  • toon/reflect TextMarshaler is honored on encode; TextUnmarshaler is honored on decode as an escape hatch.
  • toon/reflect decode propagates toon.Decode errors (e.g., ErrInvalidIndent, ErrDuplicateKey) with their original code via toon.CodeOf.

Deprecated Options

These fields remain on the option structs and are ignored by the encoder and decoder: EncodeOptions.IncludeLengthMarkers, EncodeOptions.KeyFolding, EncodeOptions.FlattenDepth, and DecodeOptions.ExpandPaths. The CLI has no flags for them, and neither do the MCP tools.

Number Modes

Decoding never converts numbers: every numeric token keeps its text in toon.Number.Raw, including values outside the float64 range such as 1e500 or 1e-400, in strict and non-strict mode.

Encoding follows EncodeOptions.NumberMode:

Mode Behavior
toon.NumberLossless (default) Writes the canonical decimal token without float64 conversion (1.200 β†’ 1.2, 1e500 β†’ 1e+500).
toon.NumberFloat64 Rounds through float64 (12345678901234567890 β†’ 12345678901234567000). Values outside the float64 range return an error.
toon.NumberStringForUnsafe Writes numbers outside the float64 range as quoted strings ("1e+500", "1e-400").

Numbers outside [1e-6, 1e21) are written in exponent form (1e-7, 1e+21); all others are plain decimals.

Patch releases must not change canonical output except to fix spec-compliance bugs.

Format Normalization

JSON:

  • Uses json.Decoder.Token() with UseNumber().
  • Preserves object order.
  • Rejects duplicate keys unless explicitly allowed, in which case deterministic last-write-wins applies.
  • Rejects lone surrogate escapes.

YAML:

  • Uses gopkg.in/yaml.v3 AST to preserve mapping order.
  • Multiple documents error by default or become an array when requested.
  • Core scalars become typed primitives; timestamps remain strings.
  • Merge keys are resolved deterministically.
  • Unknown tags and non-string keys are stringified.

CSV:

  • Header-present rows become arrays of objects keyed by header names.
  • Header-absent rows use generated field1, field2, ... keys.
  • Empty cells remain empty strings.
  • Type inference is configurable.
  • Numeric-looking cells with forbidden leading zeros remain strings.

XML:

  • Elements become object fields keyed by element name.
  • Attributes use @ by default.
  • Text with attributes or children uses #text by default.
  • Repeated child elements become arrays.
  • Comments and processing instructions are ignored.
  • Mixed content defaults to compact mode; preserve mode keeps exact text/child order as an array.
  • Namespace handling supports local, qualified, and URI-expanded modes.

Compatibility And Versioning

This module follows semantic versioning.

  • Patch releases fix bugs and spec-compliance issues without intentional API breaks.
  • Minor releases may add options and non-breaking APIs.
  • Major releases may track breaking TOON spec changes or public API changes.

The ordered toon.Node model is the stable core API. Go maps are not used as the primary representation for ordered documents.

Release Verification

Recommended release checks:

go test ./...
go test -race ./...
go test ./toon -run=Fuzz -fuzz=FuzzDecode -fuzztime=10s
go test ./toon/reflect -run=Fuzz -fuzz=FuzzMarshalValue -fuzztime=10s
go test ./toon/reflect -run=Fuzz -fuzz=FuzzUnmarshalValue -fuzztime=10s
go test ./... -bench=. -run='^$'

go test ./toon also runs the vendored TOON v4.4 spec fixtures in toon/testdata/spec/. Re-vendor them with scripts/sync-spec-fixtures.sh [tag] (pinned to v4.4.0).

Supported build-check targets for cmd/toon:

  • linux/amd64
  • linux/arm64
  • darwin/amd64
  • darwin/arm64
  • windows/amd64

Benchmarks

Latest local benchmark snapshot, run with Go 1.26 on Darwin ARM64, Apple M4 Max:

go test ./toon ./formats -bench=. -run='^$' -benchmem -count=5
Benchmark Time/op Bytes/op Allocs/op
BenchmarkEncodeLargeOrderedObject 48-49 us 33 KB 110
BenchmarkEncodeLargeTabularArray 91-94 us 66 KB 113
BenchmarkDecodeRepresentativeTOON 192-204 us 565 KB 6031
BenchmarkLargeJSONInputNormalization 197-201 us 772 KB 7033
BenchmarkLargeCSVInputNormalization 294-297 us 982 KB 12026
BenchmarkJSONOutputOrderedNode 88-92 us 188 KB 122
BenchmarkWideJSONObjectNormalization 89-91 us 329 KB 1050
BenchmarkWideCSVHeaderNormalization 147 us 557 KB 2085
BenchmarkMarshalSmallStruct (toon/reflect) ~3.0 us 4.5 KB 62
BenchmarkUnmarshalSmallStruct (toon/reflect) ~3.3 us 5.3 KB 50
BenchmarkMarshalLargeMap (toon/reflect, 1k keys) ~333 us 305 KB 6122
BenchmarkUnmarshalLargeArray (toon/reflect, 256 ints) ~21 us 42 KB 280
BenchmarkNodeFromValue (toon/reflect) ~2.0 us 3.5 KB 48
BenchmarkNodeToValue (toon/reflect) ~1.5 us 0.8 KB 9

JSON normalization uses a purpose-built ordered scanner to preserve object order, detect duplicate keys, and retain lossless number tokens without routing through Go maps.

Directories ΒΆ

Path Synopsis
cmd
toon command
mcp
go-toon-mcp command
reflect
Package reflect provides a reflection-based convenience layer above the toon.Node ordered document model.
Package reflect provides a reflection-based convenience layer above the toon.Node ordered document model.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL