Files
interpres/number.go
T

251 lines
6.7 KiB
Go
Raw Normal View History

2026-08-19 09:47:00 +02:00
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (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. The NumbersAsLiterals option 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) }
2026-08-19 09:47:00 +02:00
// 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
}
2026-08-19 09:47:00 +02:00
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, ""
2026-09-17 21:29:07 +02:00
hasExp := false
2026-08-19 09:47:00 +02:00
if i := strings.IndexAny(s, "eE"); i >= 0 {
2026-09-17 21:29:07 +02:00
mantissa, exp, hasExp = s[:i], s[i+1:], true
2026-08-19 09:47:00 +02:00
}
intPart, frac, hasDot := mantissa, "", false
if i := strings.IndexByte(mantissa, '.'); i >= 0 {
intPart, frac, hasDot = mantissa[:i], mantissa[i+1:], true
}
2026-09-17 21:29:07 +02:00
if !hasDot && !hasExp {
2026-08-19 09:47:00 +02:00
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 := ""
2026-08-19 09:47:00 +02:00
if hasDot {
if fp, err = joinDigits(frac, isDecDigit); err != nil {
2026-08-19 09:47:00 +02:00
return nil, err
}
}
2026-09-17 21:29:07 +02:00
// 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 := "", ""
2026-09-17 21:29:07 +02:00
if hasExp {
var digits string
esign, digits = splitSign(exp)
if ed, err = joinDigits(digits, isDecDigit); err != nil {
2026-08-19 09:47:00 +02:00
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
}
2026-08-19 09:47:00 +02:00
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.
2026-08-19 09:47:00 +02:00
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
}
2026-08-19 09:47:00 +02:00
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')
}