feat: add Decoder.UseNumber and the Number type
Test / test (push) Successful in 1m52s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-21 23:49:39 +02:00
parent 582222738b
commit ce0c1ebd9d
9 changed files with 320 additions and 4 deletions
+7
View File
@@ -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
+25
View File
@@ -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:
+123
View File
@@ -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)
}
}
}
+33 -1
View File
@@ -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
+11
View File
@@ -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:
+52
View File
@@ -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)
}
}
})
}
+16 -3
View File
@@ -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
+44
View File
@@ -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.
+9
View File
@@ -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
}