diff --git a/CHANGELOG.md b/CHANGELOG.md index 36ba5ed..68f409f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -46,6 +46,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 (10000 levels, which no hand-written document approaches): a document that nests arrays or inline tables deeper used to run the stack out and is now rejected with a `SyntaxError` naming the limit. +- `Decoder.UseNumber()` decodes the integers and floats of the document into + `Number`, which carries the literal the document wrote, so `0x1f`, `1_000`, + `+1.0` and `inf` survive a round trip with their spelling instead of the + normalised `31`, `1000` and `1.0`. Typed destinations take the evaluated + value as before, a `Number` field takes the literal, and `Marshal` writes a + `Number` back as its bare literal, rejecting one that is not a valid TOML + number. ### Changed diff --git a/decode.go b/decode.go index 2bbd9fb..492b6dc 100644 --- a/decode.go +++ b/decode.go @@ -26,6 +26,7 @@ var timeType = reflect.TypeFor[time.Time]() var ( unmarshalerType = reflect.TypeFor[Unmarshaler]() textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() + numberType = reflect.TypeFor[Number]() ) // The per-type flags record which interface lookups a decode into that type @@ -181,6 +182,8 @@ func (d *decoder) assign(data any, dst reflect.Value) error { return setDuration(dst, v) } return setBasic(dst, reflect.ValueOf(v), "string") + case Number: + return setNumber(dst, v) case bool: return setBasic(dst, reflect.ValueOf(v), "bool") case int64: @@ -389,6 +392,28 @@ func setDuration(dst reflect.Value, s string) error { return nil } +// setNumber stores a Number, the literal UseNumber keeps. A Number destination +// takes the literal as it is; every other destination takes the evaluated +// value through the ordinary rules, so an integer field, a float field and a +// duration field all read a Number the way they read the evaluated kind. +func setNumber(dst reflect.Value, n Number) error { + if dst.Type() == numberType { + dst.SetString(string(n)) + return nil + } + v, err := decodeNumber(string(n)) + if err != nil { + return fmt.Errorf("interpres: %w", err) + } + switch v := v.(type) { + case int64: + return setInt(dst, v) + case float64: + return setFloat(dst, v) + } + return fmt.Errorf("interpres: cannot assign number to %s", dst.Type()) +} + func setInt(dst reflect.Value, v int64) error { switch dst.Kind() { case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: diff --git a/decode_test.go b/decode_test.go index 378de68..dc43d7a 100644 --- a/decode_test.go +++ b/decode_test.go @@ -1097,3 +1097,126 @@ func TestUnmarshalerReceivesOffsetDateTime(t *testing.T) { t.Errorf("local kind = %q, want interpres.LocalDateTime", cfg.L.Kind) } } + +func TestDecoderUseNumber(t *testing.T) { + data := []byte(`hex = 0x1f +sep = 1_000 +signed = +1.0 +exp = 1e6 +posinf = inf +negzero = -0.0 +plain = 42 +frac = 2.5 +`) + t.Run("the tree keeps the literal", func(t *testing.T) { + var tree map[string]any + if err := NewDecoder().UseNumber().Decode(data, &tree); err != nil { + t.Fatal(err) + } + for lit, key := range map[string]string{ + "0x1f": "hex", "1_000": "sep", "+1.0": "signed", "1e6": "exp", + "inf": "posinf", "-0.0": "negzero", "42": "plain", "2.5": "frac", + } { + got, ok := tree[key].(Number) + if !ok { + t.Errorf("%s = %T, want Number", key, tree[key]) + continue + } + if string(got) != lit { + t.Errorf("%s = %q, want %q", key, got, lit) + } + } + }) + t.Run("typed fields take the evaluated value", func(t *testing.T) { + var cfg struct { + Hex Number `toml:"hex"` + Plain int64 `toml:"plain"` + Frac float64 `toml:"frac"` + Rate time.Duration + } + dec := NewDecoder().UseNumber() + if err := dec.Decode([]byte("hex = 0x1f\nplain = 42\nfrac = 2.5\nRate = 1_000\n"), &cfg); err != nil { + t.Fatal(err) + } + if cfg.Hex != "0x1f" { + t.Errorf("hex = %q, want 0x1f", cfg.Hex) + } + if cfg.Plain != 42 { + t.Errorf("plain = %d, want 42", cfg.Plain) + } + if cfg.Frac != 2.5 { + t.Errorf("frac = %g, want 2.5", cfg.Frac) + } + if cfg.Rate != 1000 { + t.Errorf("rate = %s, want 1µs", cfg.Rate) + } + }) + t.Run("invalid numbers are still parse errors", func(t *testing.T) { + for _, in := range []string{"a = 01\n", "a = 1__0\n", "a = 1x\n"} { + var tree map[string]any + if err := NewDecoder().UseNumber().Decode([]byte(in), &tree); err == nil { + t.Errorf("%q decoded without an error", in) + } + } + }) + t.Run("without UseNumber the tree holds the evaluated kinds", func(t *testing.T) { + var tree map[string]any + if err := NewDecoder().Decode([]byte("hex = 0x1f\nfrac = 2.5\n"), &tree); err != nil { + t.Fatal(err) + } + if v, ok := tree["hex"].(int64); !ok || v != 31 { + t.Errorf("hex = %#v, want int64 31", tree["hex"]) + } + if v, ok := tree["frac"].(float64); !ok || v != 2.5 { + t.Errorf("frac = %#v, want float64 2.5", tree["frac"]) + } + }) +} + +func TestNumberMethods(t *testing.T) { + tests := []struct { + lit Number + wantI int64 + wantF float64 + intErr bool + }{ + {lit: "42", wantI: 42, wantF: 42}, + {lit: "0x1f", wantI: 31, wantF: 31}, + {lit: "1_000", wantI: 1000, wantF: 1000}, + {lit: "+1.0", wantF: 1, intErr: true}, + {lit: "1e6", wantF: 1e6, intErr: true}, + {lit: "inf", wantF: math.Inf(1), intErr: true}, + {lit: "-2.5", wantF: -2.5, intErr: true}, + } + for _, tt := range tests { + i, err := tt.lit.Int64() + if tt.intErr && err == nil { + t.Errorf("%q.Int64() succeeded with %d, want an error", tt.lit, i) + } + if !tt.intErr { + if err != nil { + t.Errorf("%q.Int64() = %v", tt.lit, err) + continue + } + if i != tt.wantI { + t.Errorf("%q.Int64() = %d, want %d", tt.lit, i, tt.wantI) + } + } + f, err := tt.lit.Float64() + if err != nil { + t.Errorf("%q.Float64() = %v", tt.lit, err) + continue + } + if f != tt.wantF { + t.Errorf("%q.Float64() = %g, want %g", tt.lit, f, tt.wantF) + } + } + for _, lit := range []Number{"01", "1__0", "abc", ""} { + if _, err := lit.Float64(); err == nil { + t.Errorf("%q.Float64() succeeded, want an error", lit) + } + if _, err := lit.Int64(); err == nil { + t.Errorf("%q.Int64() succeeded, want an error", lit) + } + } +} diff --git a/docs/API.md b/docs/API.md index 0520770..60fcf3a 100644 --- a/docs/API.md +++ b/docs/API.md @@ -157,7 +157,9 @@ and every 64 fields during the reflection walk. 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`. +and `map[string]T`. `Decoder.UseNumber` replaces the two numeric rows of the +table with `Number`, which keeps the literal; see +[Numbers as literals](#numbers-as-literals). ### Target constraints @@ -211,6 +213,29 @@ The decoder converts to the destination type with explicit overflow checks: 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`. +### Numbers as literals + +`NewDecoder().UseNumber()` decodes every integer and float into `Number`, a +string type that carries the literal the document wrote: `0x1f`, `1_000`, +`+1.0`, `inf`. The shape is validated as strictly as ever, so `01` and `1__0` +remain parse errors; only the evaluated value is replaced by the literal. A +round trip through the value tree and `Marshal` keeps the spelling, where the +default tree normalises `0x1f` to `31` and `+1.0` to `1.0`. + +```go +var tree map[string]any +err := interpres.NewDecoder().UseNumber().Decode(data, &tree) +lit := tree["rate"].(interpres.Number) // "1_000" +``` + +A destination of a concrete kind is unaffected: an `int64` field, a `float64` +field and a `time.Duration` field take the evaluated value they always took, +and a `Number` field takes the literal. `Number.Float64` and `Number.Int64` +evaluate the literal on demand, with an error for a float asked as an integer +and for a literal that is not a valid TOML number. `Marshal` writes a `Number` +as its bare literal and rejects one that is not a valid TOML number, whether it +stands alone or inside a value array. + ### Date-time values Offset date-times decode into `OffsetDateTime`, whose embedded `time.Time` is the @@ -639,6 +664,7 @@ concurrent use. | Method | Default | Effect | |---|---|---| | `DisallowUnknownFields()` | off | a key with no matching struct field is an error | +| `UseNumber()` | off | integers and floats decode into `Number`, which carries the literal; see [Numbers as literals](#numbers-as-literals) | | `MaxDepth(depth int)` | `10000` | bound how deeply arrays and inline tables may nest | | `MaxInputSize(size int)` | no limit | bound the size of the document, in bytes | @@ -688,6 +714,12 @@ See [Custom encoding](#custom-encoding-marshaler). See [Custom decoding](#custom-decoding-unmarshaler). +### `type Number string` + +The literal a number was written with, what `UseNumber` decodes into and what +`Marshal` writes back as it is. See +[Numbers as literals](#numbers-as-literals). + ### Date-time wrappers ```go diff --git a/encode.go b/encode.go index 6179ce0..0414664 100644 --- a/encode.go +++ b/encode.go @@ -750,6 +750,11 @@ func normaliseValue(v reflect.Value) (any, error) { if v.Type() == durationType { return time.Duration(v.Int()).String(), nil } + // A Number goes out as the literal it carries, the one string-kind value + // that is not written quoted. + if v.Type() == numberType { + return Number(v.String()), nil + } // A type that renders itself as text becomes a TOML string, scalar kinds // and structs alike. s, isText, err := textValue(v) @@ -1171,6 +1176,12 @@ func (e *encoder) writeValue(val any) error { case int64: e.buf.WriteString(strconv.FormatInt(v, 10)) return nil + case Number: + if _, err := decodeNumber(string(v)); err != nil { + return fmt.Errorf("interpres: %w", err) + } + e.buf.WriteString(string(v)) + return nil case float64: return e.writeFloat(v) case time.Time: diff --git a/encode_test.go b/encode_test.go index 4016308..754c7e4 100644 --- a/encode_test.go +++ b/encode_test.go @@ -1946,3 +1946,55 @@ func TestMarshalerResultIsNormalised(t *testing.T) { t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) } } + +func TestMarshalNumber(t *testing.T) { + t.Run("the literal is written as it is", func(t *testing.T) { + out, err := Marshal(map[string]any{ + "hex": Number("0x1f"), "sep": Number("1_000"), + "signed": Number("+1.0"), "inf": Number("inf"), + }) + if err != nil { + t.Fatal(err) + } + want := "hex = 0x1f\ninf = inf\nsep = 1_000\nsigned = +1.0\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } + }) + t.Run("a Number field round-trips", func(t *testing.T) { + type Cfg struct { + Rate Number `toml:"rate"` + } + out, err := Marshal(Cfg{Rate: "1_000"}) + if err != nil { + t.Fatal(err) + } + if string(out) != "rate = 1_000\n" { + t.Fatalf("output %q", out) + } + var back map[string]any + if err := NewDecoder().UseNumber().Decode(out, &back); err != nil { + t.Fatal(err) + } + if got, ok := back["rate"].(Number); !ok || got != "1_000" { + t.Errorf("round trip = %#v, want Number(\"1_000\")", back["rate"]) + } + }) + t.Run("a Number inside a value array", func(t *testing.T) { + out, err := Marshal(map[string]any{"vals": []any{Number("0x1f"), "s", int64(2)}}) + if err != nil { + t.Fatal(err) + } + want := "vals = [0x1f, \"s\", 2]\n" + if string(out) != want { + t.Errorf("output mismatch:\ngot: %q\nwant: %q", out, want) + } + }) + t.Run("an invalid literal is an error", func(t *testing.T) { + for _, lit := range []Number{"01", "1__0", "abc", "1.2.3"} { + if _, err := Marshal(map[string]any{"n": lit}); err == nil { + t.Errorf("Number(%q) encoded without an error", lit) + } + } + }) +} diff --git a/interpres.go b/interpres.go index 2dd1632..e04dd3d 100644 --- a/interpres.go +++ b/interpres.go @@ -127,11 +127,12 @@ func ParseMapContext(ctx context.Context, data []byte) (map[string]any, error) { return tree, err } -// parseOptions bound the work one parse may do. A zero field takes the -// default. +// parseOptions bound the work one parse may do and the shape it produces. A +// zero field takes the default. type parseOptions struct { maxDepth int maxInputSize int + useNumber bool } // parseWithOptions parses data, building the node tree of a Document when @@ -152,7 +153,7 @@ func parseWithOptions(ctx context.Context, data []byte, opts parseOptions, wantD } // The parser scans data in place; it only reads the buffer, and every // string it stores in the tree is copied out of it. - p := &parser{src: data, line: 1, ctx: ctx, maxDepth: maxDepth, wantDoc: wantDoc} + p := &parser{src: data, line: 1, ctx: ctx, maxDepth: maxDepth, wantDoc: wantDoc, useNumber: opts.useNumber} tree, err := p.parse() if err != nil { return nil, nil, err @@ -193,6 +194,7 @@ func UnmarshalContext(ctx context.Context, data []byte, v any) error { // strictness and configurable limits on the parse it performs. type Decoder struct { disallowUnknown bool + useNumber bool maxDepth int maxInputSize int } @@ -207,6 +209,16 @@ func (d *Decoder) DisallowUnknownFields() *Decoder { return d } +// UseNumber causes the numbers of the document to reach the value tree as a +// Number carrying the literal the document wrote, so 0x1f, 1_000, +1.0 and +// inf survive a round trip with their spelling intact. A destination of a +// concrete numeric kind still takes the evaluated value; the literal is kept +// only where a Number, or an any, receives it. +func (d *Decoder) UseNumber() *Decoder { + d.useNumber = true + return d +} + // MaxDepth bounds how deeply arrays and inline tables may nest in a document // this decoder accepts. The parser is a recursive descent, so a document that // nests without bound would exhaust the stack; one that nests deeper than the @@ -242,6 +254,7 @@ func (d *Decoder) DecodeContext(ctx context.Context, data []byte, v any) error { tree, _, err := parseWithOptions(ctx, data, parseOptions{ maxDepth: d.maxDepth, maxInputSize: d.maxInputSize, + useNumber: d.useNumber, }, false) if err != nil { return err diff --git a/number.go b/number.go index 6dea13d..32a966e 100644 --- a/number.go +++ b/number.go @@ -10,6 +10,50 @@ import ( "strings" ) +// A Number holds a TOML number as the literal the document wrote it with: +// 0x1f, 1_000, +1.0, inf. Decoder.UseNumber decodes integers and floats into +// it, so a round trip through the value tree keeps the spelling instead of a +// normalised one, and Marshal writes the literal back as it is. +// +// Number is a string type, the shape encoding/json.Number has: the literal is +// carried, not evaluated. Float64 and Int64 evaluate it on demand, and a +// destination of another numeric kind takes the evaluated value through the +// ordinary conversion rules. +type Number string + +// Float64 returns the value as a float64. An integer or radix literal +// converts; a literal that is not a valid TOML number is an error. +func (n Number) Float64() (float64, error) { + v, err := decodeNumber(string(n)) + if err != nil { + return 0, fmt.Errorf("interpres: %w", err) + } + switch v := v.(type) { + case float64: + return v, nil + case int64: + return float64(v), nil + } + return 0, fmt.Errorf("interpres: %q is not a number", n) +} + +// Int64 returns the value as an int64. A float literal is an error, however +// whole its value, and so is a literal that is not a valid TOML number. +func (n Number) Int64() (int64, error) { + v, err := decodeNumber(string(n)) + if err != nil { + return 0, fmt.Errorf("interpres: %w", err) + } + i, ok := v.(int64) + if !ok { + return 0, fmt.Errorf("interpres: %q is not an integer", n) + } + return i, nil +} + +// String returns the literal itself. +func (n Number) String() string { return string(n) } + // decodeNumber parses a bare numeric token under strict TOML rules: no leading // zeros, underscores only between digits, prefixed radixes without a sign, and // floats with explicit fraction/exponent digits. diff --git a/parser.go b/parser.go index 0ab0688..87c0214 100644 --- a/parser.go +++ b/parser.go @@ -36,6 +36,10 @@ type parser struct { maxDepth int depth int + // useNumber leaves the numbers a Number carries the literal, instead of + // the evaluated int64 or float64 the tree holds by default. + useNumber bool + root map[string]any current map[string]any headers map[string]bool @@ -721,6 +725,11 @@ func (p *parser) parseAtom() (any, error) { if err != nil { return nil, p.errf("%s", err) } + // The token's shape is validated either way; UseNumber only keeps the + // literal instead of the evaluated value. + if p.useNumber { + return Number(tok), nil + } return v, nil }