Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
634 changes: 317 additions & 317 deletions README.md

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions byteseq.go
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,8 @@ func EqualFold[S byteSeq](b, s S) bool {
return true
}

// TrimLeft is the equivalent of strings/bytes.TrimLeft
// TrimLeft removes all leading occurrences of the byte cutset from s.
// Unlike strings/bytes.TrimLeft, cutset is a single byte, not a set of characters.
func TrimLeft[S byteSeq](s S, cutset byte) S {
lenStr, start := len(s), 0
for start < lenStr && s[start] == cutset {
Expand All @@ -66,7 +67,8 @@ func TrimLeft[S byteSeq](s S, cutset byte) S {
return s[start:]
}

// Trim is the equivalent of strings/bytes.Trim
// Trim removes all leading and trailing occurrences of the byte cutset from s.
// Unlike strings/bytes.Trim, cutset is a single byte, not a set of characters.
func Trim[S byteSeq](s S, cutset byte) S {
i, j := 0, len(s)-1
for ; i <= j; i++ {
Expand All @@ -83,7 +85,8 @@ func Trim[S byteSeq](s S, cutset byte) S {
return s[i : j+1]
}

// TrimRight is the equivalent of strings/bytes.TrimRight
// TrimRight removes all trailing occurrences of the byte cutset from s.
// Unlike strings/bytes.TrimRight, cutset is a single byte, not a set of characters.
func TrimRight[S byteSeq](s S, cutset byte) S {
lenStr := len(s)
for lenStr > 0 && s[lenStr-1] == cutset {
Expand Down
28 changes: 21 additions & 7 deletions common.go
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,10 @@ func IncrementIPRange(ip net.IP) {
}

// ConvertToBytes returns integer size of bytes from human-readable string, ex. 42kb, 42M
// Returns 0 if string is unrecognized
// Decimal units are powers of 1000 (42k = 42000); binary units with an 'i' infix
// are powers of 1024 (42Ki = 43008). Note that ByteSize formats with powers of 1024,
// so use binary suffixes for a lossless round-trip.
// Returns 0 if the string is unrecognized or negative.
Comment thread
ReneWerner87 marked this conversation as resolved.
func ConvertToBytes(humanReadableString string) int {
strLen := len(humanReadableString)
if strLen == 0 {
Expand Down Expand Up @@ -189,7 +192,7 @@ func ConvertToBytes(humanReadableString string) int {
if strings.IndexByte(numPart, '.') >= 0 {
var err error
size, err = ParseFloat64(numPart)
if err != nil {
if err != nil || size < 0 {
return 0
}
} else {
Expand All @@ -201,17 +204,28 @@ func ConvertToBytes(humanReadableString string) int {
}

if unitPrefixPos > 0 {
// An 'i' after the unit prefix selects binary units (KiB = 1024).
binary := unitPrefixPos+1 < strLen &&
(humanReadableString[unitPrefixPos+1] == 'i' || humanReadableString[unitPrefixPos+1] == 'I')
var decMul, binMul float64
switch humanReadableString[unitPrefixPos] {
case 'k', 'K':
size *= 1e3
decMul, binMul = 1e3, 1<<10
case 'm', 'M':
size *= 1e6
decMul, binMul = 1e6, 1<<20
case 'g', 'G':
size *= 1e9
decMul, binMul = 1e9, 1<<30
case 't', 'T':
size *= 1e12
decMul, binMul = 1e12, 1<<40
case 'p', 'P':
size *= 1e15
decMul, binMul = 1e15, 1<<50
default:
decMul, binMul = 1, 1
}
if binary {
size *= binMul
} else {
size *= decMul
}
}

Expand Down
15 changes: 15 additions & 0 deletions common_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func Test_UUIDv4(t *testing.T) {
res := UUIDv4()
require.Len(t, res, 36)
require.NotEqual(t, "00000000-0000-0000-0000-000000000000", res)
require.Regexp(t, `^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$`, res)
}

func Test_UUIDv4_Concurrency(t *testing.T) {
Expand Down Expand Up @@ -180,6 +181,20 @@ func Test_ConvertToBytes(t *testing.T) {
require.Equal(t, 42*1000000000000000, ConvertToBytes("42PB"))
require.Equal(t, 42*1000000000000000, ConvertToBytes("42pb"))

// Test binary units (KiB = 1024)
require.Equal(t, 42*1024, ConvertToBytes("42Ki"))
require.Equal(t, 42*1024, ConvertToBytes("42KiB"))
require.Equal(t, 42*1024, ConvertToBytes("42kib"))
require.Equal(t, 42*1024*1024, ConvertToBytes("42MiB"))
require.Equal(t, 42*1024*1024*1024, ConvertToBytes("42GiB"))
require.Equal(t, 42*1024*1024*1024*1024, ConvertToBytes("42TiB"))
require.Equal(t, 42*1024*1024*1024*1024*1024, ConvertToBytes("42PiB"))
require.Equal(t, int(1.5*1024), ConvertToBytes("1.5KiB"))

// Negative sizes are unrecognized
require.Equal(t, 0, ConvertToBytes("-5k"))
require.Equal(t, 0, ConvertToBytes("-5.0k"))

// Test edge cases and error conditions
require.Equal(t, 0, ConvertToBytes("string"))
require.Equal(t, 0, ConvertToBytes("MB"))
Expand Down
3 changes: 3 additions & 0 deletions convert.go
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,9 @@ func ToString(arg any, timeFormat ...string) string {
return ""
}
return ToString(v.Interface(), timeFormat...)
// error before fmt.Stringer to match the fmt package's precedence
case error:
return v.Error()
case fmt.Stringer:
return v.String()
// Handle common pointer types directly to avoid reflection
Expand Down
4 changes: 4 additions & 0 deletions convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
package utils

import (
"errors"
"fmt"
"math"
"reflect"
Expand Down Expand Up @@ -119,6 +120,9 @@ func Test_ToString(t *testing.T) {
// fmt.Stringer
{name: "fmt.Stringer", input: stringerSample, expected: "Stringer Value"},

// error
{name: "error", input: errors.New("something failed"), expected: "something failed"},

// Composite types (arrays, slices)
{name: "[]string", input: []string{"Hello", "World"}, expected: "[Hello World]"},
{name: "[]int", input: []int{42, 21}, expected: "[42 21]"},
Expand Down
5 changes: 3 additions & 2 deletions http.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,9 @@ func GetMIME(extension string) string {
extWithDot = "." + extension
}

// Single map lookup with normalized key
if foundMime := mimeExtensions[extWithoutDot]; len(foundMime) > 0 {
// Single map lookup with normalized key. Extensions are matched
// case-insensitively; ToLower only allocates for upper-case input.
if foundMime := mimeExtensions[casestrings.ToLower(extWithoutDot)]; len(foundMime) > 0 {
return foundMime
}

Expand Down
7 changes: 7 additions & 0 deletions http_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,13 @@ func Test_GetMIME(t *testing.T) {
res = GetMIME("cbor")
require.Equal(t, "application/cbor", res)

// upper-case extensions match case-insensitively
res = GetMIME("PNG")
require.Equal(t, "image/png", res)

res = GetMIME(".JSON")
require.Equal(t, "application/json", res)

// empty case
res = GetMIME("")
require.Empty(t, res)
Expand Down
63 changes: 39 additions & 24 deletions parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import (
)

const (
maxFracDigits = 16
maxUint64Cutoff = math.MaxUint64 / 10
maxUint64Cutlim = math.MaxUint64 % 10
)
Expand Down Expand Up @@ -249,39 +248,46 @@ func parseFloat[S byteSeq](fn string, s S) (float64, error) {
return 0, &strconv.NumError{Func: fn, Num: string(s), Err: strconv.ErrSyntax}
}

var intPart uint64
// Collect integer and fractional digits into a single mantissa and track
// the decimal exponent. Digits beyond uint64 precision are dropped: integer
// digits shift the exponent up, fractional digits are simply ignored.
var mantissa uint64
var exp10 int
digits := 0
for i < len(s) {
c := s[i] - '0'
if c > 9 {
break
}
if intPart > maxUint64Cutoff || (intPart == maxUint64Cutoff && uint64(c) > maxUint64Cutlim) {
return 0, &strconv.NumError{Func: fn, Num: string(s), Err: strconv.ErrRange}
if mantissa > maxUint64Cutoff || (mantissa == maxUint64Cutoff && uint64(c) > maxUint64Cutlim) {
exp10++
} else {
mantissa = mantissa*10 + uint64(c)
}
intPart = intPart*10 + uint64(c)
digits++
i++
}

var fracPart uint64
var fracDiv uint64 = 1
var fracDigits int
if i < len(s) && s[i] == '.' {
i++
for i < len(s) {
c := s[i] - '0'
if c > 9 {
break
}
if fracDigits >= maxFracDigits {
return 0, &strconv.NumError{Func: fn, Num: string(s), Err: strconv.ErrRange}
if mantissa < maxUint64Cutoff || (mantissa == maxUint64Cutoff && uint64(c) <= maxUint64Cutlim) {
mantissa = mantissa*10 + uint64(c)
exp10--
}
fracPart = fracPart*10 + uint64(c)
fracDiv *= 10
fracDigits++
digits++
i++
}
}

if digits == 0 {
return 0, &strconv.NumError{Func: fn, Num: string(s), Err: strconv.ErrSyntax}
}

var expSign bool
var exp int64
if i < len(s) && (s[i] == 'e' || s[i] == 'E') {
Expand All @@ -299,17 +305,17 @@ func parseFloat[S byteSeq](fn string, s S) (float64, error) {
if i == len(s) {
return 0, &strconv.NumError{Func: fn, Num: string(s), Err: strconv.ErrSyntax}
}
// Saturate the parsed exponent far beyond anything exp10 (bounded by
// the input length) could pull back into range. Clamping to the final
// range must wait until both are combined below.
const maxParsedExp = int64(1) << 50
for i < len(s) {
c := s[i] - '0'
if c > 9 {
return 0, &strconv.NumError{Func: fn, Num: string(s), Err: strconv.ErrSyntax}
}
exp = exp*10 + int64(c)
if !expSign && exp > 308 {
exp = 309
}
if expSign && exp > 324 {
exp = 325
if exp < maxParsedExp {
exp = exp*10 + int64(c)
}
i++
}
Expand All @@ -321,12 +327,21 @@ func parseFloat[S byteSeq](fn string, s S) (float64, error) {
if expSign {
exp = -exp
}

f := float64(intPart)
if fracPart > 0 {
f += float64(fracPart) / float64(fracDiv)
// Clamp the combined exponent to math.Pow10's saturation range so the
// int conversion below cannot wrap on 32-bit platforms for extremely
// long digit strings; larger magnitudes overflow (Inf, caught below) or
// underflow (Pow10 returns 0) anyway.
exp += int64(exp10)
if exp > 309 {
exp = 309
} else if exp < -325 {
exp = -325
}
if exp != 0 {

f := float64(mantissa)
// Skip scaling for a zero mantissa: 0 * Pow10(309) would be 0 * Inf = NaN
// and turn inputs like "0e400" into a spurious range error.
if exp != 0 && f != 0 {
f *= math.Pow10(int(exp))
Comment thread
ReneWerner87 marked this conversation as resolved.
}
if neg {
Expand Down
24 changes: 19 additions & 5 deletions parse_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package utils
import (
"math"
"strconv"
"strings"
"testing"

"github.com/stretchr/testify/require"
Expand Down Expand Up @@ -675,10 +676,15 @@ func Test_ParseFloat64(t *testing.T) {
{strconv.FormatFloat(-math.MaxFloat64, 'g', -1, 64), -math.MaxFloat64, true},
{"5e-324", 0, true},
{"1e-400", 0, true},
{"25000000000000000000e-18", 0, false},
{"1234567891234567891234567", 0, false},
{"25000000000000000000e-18", 25, true},
{"1234567891234567891234567", 1.2345678912345679e24, true},
{"1.2345678912345678", 1.2345678912345678, true}, // large number
{"0.11111111111111119", 0, false},
{"0.11111111111111119", 0.11111111111111119, true},
{".", 0, false},
{"+.", 0, false},
{"-.", 0, false},
{"e5", 0, false},
{".e5", 0, false},
{"1.2.3", 0, false},
{"1e1.0", 0, false},
{"1e309", 0, false},
Expand All @@ -696,6 +702,12 @@ func Test_ParseFloat64(t *testing.T) {
{"123e1a", 0, false},
{"9999999999999999999", 1e19, true},
{"1.2.3", 0, false},
{"0e400", 0, true},
{"-0e400", 0, true},
{"1e400", 0, false},
{"0.1e1000", 0, false},
{strings.Repeat("9", 400), 0, false},
{strings.Repeat("9", 400) + "e-390", 1e10, true},
}
for _, tt := range tests {
v, err := ParseFloat64(tt.in)
Expand Down Expand Up @@ -774,9 +786,11 @@ func Test_ParseFloat32(t *testing.T) {
{"1e", 0, false, false},
{"1e+", 0, false, false},
{"1e-", 0, false, false},
{"1234567891234567891234567", 0, false, false},
{"1234567891234567891234567", 1.2345679e24, true, false},
{"1.2345678912345678", 1.2345679, true, false},
{"0.11111111111111119", 0, false, false},
{"0.11111111111111119", 0.11111112, true, false},
{".", 0, false, false},
{"e5", 0, false, false},
{"1.2.3", 0, false, false},
{"1e1.0", 0, false, false},
{"1e309", 0, false, false},
Expand Down
Loading
Loading