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 (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 nests arrays or inline tables deeper used to run the stack out and is now
rejected with a `SyntaxError` naming the limit. 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 ### Changed
+25
View File
@@ -26,6 +26,7 @@ var timeType = reflect.TypeFor[time.Time]()
var ( var (
unmarshalerType = reflect.TypeFor[Unmarshaler]() unmarshalerType = reflect.TypeFor[Unmarshaler]()
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]() textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
numberType = reflect.TypeFor[Number]()
) )
// The per-type flags record which interface lookups a decode into that type // 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 setDuration(dst, v)
} }
return setBasic(dst, reflect.ValueOf(v), "string") return setBasic(dst, reflect.ValueOf(v), "string")
case Number:
return setNumber(dst, v)
case bool: case bool:
return setBasic(dst, reflect.ValueOf(v), "bool") return setBasic(dst, reflect.ValueOf(v), "bool")
case int64: case int64:
@@ -389,6 +392,28 @@ func setDuration(dst reflect.Value, s string) error {
return nil 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 { func setInt(dst reflect.Value, v int64) error {
switch dst.Kind() { switch dst.Kind() {
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: 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) 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 When decoding into a struct, these values convert onto the destination's
concrete types: any integer or unsigned width, floats, slices, nested structs 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 ### 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 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`. 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 ### Date-time values
Offset date-times decode into `OffsetDateTime`, whose embedded `time.Time` is the Offset date-times decode into `OffsetDateTime`, whose embedded `time.Time` is the
@@ -639,6 +664,7 @@ concurrent use.
| Method | Default | Effect | | Method | Default | Effect |
|---|---|---| |---|---|---|
| `DisallowUnknownFields()` | off | a key with no matching struct field is an error | | `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 | | `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 | | `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). 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 ### Date-time wrappers
```go ```go
+11
View File
@@ -750,6 +750,11 @@ func normaliseValue(v reflect.Value) (any, error) {
if v.Type() == durationType { if v.Type() == durationType {
return time.Duration(v.Int()).String(), nil 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 // A type that renders itself as text becomes a TOML string, scalar kinds
// and structs alike. // and structs alike.
s, isText, err := textValue(v) s, isText, err := textValue(v)
@@ -1171,6 +1176,12 @@ func (e *encoder) writeValue(val any) error {
case int64: case int64:
e.buf.WriteString(strconv.FormatInt(v, 10)) e.buf.WriteString(strconv.FormatInt(v, 10))
return nil 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: case float64:
return e.writeFloat(v) return e.writeFloat(v)
case time.Time: 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) 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 return tree, err
} }
// parseOptions bound the work one parse may do. A zero field takes the // parseOptions bound the work one parse may do and the shape it produces. A
// default. // zero field takes the default.
type parseOptions struct { type parseOptions struct {
maxDepth int maxDepth int
maxInputSize int maxInputSize int
useNumber bool
} }
// parseWithOptions parses data, building the node tree of a Document when // 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 // The parser scans data in place; it only reads the buffer, and every
// string it stores in the tree is copied out of it. // 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() tree, err := p.parse()
if err != nil { if err != nil {
return nil, nil, err 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. // strictness and configurable limits on the parse it performs.
type Decoder struct { type Decoder struct {
disallowUnknown bool disallowUnknown bool
useNumber bool
maxDepth int maxDepth int
maxInputSize int maxInputSize int
} }
@@ -207,6 +209,16 @@ func (d *Decoder) DisallowUnknownFields() *Decoder {
return d 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 // 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 // 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 // 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{ tree, _, err := parseWithOptions(ctx, data, parseOptions{
maxDepth: d.maxDepth, maxDepth: d.maxDepth,
maxInputSize: d.maxInputSize, maxInputSize: d.maxInputSize,
useNumber: d.useNumber,
}, false) }, false)
if err != nil { if err != nil {
return err return err
+44
View File
@@ -10,6 +10,50 @@ import (
"strings" "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 // decodeNumber parses a bare numeric token under strict TOML rules: no leading
// zeros, underscores only between digits, prefixed radixes without a sign, and // zeros, underscores only between digits, prefixed radixes without a sign, and
// floats with explicit fraction/exponent digits. // floats with explicit fraction/exponent digits.
+9
View File
@@ -36,6 +36,10 @@ type parser struct {
maxDepth int maxDepth int
depth 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 root map[string]any
current map[string]any current map[string]any
headers map[string]bool headers map[string]bool
@@ -721,6 +725,11 @@ func (p *parser) parseAtom() (any, error) {
if err != nil { if err != nil {
return nil, p.errf("%s", err) 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 return v, nil
} }