8 changed files with 660 additions and 1 deletions
@ -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: {} |
|||
|
@ -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 |
@ -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. |
|||
|
@ -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 |
|||
} |
@ -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(<nil>)
|
|||
// P1DT1H = 25h0m0s(<nil>)
|
|||
// PT0.2S = 200ms(<nil>)
|
|||
// P1Y1M1D(from 0001-01-01 00:00:00 +0000 UTC) = 9528h0m0s(<nil>)
|
|||
// P1Y1M1D(form 2020-01-01 01:01:01.000000001 +0000 UTC) = 9552h0m0s(<nil>)
|
|||
} |
@ -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 |
|||
} |
@ -0,0 +1,3 @@ |
|||
module gitoa.ru/go-4devs/iso8601 |
|||
|
|||
go 1.19 |
Loading…
Reference in new issue