diff --git a/README.md b/README.md new file mode 100644 index 0000000..3e7da15 --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# Humansize +This library parses a human-readable format for writing data measurements in byte counts, or turns a byte count into a data measurement format string. +## Installation +``` +go get github.com/MyBlackJay/humansize +``` + +## Example +[Usage](https://go.dev/play/p/pux9fwKARrG) +```go +package main + +import ( + "fmt" + "github.com/MyBlackJay/humansize" +) + +func formatAndPrintKB() { + size := "100KB" + + if parsing, err := humansize.Compile(size); err == nil { + fmt.Println(parsing.GetInput(), parsing.GetMeasure(), parsing.GetCompiledUInt64()) + } +} + +func formatAndPrintMiB() { + size := "1MiB" + + if parsing, err := humansize.Compile(size); err == nil { + fmt.Println(parsing.GetInput(), parsing.GetMeasure(), parsing.GetCompiledUInt64()) + } +} + +func MustCompileMiWithError() { + defer func() { + if err := recover(); err != nil { + fmt.Println(err) + } + }() + + size := "1MR" + + parsing := humansize.MustCompile(size) + fmt.Println(parsing.GetInput(), parsing.GetMeasure(), parsing.GetCompiledUInt64()) +} + +func validateMeasureAndPrint() { + measure := "EiR" + fmt.Println(humansize.ValidateMeasure(measure)) +} + +func TurnBytesIntoToSizeAndPrint() { + size := 2.596 * float64(1<<60) + if res, err := humansize.BytesToSize(size, 10); err == nil { + fmt.Println(res) + } +} + +func main() { + formatAndPrintKB() // 100KB 1024 102400 + formatAndPrintMiB() // 1MiB 1048576 1048576 + MustCompileMiWithError() // unsupported data size format + validateMeasureAndPrint() // false + TurnBytesIntoToSizeAndPrint() // 2.5960000000EB +} +``` + diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..e176fb6 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/MyBlackJay/humansize + +go 1.21 diff --git a/parser.go b/parser.go new file mode 100644 index 0000000..b3ce1b9 --- /dev/null +++ b/parser.go @@ -0,0 +1,137 @@ +package humansize + +import ( + "errors" + "math" + "math/big" + "regexp" + "strconv" + "strings" +) + +const ( + measurePattern string = `^([bB]|[bB]ytes|[kmgtpeKMGTPE]|[kmgtpeKMGTPE]?[iI]|[kmgtpeKMGTPE][iI]?[bB])?$` + sizePattern string = `^([0-9]+|[0-9]*\.[0-9]+)([bB]|[bB]ytes|[kmgtpeKMGTPE]|[kmgtpeKMGTPE]?[iI]|[kmgtpeKMGTPE][iI]?[bB])?$` +) + +var defaultMeasure = []string{"B", "KB", "MB", "GB", "TB", "PB", "EB"} + +// ReadableSize is the representation of a compiled data size expression. +type ReadableSize struct { + input string + measure int64 + compiled *big.Int +} + +// GetInput returns original data size expression. +func (rs *ReadableSize) GetInput() string { + return rs.input +} + +// GetMeasure returns the compiled data units in uint64. +func (rs *ReadableSize) GetMeasure() int64 { + return rs.measure +} + +// Get returns the compiled data size in big.Int. +func (rs *ReadableSize) Get() big.Int { + return *rs.compiled +} + +// GetCompiledUInt64 returns the compiled data size in uint64. +// Warning: Possible rounding overflow, use with relatively small numbers. +func (rs *ReadableSize) GetCompiledUInt64() uint64 { + return rs.compiled.Uint64() +} + +// compileMeasuring returns a numeric representation of a data unit in int64. +// See the constant for the allowed options. +func compileMeasuring(measure string) int64 { + multiplier := int64(1) + + if measure == "" { + return multiplier + } + + switch strings.ToLower(string(measure[0])) { + case "k": + multiplier = 1 << 10 + case "m": + multiplier = 1 << 20 + case "g": + multiplier = 1 << 30 + case "t": + multiplier = 1 << 40 + case "p": + multiplier = 1 << 50 + case "e": + multiplier = 1 << 60 + } + + return multiplier +} + +// Compile parses a data size expression and returns, if successful, a ReadableSize object. +// For example: 100MB. +func Compile(input string) (*ReadableSize, error) { + parser := regexp.MustCompile(sizePattern) + + if matches := parser.FindStringSubmatch(input); len(matches) == 3 { + if sz, err := strconv.ParseFloat(matches[1], 64); err == nil { + measure := compileMeasuring(matches[2]) + result, _ := big.NewFloat(sz).Mul(big.NewFloat(sz), big.NewFloat(float64(measure))).Int(new(big.Int)) + + return &ReadableSize{ + input: input, + measure: measure, + compiled: result, + }, nil + } + } + + return nil, errors.New("unsupported data size format") +} + +// MustCompile parses a data size expression and returns, if successful, +// a ReadableSize object or returns panic, if an error is found. +// For example: 100MB. +func MustCompile(input string) *ReadableSize { + res, err := Compile(input) + + if err != nil { + panic(err) + } + + return res +} + +// ValidateMeasure parses a data size measure and returns true or false. +func ValidateMeasure(format string) bool { + if format == "" || !regexp.MustCompile(measurePattern).MatchString(format) { + return false + } + + return true +} + +// BytesToSize parses a number and returns a string of data size format. +// For example: 100MB. +func BytesToSize(size float64, precision uint) (string, error) { + rounder := func() float64 { + ratio := math.Pow(10, float64(precision)) + return math.Round(size*ratio) / ratio + } + + if size == 0 { + return "0B", nil + } + + for i, v := range defaultMeasure { + if size < 1024 || i == len(defaultMeasure)-1 { + return strconv.FormatFloat(rounder(), 'f', int(precision), 64) + v, nil + } + size /= 1 << 10 + } + + return "", errors.New("unable convert") +} diff --git a/parser_test.go b/parser_test.go new file mode 100644 index 0000000..9e0c8c1 --- /dev/null +++ b/parser_test.go @@ -0,0 +1,199 @@ +package humansize + +import ( + "math/big" + "testing" +) + +func TestCompileMeasure(t *testing.T) { + defer func() { + if err := recover(); err != nil { + t.Logf("Test completed successfully") + } else { + t.Errorf("Test failed. Expected panic") + } + }() + var tests = map[string]struct { + text []string + input string + measure int64 + compiled uint64 + isError bool + }{ + "not_valid_m": { + text: []string{"MMB"}, + input: "10", + measure: 0, + compiled: 0, + isError: true, + }, + } + + for _, v := range tests { + for _, mes := range v.text { + input := v.input + mes + MustCompile(input) + } + + } +} + +func TestCompile(t *testing.T) { + var tests = map[string]struct { + text []string + input string + measure int64 + compiled uint64 + isError bool + }{ + "without_measure": { + text: []string{""}, + input: "1024", + measure: 1, + compiled: 1024, + isError: false, + }, + "empty": { + text: []string{"b", "B", "bytes", "Bytes"}, + input: "", + measure: 0, + compiled: 0, + isError: true, + }, + "b": { + text: []string{"b", "B", "bytes", "Bytes"}, + input: "10", + measure: 1, + compiled: 10, + isError: false, + }, + "k": { + text: []string{"k", "kb", "kib", "K", "KB", "KiB", "KIB"}, + input: "10", + measure: 1 << 10, + compiled: 10 * (1 << 10), + isError: false, + }, + "m": { + text: []string{"m", "mb", "mib", "M", "MB", "MiB", "MIB"}, + input: "10", + measure: 1 << 20, + compiled: 10 * (1 << 20), + isError: false, + }, + "g": { + text: []string{"g", "gb", "gib", "G", "GB", "GiB", "GIB"}, + input: "10", + measure: 1 << 30, + compiled: 10 * (1 << 30), + isError: false, + }, + "t": { + text: []string{"t", "tb", "tib", "T", "TB", "TiB", "TIB"}, + input: "10", + measure: 1 << 40, + compiled: 10 * (1 << 40), + isError: false, + }, + "p": { + text: []string{"p", "pb", "pib", "P", "PB", "PiB", "PIB"}, + input: "10", + measure: 1 << 50, + compiled: 10 * (1 << 50), + isError: false, + }, + "e": { + text: []string{"e", "eb", "eib", "E", "EB", "EiB", "EIB"}, + input: "10", + measure: 1 << 60, + compiled: big.NewInt(10).Mul(big.NewInt(10), big.NewInt(1<<60)).Uint64(), + isError: false, + }, + "float": { + text: []string{"MB"}, + input: "1000.500", + measure: 1 << 20, + compiled: uint64(1000.5 * float64(1<<20)), + isError: false, + }, + } + + for k, v := range tests { + i := 0 + for _, mes := range v.text { + input := v.input + mes + + res, err := Compile(input) + + switch { + case err != nil && !v.isError: + t.Errorf("Test %s-%d (input: %s) failed. Error was not expected.", k, i, input) + case v.isError && err == nil: + t.Errorf("Test %s-%d (input: %s) failed. The error was expected", k, i, input) + case (err != nil && v.isError) || (err == nil && res.GetCompiledUInt64() == v.compiled && res.measure == v.measure && res.input == input): + t.Logf("Test %s-%d (input: %s) completed successfully", k, i, input) + case err == nil && (res.GetCompiledUInt64() != v.compiled || res.measure != v.measure || res.input != input): + t.Errorf( + "Test %s-%d (input: %s) failed. Expected: {input: %s, measure: %d, compiled: %d}. Result: {input: %s, measure: %d, compiled: %d}", + k, i, input, input, v.measure, v.compiled, res.input, res.measure, res.compiled, + ) + } + i++ + } + + } +} + +func TestValidateMeasure(t *testing.T) { + tests := []struct { + input string + result bool + }{ + {"MB", true}, + {"MBN", false}, + {"mb", true}, + {"GiB", true}, + {"pib", true}, + {"b", true}, + {"Bites", false}, + } + + for i, v := range tests { + if res := ValidateMeasure(v.input); res == v.result { + t.Logf("Test %d (input: %s) completed successfully", i, v.input) + } else { + t.Errorf( + "Test %d (input: %s) failed. Expected: %t. Result: %t", + i, v.input, v.result, res, + ) + } + + } + +} + +func TestBytesToSize(t *testing.T) { + tests := []struct { + input float64 + precision uint + result string + }{ + {0, 0, "0B"}, + {512, 0, "512B"}, + {2 * 1 << 20, 0, "2MB"}, + {1.5 * float64(1<<30), 1, "1.5GB"}, + {1 << 40, 1, "1.0TB"}, + {0.5 * float64(1<<50), 0, "512TB"}, + {2.596 * float64(1<<60), 2, "2.60EB"}, + } + for i, v := range tests { + if res, _ := BytesToSize(v.input, v.precision); res == v.result { + t.Logf("Test %d (input: %.2f) completed successfully", i, v.input) + } else { + t.Errorf( + "Test %d (input: %.2f) failed. Expected: %s. Result: %s", + i, v.input, v.result, res, + ) + } + } +}