Browse Source

add duration

master v0.0.1
andrey1s 2 years ago
parent
commit
8d5acffdab
  1. 24
      .drone.yml
  2. 51
      .golangci.yml
  3. 2
      LICENSE
  4. 5
      README.md
  5. 382
      duration.go
  6. 43
      duration_example_test.go
  7. 151
      duration_test.go
  8. 3
      go.mod

24
.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: {}

51
.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

2
LICENSE

@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) <year> <copyright holders> 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: 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:

5
README.md

@ -1,2 +1,7 @@
# iso8601 # 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.

382
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
}

43
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(<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>)
}

151
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
}

3
go.mod

@ -0,0 +1,3 @@
module gitoa.ru/go-4devs/iso8601
go 1.19
Loading…
Cancel
Save