251 lines
6.7 KiB
Go
251 lines
6.7 KiB
Go
// 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) }
|
|
|
|
// 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')
|
|
}
|