// Copyright (c) 2026 Petr BalvĂ­n (https://petrbalvin.org) // SPDX-License-Identifier: MIT package interpres import ( "fmt" "math" "strconv" "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. func decodeNumber(tok string) (any, error) { switch tok { case "inf", "+inf": return math.Inf(1), nil case "-inf": return math.Inf(-1), nil case "nan", "+nan", "-nan": return math.NaN(), nil } if len(tok) >= 2 && tok[0] == '0' && (tok[1] == 'x' || tok[1] == 'o' || tok[1] == 'b') { return decodeRadix(tok) } if strings.ContainsAny(tok, ".eE") { return decodeFloat(tok) } return decodeDecimalInt(tok) } func decodeDecimalInt(tok string) (any, error) { sign, body := splitSign(tok) digits, err := joinDigits(body, isDecDigit) if err != nil { return nil, err } if err := checkNoLeadingZero(digits); err != nil { return nil, err } // An unsigned token parses in place; only a sign needs the concatenated // copy, and concatenating an empty sign still allocated. if sign == "" { i, err := strconv.ParseInt(digits, 10, 64) if err != nil { return nil, fmt.Errorf("integer %q out of range", tok) } return i, nil } i, err := strconv.ParseInt(sign+digits, 10, 64) if err != nil { return nil, fmt.Errorf("integer %q out of range", tok) } return i, nil } func decodeRadix(tok string) (any, error) { var base int var isDigit func(byte) bool switch tok[1] { case 'x': base, isDigit = 16, isHexDigit case 'o': base, isDigit = 8, isOctDigit case 'b': base, isDigit = 2, isBinDigit } digits, err := joinDigits(tok[2:], isDigit) if err != nil { return nil, err } i, err := strconv.ParseInt(digits, base, 64) if err != nil { return nil, fmt.Errorf("integer %q out of range", tok) } return i, nil } func decodeFloat(tok string) (any, error) { sign, s := splitSign(tok) mantissa, exp := s, "" hasExp := false if i := strings.IndexAny(s, "eE"); i >= 0 { mantissa, exp, hasExp = s[:i], s[i+1:], true } intPart, frac, hasDot := mantissa, "", false if i := strings.IndexByte(mantissa, '.'); i >= 0 { intPart, frac, hasDot = mantissa[:i], mantissa[i+1:], true } if !hasDot && !hasExp { return nil, fmt.Errorf("invalid float %q", tok) } ip, err := joinDigits(intPart, isDecDigit) if err != nil { return nil, err } if err := checkNoLeadingZero(ip); err != nil { return nil, err } fp := "" if hasDot { if fp, err = joinDigits(frac, isDecDigit); err != nil { return nil, err } } // The ABNF requires at least one digit after the exponent marker, so a // trailing e or E is an error even though strconv would accept it. The // digits are a zero-prefixable integer, so leading zeros are fine here // (the corpus holds valid cases such as 1e06 and 0e00). esign, ed := "", "" if hasExp { var digits string esign, digits = splitSign(exp) if ed, err = joinDigits(digits, isDecDigit); err != nil { return nil, err } } // The checks above validated the token's shape, and every character a // valid token may carry is one strconv.ParseFloat accepts in place, so // only a token with underscores needs the stripped rebuild. if !strings.ContainsRune(tok, '_') { f, err := strconv.ParseFloat(tok, 64) if err != nil { return nil, fmt.Errorf("invalid float %q", tok) } return f, nil } build := sign + ip if hasDot { build += "." + fp } if hasExp { build += "e" + esign + ed } f, err := strconv.ParseFloat(build, 64) if err != nil { return nil, fmt.Errorf("invalid float %q", tok) } return f, nil } // joinDigits validates that every rune is a digit (per isDigit) and that each // underscore sits between two digits, returning the digits with underscores // removed. A token without underscores, the common case, is validated in // place and returned without a copy. func joinDigits(s string, isDigit func(byte) bool) (string, error) { if s == "" { return "", fmt.Errorf("number is missing digits") } if !strings.ContainsRune(s, '_') { for i := range len(s) { if !isDigit(s[i]) { return "", fmt.Errorf("invalid character %q in number", string(s[i])) } } return s, nil } var b strings.Builder for i := range len(s) { c := s[i] if c == '_' { if i == 0 || i == len(s)-1 || !isDigit(s[i-1]) || !isDigit(s[i+1]) { return "", fmt.Errorf("misplaced underscore in number %q", s) } continue } if !isDigit(c) { return "", fmt.Errorf("invalid character %q in number", string(c)) } b.WriteByte(c) } return b.String(), nil } func checkNoLeadingZero(digits string) error { if len(digits) > 1 && digits[0] == '0' { return fmt.Errorf("leading zeros are not allowed in numbers") } return nil } func splitSign(tok string) (sign, rest string) { if tok != "" && (tok[0] == '+' || tok[0] == '-') { if tok[0] == '-' { return "-", tok[1:] } return "", tok[1:] } return "", tok } func isDecDigit(c byte) bool { return c >= '0' && c <= '9' } func isOctDigit(c byte) bool { return c >= '0' && c <= '7' } func isBinDigit(c byte) bool { return c == '0' || c == '1' } func isHexDigit(c byte) bool { return isDecDigit(c) || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') }