diff --git a/.github/workflows/lint.yaml b/.github/workflows/lint.yaml new file mode 100644 index 0000000..1a998e5 --- /dev/null +++ b/.github/workflows/lint.yaml @@ -0,0 +1,29 @@ +name: golangci-lint +on: + push: + tags: + - v* + branches: + - master + - main + pull_request: +jobs: + golangci: + name: lint + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Cache-Go + uses: actions/cache@v1 + with: + path: | + ~/go/pkg/mod # Module download cache + ~/.cache/go-build # Build cache (Linux) + ~/Library/Caches/go-build # Build cache (Mac) + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + - name: golangci-lint + uses: golangci/golangci-lint-action@v3 + with: + version: latest diff --git a/.github/workflows/releaser.yml b/.github/workflows/releaser.yml new file mode 100644 index 0000000..cc06e22 --- /dev/null +++ b/.github/workflows/releaser.yml @@ -0,0 +1,39 @@ +name: releaser + +on: + push: + tags: + - 'v*.*.*' + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + - name: Set up Go + uses: actions/setup-go@v2 + with: + go-version: 1.19 + - name: Cache-Go + uses: actions/cache@v1 + with: + path: | + ~/go/pkg/mod # Module download cache + ~/.cache/go-build # Build cache (Linux) + ~/Library/Caches/go-build # Build cache (Mac) + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + - name: Test + run: go test ./... + - name: Run GoReleaser + uses: goreleaser/goreleaser-action@v2 + with: + distribution: goreleaser + version: latest + args: release --rm-dist + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml new file mode 100644 index 0000000..a882731 --- /dev/null +++ b/.github/workflows/test.yaml @@ -0,0 +1,28 @@ +on: [push, pull_request] +name: Test +jobs: + test: + strategy: + matrix: + go-version: [1.19.x] + os: [ubuntu-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Install Go + uses: actions/setup-go@v2 + with: + go-version: ${{ matrix.go-version }} + - name: Checkout code + uses: actions/checkout@v2 + - name: Cache-Go + uses: actions/cache@v1 + with: + path: | + ~/go/pkg/mod # Module download cache + ~/.cache/go-build # Build cache (Linux) + ~/Library/Caches/go-build # Build cache (Mac) + key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + restore-keys: | + ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }} + - name: Test + run: go test ./... diff --git a/.goreleaser.yml b/.goreleaser.yml new file mode 100644 index 0000000..dab3f5c --- /dev/null +++ b/.goreleaser.yml @@ -0,0 +1,38 @@ +project_name: dateparse +builds: + - + id: "dateparse" + binary: "dateparse" + dir: dateparse + - + id: "example" + binary: "example" + dir: example +archives: + - + format_overrides: + - goos: windows + format: zip +checksum: + name_template: 'checksums.txt' +snapshot: + name_template: "{{ .Tag }}-next" +changelog: + sort: asc + filters: + exclude: + - '^docs:' + - '^test:' +nfpms: + - + vendor: dateparse + homepage: https://github.com/araddon/dateparse + maintainer: n/a + description: NA + formats: + - apk + - deb + - rpm + release: 1 + section: default + priority: extra diff --git a/bench_test.go b/bench_test.go index 0c6739a..4322b86 100644 --- a/bench_test.go +++ b/bench_test.go @@ -7,7 +7,6 @@ import ( ) /* - go test -bench Parse BenchmarkShotgunParse 50000 37588 ns/op 13258 B/op 167 allocs/op @@ -21,14 +20,13 @@ BenchmarkParseAny-4 200000 8627 ns/op 144 B/op 3 allo BenchmarkShotgunParse-8 50000 33940 ns/op 13136 B/op 169 allocs/op BenchmarkParseAny-8 200000 10146 ns/op 912 B/op 29 allocs/op BenchmarkParseDateString-8 10000 123077 ns/op 208 B/op 13 allocs/op - */ func BenchmarkShotgunParse(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { for _, dateStr := range testDates { // This is the non dateparse traditional approach - parseShotgunStyle(dateStr) + _, _ = parseShotgunStyle(dateStr) } } } @@ -37,7 +35,7 @@ func BenchmarkParseAny(b *testing.B) { b.ReportAllocs() for i := 0; i < b.N; i++ { for _, dateStr := range testDates { - ParseAny(dateStr) + _, _ = ParseAny(dateStr) } } } diff --git a/go.mod b/go.mod index 071cd5e..baa649f 100644 --- a/go.mod +++ b/go.mod @@ -1,9 +1,16 @@ module github.com/araddon/dateparse -go 1.12 +go 1.19 require ( - github.com/mattn/go-runewidth v0.0.10 // indirect github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4 github.com/stretchr/testify v1.7.0 ) + +require ( + github.com/davecgh/go-spew v1.1.0 // indirect + github.com/mattn/go-runewidth v0.0.10 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect + github.com/rivo/uniseg v0.1.0 // indirect + gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c // indirect +) diff --git a/go.sum b/go.sum index 40bf744..370a49d 100644 --- a/go.sum +++ b/go.sum @@ -8,7 +8,6 @@ github.com/rivo/uniseg v0.1.0 h1:+2KBaVoUmb9XzDsrx/Ct0W/EYOSFf/nWTauy++DprtY= github.com/rivo/uniseg v0.1.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4 h1:8qmTC5ByIXO3GP/IzBkxcZ/99VITvnIETDhdFz/om7A= github.com/scylladb/termtables v0.0.0-20191203121021-c4c0b6d42ff4/go.mod h1:C1a7PQSMz9NShzorzCiG2fk9+xuCgLkPeCvMHYR2OWg= -github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= diff --git a/parseany.go b/parseany.go index b9668b2..b427d68 100644 --- a/parseany.go +++ b/parseany.go @@ -133,11 +133,12 @@ const ( var ( // ErrAmbiguousMMDD for date formats such as 04/02/2014 the mm/dd vs dd/mm are // ambiguous, so it is an error for strict parse rules. - ErrAmbiguousMMDD = fmt.Errorf("This date has ambiguous mm/dd vs dd/mm type format") + ErrAmbiguousMMDD = fmt.Errorf("this date has ambiguous mm/dd vs dd/mm type format") + ErrCouldntFindFormat = fmt.Errorf("could not find format for") ) func unknownErr(datestr string) error { - return fmt.Errorf("Could not find format for %q", datestr) + return fmt.Errorf("%w %q", ErrCouldntFindFormat, datestr) } // ParseAny parse an unknown date format, detect the layout. @@ -170,15 +171,14 @@ func ParseIn(datestr string, loc *time.Location, opts ...ParserOption) (time.Tim // Set Location to time.Local. Same as ParseIn Location but lazily uses // the global time.Local variable for Location argument. // -// denverLoc, _ := time.LoadLocation("America/Denver") -// time.Local = denverLoc +// denverLoc, _ := time.LoadLocation("America/Denver") +// time.Local = denverLoc // -// t, err := dateparse.ParseLocal("3/1/2014") +// t, err := dateparse.ParseLocal("3/1/2014") // // Equivalent to: // -// t, err := dateparse.ParseIn("3/1/2014", denverLoc) -// +// t, err := dateparse.ParseIn("3/1/2014", denverLoc) func ParseLocal(datestr string, opts ...ParserOption) (time.Time, error) { p, err := parseTime(datestr, time.Local, opts...) if err != nil { @@ -204,9 +204,8 @@ func MustParse(datestr string, opts ...ParserOption) time.Time { // ParseFormat parse's an unknown date-time string and returns a layout // string that can parse this (and exact same format) other date-time strings. // -// layout, err := dateparse.ParseFormat("2013-02-01 00:00:00") -// // layout = "2006-01-02 15:04:05" -// +// layout, err := dateparse.ParseFormat("2013-02-01 00:00:00") +// // layout = "2006-01-02 15:04:05" func ParseFormat(datestr string, opts ...ParserOption) (string, error) { p, err := parseTime(datestr, nil, opts...) if err != nil { @@ -234,7 +233,10 @@ func ParseStrict(datestr string, opts ...ParserOption) (time.Time, error) { func parseTime(datestr string, loc *time.Location, opts ...ParserOption) (p *parser, err error) { - p = newParser(datestr, loc, opts...) + p, err = newParser(datestr, loc, opts...) + if err != nil { + return + } if p.retryAmbiguousDateWithSwap { // month out of range signifies that a day/month swap is the correct solution to an ambiguous date // this is because it means that a day is being interpreted as a month and overflowing the valid value for that @@ -250,7 +252,7 @@ func parseTime(datestr string, loc *time.Location, opts ...ParserOption) (p *par // turn off the retry to avoid endless recursion retryAmbiguousDateWithSwap := RetryAmbiguousDateWithSwap(false) modifiedOpts := append(opts, preferMonthFirst, retryAmbiguousDateWithSwap) - p, err = parseTime(datestr, time.Local, modifiedOpts...) + p, _ = parseTime(datestr, time.Local, modifiedOpts...) } } @@ -268,7 +270,7 @@ iterRunes: //r := rune(datestr[i]) r, bytesConsumed := utf8.DecodeRuneInString(datestr[i:]) if bytesConsumed > 1 { - i += (bytesConsumed - 1) + i += bytesConsumed - 1 } // gou.Debugf("i=%d r=%s state=%d %s", i, string(r), p.stateDate, datestr) @@ -467,8 +469,6 @@ iterRunes: switch r { case ':': p.set(p.offseti, "-07:00") - // case ' ': - // return nil, unknownErr(datestr) } case dateYearDashAlphaDash: @@ -538,7 +538,9 @@ iterRunes: // I honestly don't know if this format ever shows up as yyyy/ switch r { - case ' ', ':': + case ' ': + fallthrough + case ':': p.stateTime = timeStart if p.daylen == 0 { p.daylen = i - p.dayi @@ -566,7 +568,9 @@ iterRunes: } // We aren't breaking because we are going to re-use this case // to find where the date starts, and possible time begins - case ' ', ':': + case ' ': + fallthrough + case ':': p.stateTime = timeStart if p.yearlen == 0 { p.yearlen = i - p.yeari @@ -925,7 +929,9 @@ iterRunes: switch r { case '\'': p.yeari = i + 1 - case ' ', ',': + case ' ': + fallthrough + case ',': // x // May 8, 2009 5:57:51 PM // x @@ -941,7 +947,9 @@ iterRunes: // April 8, 2009 // April 8 2009 switch r { - case ' ', ',': + case ' ': + fallthrough + case ',': // x // June 8, 2009 // x @@ -1066,7 +1074,9 @@ iterRunes: p.dayi = i } switch r { - case ' ', '-': + case ' ': + fallthrough + case '-': if p.moi == 0 { p.moi = i + 1 p.daylen = i - p.dayi @@ -1087,8 +1097,15 @@ iterRunes: // Thu, 4 Jan 2018 17:53:36 +0000 // Tue, 11 Jul 2017 16:28:13 +0200 (CEST) // Mon, 02-Jan-06 15:04:05 MST + var offset int switch r { - case ' ', '-': + case ' ': + for i+1 < len(datestr) && datestr[i+1] == ' ' { + i++ + offset++ + } + fallthrough + case '-': if p.dayi == 0 { p.dayi = i + 1 } else if p.moi == 0 { @@ -1096,11 +1113,11 @@ iterRunes: p.setDay() p.moi = i + 1 } else if p.yeari == 0 { - p.molen = i - p.moi + p.molen = i - p.moi - offset p.set(p.moi, "Jan") p.yeari = i + 1 } else { - p.yearlen = i - p.yeari + p.yearlen = i - p.yeari - offset p.setYear() p.stateTime = timeStart break iterRunes @@ -1328,7 +1345,12 @@ iterRunes: // 15:44:11 UTC+0100 2015 switch r { case '+', '-': - p.tzlen = i - p.tzi + if datestr[p.tzi:i] == "GMT" { + p.tzi = 0 + p.tzlen = 0 + } else { + p.tzlen = i - p.tzi + } if p.tzlen == 4 { p.set(p.tzi, " MST") } else if p.tzlen == 3 { @@ -1439,7 +1461,6 @@ iterRunes: if datestr[i-1] == 'm' { p.extra = i - 2 p.trimExtra() - break } case '+', '-', '(': // This really doesn't seem valid, but for some reason when round-tripping a go date @@ -1449,7 +1470,6 @@ iterRunes: p.extra = i - 1 p.stateTime = timeWsOffset p.trimExtra() - break default: switch { case unicode.IsDigit(r): @@ -1596,7 +1616,6 @@ iterRunes: // 00:00:00.000 +0300 +0300 p.extra = i - 1 p.trimExtra() - break default: if unicode.IsLetter(r) { // 00:07:31.945167 +0000 UTC @@ -1665,10 +1684,13 @@ iterRunes: p.trimExtra() case timeWsAlphaZoneOffset: // 06:20:00 UTC-05 - if i-p.offseti < 4 { + switch i - p.offseti { + case 2, 3, 4: p.set(p.offseti, "-07") - } else { + case 5: p.set(p.offseti, "-0700") + case 6: + p.set(p.offseti, "-07:00") } case timePeriod: @@ -1982,7 +2004,6 @@ type parser struct { msi int mslen int offseti int - offsetlen int tzi int tzlen int t *time.Time @@ -2008,7 +2029,7 @@ func RetryAmbiguousDateWithSwap(retryAmbiguousDateWithSwap bool) ParserOption { } } -func newParser(dateStr string, loc *time.Location, opts ...ParserOption) *parser { +func newParser(dateStr string, loc *time.Location, opts ...ParserOption) (*parser, error) { p := &parser{ stateDate: dateStart, stateTime: timeIgnore, @@ -2021,9 +2042,11 @@ func newParser(dateStr string, loc *time.Location, opts ...ParserOption) *parser // allow the options to mutate the parser fields from their defaults for _, option := range opts { - option(p) + if err := option(p); err != nil { + return nil, fmt.Errorf("option error: %w", err) + } } - return p + return p, nil } func (p *parser) nextIs(i int, b byte) bool { @@ -2141,17 +2164,6 @@ func (p *parser) trimExtra() { } } -// func (p *parser) remove(i, length int) { -// if len(p.format) > i+length { -// //append(a[:i], a[j:]...) -// p.format = append(p.format[0:i], p.format[i+length:]...) -// } -// if len(p.datestr) > i+length { -// //append(a[:i], a[j:]...) -// p.datestr = fmt.Sprintf("%s%s", p.datestr[0:i], p.datestr[i+length:]) -// } -// } - func (p *parser) parse() (time.Time, error) { if p.t != nil { return *p.t, nil diff --git a/parseany_test.go b/parseany_test.go index 7fea1e6..0193b52 100644 --- a/parseany_test.go +++ b/parseany_test.go @@ -10,8 +10,7 @@ import ( func TestOne(t *testing.T) { time.Local = time.UTC - var ts time.Time - ts = MustParse("2020-07-20+08:00") + var ts = MustParse("2020-07-20+08:00") assert.Equal(t, "2020-07-19 16:00:00 +0000 UTC", fmt.Sprintf("%v", ts.In(time.UTC))) } @@ -417,6 +416,10 @@ var testInputs = []dateTest{ {in: "1384216367111", out: "2013-11-12 00:32:47.111 +0000 UTC"}, {in: "1384216367111222", out: "2013-11-12 00:32:47.111222 +0000 UTC"}, {in: "1384216367111222333", out: "2013-11-12 00:32:47.111222333 +0000 UTC"}, + + {in: "Wed, 8 Feb 2023 19:00:46 +1100 (AEDT)", out: "2023-02-08 08:00:46 +0000 UTC"}, + {in: "FRI, 16 AUG 2013 9:39:51 +1000", out: "2013-08-15 23:39:51 +0000 UTC"}, + {in: "Mon, 1 Dec 2008 14:48:22 GMT-07:00", out: "2008-12-01 21:48:22 +0000 UTC"}, } func TestParse(t *testing.T) { @@ -425,52 +428,70 @@ func TestParse(t *testing.T) { time.Local = time.UTC zeroTime := time.Time{}.Unix() - ts, err := ParseAny("INVALID") - assert.Equal(t, zeroTime, ts.Unix()) - assert.NotEqual(t, nil, err) + t.Run("Invalid", func(t *testing.T) { + ts, err := ParseAny("INVALID") + assert.Equal(t, zeroTime, ts.Unix()) + assert.NotEqual(t, nil, err) - assert.Equal(t, true, testDidPanic("NOT GONNA HAPPEN")) - // https://github.com/golang/go/issues/5294 - _, err = ParseAny(time.RFC3339) - assert.NotEqual(t, nil, err) + assert.Equal(t, true, testDidPanic("NOT GONNA HAPPEN")) + // https://github.com/golang/go/issues/5294 + _, err = ParseAny(time.RFC3339) + assert.NotEqual(t, nil, err) + }) for _, th := range testInputs { - if len(th.loc) > 0 { - loc, err := time.LoadLocation(th.loc) - if err != nil { - t.Fatalf("Expected to load location %q but got %v", th.loc, err) + t.Run(th.in, func(t *testing.T) { + var ts time.Time + defer func() { + if r := recover(); r != nil { + t.Fatalf("error: %s", r) + } + }() + if len(th.loc) > 0 { + loc, err := time.LoadLocation(th.loc) + if err != nil { + t.Fatalf("Expected to load location %q but got %v", th.loc, err) + } + ts, err = ParseIn(th.in, loc) + if err != nil { + t.Fatalf("expected to parse %q but got %v", th.in, err) + } + got := fmt.Sprintf("%v", ts.In(time.UTC)) + assert.Equal(t, th.out, got, "Expected %q but got %q from %q", th.out, got, th.in) + if th.out != got { + t.Fatalf("whoops, got %s, expected %s", got, th.out) + } + } else { + ts = MustParse(th.in) + got := fmt.Sprintf("%v", ts.In(time.UTC)) + assert.Equal(t, th.out, got, "Expected %q but got %q from %q", th.out, got, th.in) + if th.out != got { + t.Fatalf("whoops, got %s, expected %s", got, th.out) + } } - ts, err = ParseIn(th.in, loc) - if err != nil { - t.Fatalf("expected to parse %q but got %v", th.in, err) - } - got := fmt.Sprintf("%v", ts.In(time.UTC)) - assert.Equal(t, th.out, got, "Expected %q but got %q from %q", th.out, got, th.in) - if th.out != got { - panic("whoops") - } - } else { - ts = MustParse(th.in) - got := fmt.Sprintf("%v", ts.In(time.UTC)) - assert.Equal(t, th.out, got, "Expected %q but got %q from %q", th.out, got, th.in) - if th.out != got { - panic("whoops") - } - } + }) } // some errors - assert.Equal(t, true, testDidPanic(`{"ts":"now"}`)) + t.Run("", func(t *testing.T) { + assert.Equal(t, true, testDidPanic(`{"ts":"now"}`)) + }) - _, err = ParseAny("138421636711122233311111") // too many digits - assert.NotEqual(t, nil, err) + t.Run("too many digits", func(t *testing.T) { + _, err := ParseAny("138421636711122233311111") // too many digits + assert.NotEqual(t, nil, err) + }) - _, err = ParseAny("-1314") - assert.NotEqual(t, nil, err) + t.Run("negative number", func(t *testing.T) { + _, err := ParseAny("-1314") + assert.NotEqual(t, nil, err) + }) - _, err = ParseAny("2014-13-13 08:20:13,787") // month 13 doesn't exist so error - assert.NotEqual(t, nil, err) + t.Run("month doesn't exist", func(t *testing.T) { + _, err := ParseAny("2014-13-13 08:20:13,787") // month 13 doesn't exist so error + assert.NotEqual(t, nil, err) + }) } func testDidPanic(datestr string) (paniced bool) { @@ -488,7 +509,10 @@ func TestPStruct(t *testing.T) { denverLoc, err := time.LoadLocation("America/Denver") assert.Equal(t, nil, err) - p := newParser("08.21.71", denverLoc) + p, err := newParser("08.21.71", denverLoc) + if err != nil { + t.Fatalf("Parser build error: %s", err) + } p.setMonth() assert.Equal(t, 0, p.moi)