From 8d5acffdab6f44a7686e01cbcb6413406dbf6942 Mon Sep 17 00:00:00 2001 From: andrey1s Date: Tue, 11 Oct 2022 22:17:22 +0300 Subject: [PATCH] add duration --- .drone.yml | 24 +++ .golangci.yml | 51 ++++++ LICENSE | 2 +- README.md | 5 + duration.go | 382 +++++++++++++++++++++++++++++++++++++++ duration_example_test.go | 43 +++++ duration_test.go | 151 ++++++++++++++++ go.mod | 3 + 8 files changed, 660 insertions(+), 1 deletion(-) create mode 100644 .drone.yml create mode 100644 .golangci.yml create mode 100644 duration.go create mode 100644 duration_example_test.go create mode 100644 duration_test.go create mode 100644 go.mod diff --git a/.drone.yml b/.drone.yml new file mode 100644 index 0000000..97b9960 --- /dev/null +++ b/.drone.yml @@ -0,0 +1,24 @@ +kind: pipeline +name: default + +steps: +- name: golangci-lint + image: golangci/golangci-lint:v1.49 + volumes: + - name: deps + path: /go/src/mod + commands: + - golangci-lint run --timeout 5m + +- name: test + image: golang + volumes: + - name: deps + path: /go/src/mod + commands: + - go test ./... + +volumes: +- name: deps + temp: {} + diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..7e84cb7 --- /dev/null +++ b/.golangci.yml @@ -0,0 +1,51 @@ +linters-settings: + dupl: + threshold: 100 + funlen: + lines: 100 + statements: 50 + goconst: + min-len: 2 + min-occurrences: 2 + gocyclo: + min-complexity: 15 + golint: + min-confidence: 0 + govet: + check-shadowing: true + lll: + line-length: 140 + maligned: + suggest-new: true + misspell: + locale: US + varnamelen: + min-name-length: 2 + +linters: + enable-all: true + disable: + - maligned + - exhaustivestruct + - golint + - structcheck + - varcheck + - interfacer + - deadcode + - nosnakecase + - scopelint + - ifshort + - rowserrcheck + - sqlclosecheck + - wastedassign + + - varnamelen + - wsl + - nonamedreturns + - nlreturn + - gomnd + - gochecknoglobals + - funlen + - gocognit + - cyclop + - gocyclo \ No newline at end of file diff --git a/LICENSE b/LICENSE index 2071b23..70b854b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ MIT License -Copyright (c) +Copyright (c) 2022 4devs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: diff --git a/README.md b/README.md index 4582f6e..8e510cb 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,7 @@ # iso8601 +[![Build Status](https://drone.gitoa.ru/api/badges/go-4devs/iso8601/status.svg)](https://drone.gitoa.ru/go-4devs/iso8601) +[![Go Report Card](https://goreportcard.com/badge/gitoa.ru/go-4devs/iso8601)](https://goreportcard.com/report/gitoa.ru/go-4devs/iso8601) +[![GoDoc](https://godoc.org/gitoa.ru/go-4devs/iso8601?status.svg)](http://godoc.org/gitoa.ru/go-4devs/iso8601) + +A fast ISO8601 duration parse and format for Go. diff --git a/duration.go b/duration.go new file mode 100644 index 0000000..3a28619 --- /dev/null +++ b/duration.go @@ -0,0 +1,382 @@ +package iso8601 + +import ( + "errors" + "fmt" + "log" + "time" +) + +var ( + ErrInvalidDuration = errors.New("invalid duration") + ErrMissingUnit = errors.New("missing unit") + ErrUnknownUnit = errors.New("unknown unit") + ErrOverflow = errors.New("overflow") + ErrLeadingInt = errors.New("leading int") +) + +// P(n)Y(n)M(n)DT(n)H(n)M(n)S. +var ( + defaultOption = duration{ + from: time.Now, + } + units = map[string]func(from time.Time, v uint64, scale float64) uint64{ + "M": month, + "Y": year, + } + dateUnit = sampleUnits{ + "D": uint64(time.Hour * 24), + } + timeUnits = sampleUnits{ + "S": uint64(time.Second), + "M": uint64(time.Minute), + "H": uint64(time.Hour), + } +) + +func From(from func() time.Time) Option { + return func(d *duration) { + d.from = from + } +} + +type Option func(*duration) + +// ParseDuration parses a duration string format P(n)Y(n)M(n)DT(n)H(n)M(n)S. +// use iso8601.From(time) when using the month and year, by default time.Now(). +func ParseDuration(s string, opts ...Option) (time.Duration, error) { + option := defaultOption + for _, opt := range opts { + opt(&option) + } + + orig := s + var d uint64 + neg := false + + // Consume [-+]? + if s != "" { + c := s[0] + if c == '-' || c == '+' { + neg = c == '-' + s = s[1:] + } + } + + if s == "" { + return 0, fmt.Errorf("iso8601: empty %w %q", ErrInvalidDuration, orig) + } + + if s[0] != 'P' { + return 0, fmt.Errorf("iso8601: format %w %q", ErrInvalidDuration, orig) + } + + s = s[1:] + unit := option.unit + + for s != "" { + var ( + v, f uint64 // integers before, after decimal point + scale float64 = 1 // value = v + f/scale + ) + + var err error + + if s != "" && s[0] == 'T' { + s = s[1:] + unit = timeUnits.unit + } + + // The next character must be [0-9.] + if !(s[0] == '.' || '0' <= s[0] && s[0] <= '9') { + return 0, fmt.Errorf("iso8601: next character %w %q", ErrInvalidDuration, orig) + } + + // Consume [0-9]* + pl := len(s) + v, s, err = leadingInt(s) + if err != nil { + return 0, fmt.Errorf("iso8601: leadingInt %w %q", ErrInvalidDuration, orig) + } + pre := pl != len(s) // whether we consumed anything before a period + + // Consume (\.[0-9]*)? + post := false + if s != "" && s[0] == '.' { + s = s[1:] + pl := len(s) + f, scale, s = leadingFraction(s) + post = pl != len(s) + } + + if !pre && !post { + // no digits (e.g. ".s" or "-.s") + return 0, fmt.Errorf("iso8601: leadingFraction %w %q", ErrInvalidDuration, orig) + } + + // Consume unit. + i := 0 + for ; i < len(s); i++ { + c := s[i] + if c == '.' || '0' <= c && c <= '9' || c == 'T' { + break + } + } + if i == 0 { + return 0, fmt.Errorf("iso8601: %w %q", ErrMissingUnit, orig) + } + u := s[:i] + s = s[i:] + + v, err = unit(u, v, 0) + if err != nil { + return 0, fmt.Errorf("iso8601: %w unit %q", err, orig) + } + + if f > 0 { + r, err := unit(u, f, scale) + if err != nil { + return 0, fmt.Errorf("iso8601: %w fraction %q", err, orig) + } + log.Println(u, f, scale, r) + + v += r + } + + if d > 1<<63-v { + return 0, fmt.Errorf("iso8601: 1<<63 %w %q", ErrOverflow, orig) + } + d += v + } + + if neg { + return -time.Duration(d), nil + } + + if d > 1<<63-1 { + return 0, fmt.Errorf("iso8601: %w %q", ErrOverflow, orig) + } + + return time.Duration(d), nil +} + +// leadingInt consumes the leading [0-9]* from s. +func leadingInt(s string) (x uint64, rem string, err error) { + i := 0 + for ; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + break + } + if x > 1<<63/10 { + // overflow + return 0, "", ErrLeadingInt + } + x = x*10 + uint64(c) - '0' + if x > 1<<63 { + // overflow + return 0, "", ErrLeadingInt + } + } + return x, s[i:], nil +} + +// leadingFraction consumes the leading [0-9]* from s. +// It is used only for fractions, so does not return an error on overflow, +// it just stops accumulating precision. +func leadingFraction(s string) (x uint64, scale float64, rem string) { + i := 0 + scale = 1 + overflow := false + for ; i < len(s); i++ { + c := s[i] + if c < '0' || c > '9' { + break + } + if overflow { + continue + } + if x > (1<<63-1)/10 { + // It's possible for overflow to give a positive number, so take care. + overflow = true + continue + } + y := x*10 + uint64(c) - '0' + if y > 1<<63 { + overflow = true + continue + } + x = y + scale *= 10 + } + return x, scale, s[i:] +} + +func month(from time.Time, v uint64, scale float64) uint64 { + if scale == 0 { + return uint64(from.AddDate(0, int(v), 0).Sub(from)) + } + + return uint64(float64(v) * (float64(from.AddDate(0, 1, 0).Sub(from)) / scale)) +} + +func year(from time.Time, v uint64, scale float64) uint64 { + if scale == 0 { + return uint64(from.AddDate(int(v), 0, 0).Sub(from)) + } + + return uint64(float64(v) * (float64(from.AddDate(1, 0, 0).Sub(from)) / scale)) +} + +type sampleUnits map[string]uint64 + +func (s sampleUnits) unit(name string, v uint64, scale float64) (uint64, error) { + if unit, ok := s[name]; ok { + if scale != 0 { + v = uint64(float64(v) * (float64(unit) / scale)) + if v > 1<<63 { + // overflow + return 0, fmt.Errorf("iso8601:%w", ErrOverflow) + } + + return v, nil + } + + if v > 1<<63/unit { + // overflow + return 0, fmt.Errorf("iso8601:%w", ErrOverflow) + } + + return v * unit, nil + } + + return 0, fmt.Errorf("iso8601:%w", ErrMissingUnit) +} + +type duration struct { + from func() time.Time +} + +func (d *duration) unit(name string, v uint64, scale float64) (uint64, error) { + if _, ok := dateUnit[name]; ok { + return dateUnit.unit(name, v, scale) + } + + if unit, ok := units[name]; ok { + from := d.from() + out := unit(from, v, scale) + if out > 1<<63 { + // overflow + return 0, ErrOverflow + } + + d.from = func() time.Time { + return from.Add(time.Duration(out)) + } + + return out, nil + } + + return 0, fmt.Errorf("%w %q", ErrMissingUnit, name) +} + +// FormatDuration returns a string representing the duration in the form "P1Y2M3DT4H5M6S". +// Leading zero units are omitted. The zero duration formats as PT0S. +func FormatDuration(duration time.Duration) string { + if duration == 0 { + return "PT0S" + } + + var buf [32]byte + w := len(buf) + u := uint64(duration) + neg := duration < 0 + if neg { + u = -u + } + + w-- + buf[w] = 'S' + w, u = fmtFrac(buf[:w], u, 9) + + // u is now integer seconds + w = fmtInt(buf[:w], u%60) + + if u%60 == 0 && w+2 == len(buf) { + w += 2 + } + u /= 60 + + // u is now integer minutes + if u > 0 { + if u%60 > 0 { + w-- + buf[w] = 'M' + w = fmtInt(buf[:w], u%60) + } + u /= 60 + + if u > 0 && u%24 > 0 { + w-- + buf[w] = 'H' + w = fmtInt(buf[:w], u%24) + } + u /= 24 + } + + if w != len(buf) { + w-- + buf[w] = 'T' + } + + if u > 0 { + w-- + buf[w] = 'D' + w = fmtInt(buf[:w], u) + } + + w-- + buf[w] = 'P' + + if neg { + w-- + buf[w] = '-' + } + + return string(buf[w:]) +} + +func fmtInt(buf []byte, v uint64) int { + w := len(buf) + if v == 0 { + w-- + buf[w] = '0' + } else { + for v > 0 { + w-- + buf[w] = byte(v%10) + '0' + v /= 10 + } + } + return w +} + +func fmtFrac(buf []byte, v uint64, prec int) (nw int, nv uint64) { + // Omit trailing zeros up to and including decimal point. + w := len(buf) + isPrint := false + for i := 0; i < prec; i++ { + digit := v % 10 + isPrint = isPrint || digit != 0 + if isPrint { + w-- + buf[w] = byte(digit) + '0' + } + v /= 10 + } + if isPrint { + w-- + buf[w] = '.' + } + return w, v +} diff --git a/duration_example_test.go b/duration_example_test.go new file mode 100644 index 0000000..19fbcf2 --- /dev/null +++ b/duration_example_test.go @@ -0,0 +1,43 @@ +package iso8601_test + +import ( + "fmt" + "time" + + "gitoa.ru/go-4devs/iso8601" +) + +func ExampleFormatDuration() { + second := iso8601.FormatDuration(time.Second) + hours := iso8601.FormatDuration(time.Hour * 25) + partOfSecond := iso8601.FormatDuration(time.Second / 5) + + fmt.Printf("%s = %s\n", time.Second, second) + fmt.Printf("%s = %s\n", time.Hour*25, hours) + fmt.Printf("%s = %s\n", time.Second/5, partOfSecond) + // Output: + // 1s = PT1S + // 25h0m0s = P1DT1H + // 200ms = PT0.2S +} + +func ExampleParseDuration() { + year2020 := time.Date(2020, 1, 1, 1, 1, 1, 1, time.UTC) + second, errSecond := iso8601.ParseDuration("PT1S") + hours, errHours := iso8601.ParseDuration("P1DT1H") + partOfSecond, errPartOfSecond := iso8601.ParseDuration("PT0.2S") + yearFromZeroTime, errYearFromZeroTime := iso8601.ParseDuration("P1Y1M1D", iso8601.From(func() time.Time { return time.Time{} })) + yearFrom2020, errYearFrom2020 := iso8601.ParseDuration("P1Y1M1D", iso8601.From(func() time.Time { return year2020 })) + + fmt.Printf("PT1S = %s(%v)\n", second, errSecond) + fmt.Printf("P1DT1H = %s(%v)\n", hours, errHours) + fmt.Printf("PT0.2S = %s(%v)\n", partOfSecond, errPartOfSecond) + fmt.Printf("P1Y1M1D(from %v) = %s(%v)\n", time.Time{}, yearFromZeroTime, errYearFromZeroTime) + fmt.Printf("P1Y1M1D(form %v) = %s(%v)\n", year2020, yearFrom2020, errYearFrom2020) + // Output: + // PT1S = 1s() + // P1DT1H = 25h0m0s() + // PT0.2S = 200ms() + // P1Y1M1D(from 0001-01-01 00:00:00 +0000 UTC) = 9528h0m0s() + // P1Y1M1D(form 2020-01-01 01:01:01.000000001 +0000 UTC) = 9552h0m0s() +} diff --git a/duration_test.go b/duration_test.go new file mode 100644 index 0000000..cb4020a --- /dev/null +++ b/duration_test.go @@ -0,0 +1,151 @@ +package iso8601_test + +import ( + "testing" + "time" + + "gitoa.ru/go-4devs/iso8601" +) + +func TestFormatDuration(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + val time.Duration + expect string + }{ + "1 day": { + val: time.Hour * 24, + expect: "P1D", + }, + "1 hour": { + val: time.Hour, + expect: "PT1H", + }, + "1 second": { + val: time.Second, + expect: "PT1S", + }, + "1 nanosecond": { + val: time.Nanosecond, + expect: "PT0.000000001S", + }, + "negative": { + val: -time.Hour * 24, + expect: "-P1D", + }, + "zero": { + val: time.Duration(0), + expect: "PT0S", + }, + } + + for name, test := range cases { + result := iso8601.FormatDuration(test.val) + if result != test.expect { + t.Errorf("test:%v got:%v, expect:%v", name, result, test.expect) + } + } +} + +func TestParseDuration(t *testing.T) { + t.Parallel() + + cases := map[string]struct { + opts []iso8601.Option + parse string + expect time.Duration + }{ + "base": { + parse: "P3Y6M4DT12H30M17S", + expect: parseDuration(t, "30780h30m17s"), + }, + "base ofer time": { + parse: "P3Y6M4DT12H30M17S", + expect: parseDuration(t, "30756h30m17s"), + opts: []iso8601.Option{ + iso8601.From(func() time.Time { + return parseTime(t, "2006-01-02T15:04:05Z") + }), + }, + }, + "base ofer time with delimiter": { + parse: "PT12H30.5M", + expect: parseDuration(t, "12h30m30s"), + opts: []iso8601.Option{ + iso8601.From(func() time.Time { + return parseTime(t, "2006-01-02T15:04:05Z") + }), + }, + }, + "zero time": { + parse: "P3Y6M4DT12H30M17S", + expect: parseDuration(t, "30732h30m17s"), + opts: []iso8601.Option{ + iso8601.From(func() time.Time { + return time.Time{} + }), + }, + }, + "only time": { + parse: "PT12H30M17S", + expect: parseDuration(t, "12h30m17s"), + }, + "time with days": { + parse: "P10DT12H30M17S", + expect: parseDuration(t, "252h30m17s"), + }, + "time with days with options": { + parse: "P10DT12H30M17S", + expect: parseDuration(t, "252h30m17s"), + opts: []iso8601.Option{ + iso8601.From(func() time.Time { + return time.Time{} + }), + }, + }, + "one day": { + parse: "P1D", + expect: time.Hour * 24, + }, + "1 nanosecond": { + parse: "PT0.000000001S", + expect: time.Nanosecond, + }, + } + + for name, test := range cases { + dur, err := iso8601.ParseDuration(test.parse, test.opts...) + if err != nil { + t.Errorf("%s: %v", name, err) + } + + if dur != test.expect { + t.Errorf("test: %v expect:%v given:%v", name, test.expect, dur) + } + } +} + +func parseDuration(t *testing.T, in string) time.Duration { + t.Helper() + + duration, err := time.ParseDuration(in) + if err != nil { + t.Error(err) + t.FailNow() + } + + return duration +} + +func parseTime(t *testing.T, in string) time.Time { + t.Helper() + + duration, err := time.Parse(time.RFC3339, in) + if err != nil { + t.Error(err) + t.FailNow() + } + + return duration +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..78a0671 --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module gitoa.ru/go-4devs/iso8601 + +go 1.19