From b09ef46e487f98f56a546029083b030ccfaf4a71 Mon Sep 17 00:00:00 2001 From: Augusto Becciu Date: Fri, 28 Oct 2016 17:53:31 -0300 Subject: [PATCH] Added support for DST with configurable behavior. --- README.md | 6 +- cronexpr.go | 35 +++- cronexpr_next.go | 214 +++++++++++++++++++-- cronexpr_test.go | 492 ++++++++++++++++++++++++++++++++++++++++++++++- example_test.go | 78 ++++++++ 5 files changed, 808 insertions(+), 17 deletions(-) diff --git a/README.md b/README.md index e8c56d2..c94ff08 100644 --- a/README.md +++ b/README.md @@ -24,7 +24,7 @@ The reference documentation for this implementation is found at Year No 1970–2099 * / , - #### Asterisk ( * ) -The asterisk indicates that the cron expression matches for all values of the field. E.g., using an asterisk in the 4th field (month) indicates every month. +The asterisk indicates that the cron expression matches for all values of the field. E.g., using an asterisk in the 4th field (month) indicates every month. #### Slash ( / ) Slashes describe increments of ranges. For example `3-59/15` in the minute field indicate the third minute of the hour and every 15 minutes thereafter. The form `*/...` is equivalent to the form "first-last/...", that is, an increment over the largest possible range of the field. @@ -52,7 +52,7 @@ The `W` character can also be combined with `L`, i.e. `LW` to mean "the last bus Predefined cron expressions --------------------------- -(Copied from , with text modified according to this implementation) +(Copied from , with text modified according to this implementation) Entry Description Equivalent to @annually Run once a year at midnight in the morning of January 1 0 0 0 1 1 * * @@ -69,6 +69,7 @@ Other details * If only five fields are present, a `0` second field is prepended and a wildcard year field is appended, that is, `* * * * Mon` internally become `0 * * * * Mon *`. * Domain for day-of-week field is [0-7] instead of [0-6], 7 being Sunday (like 0). This to comply with http://linux.die.net/man/5/crontab#. * As of now, the behavior of the code is undetermined if a malformed cron expression is supplied +* By default, on a DST change, it returns the times that would have been skipped when the clock moves forward and returns only once the times that would have been repeated when the clock moves backwards. For customized behavior, see the godoc documentation. Install ------- @@ -131,4 +132,3 @@ License: pick the one which suits you best: - GPL v3 see - APL v2 see - diff --git a/cronexpr.go b/cronexpr.go index 58b518f..07384bf 100644 --- a/cronexpr.go +++ b/cronexpr.go @@ -27,6 +27,7 @@ import ( // type Expression struct { expression string + options Options secondList []int minuteList []int hourList []int @@ -42,6 +43,30 @@ type Expression struct { lastWeekDaysOfWeek map[int]bool daysOfWeekRestricted bool yearList []int + + // indicates an instance of a expression used internally + // for rounding a time + rounding bool +} + +type DSTFlags uint + +const ( + // DSTLeapUnskip indicates the parser to not skip times that would have been + // been skipped when the clock moves forward on a DST change. + DSTLeapUnskip = 1 << iota + + // DSTFallFireEarly indicates the parser to return the earliest time + // when a time is repeated due to a DST fall. + DSTFallFireEarly + + // DSTFallFireLate indicates the parser to return the latest time + // when a time is repeated due to a DST fall. + DSTFallFireLate +) + +type Options struct { + DSTFlags DSTFlags } /******************************************************************************/ @@ -67,6 +92,14 @@ func MustParse(cronLine string) *Expression { // about what is a well-formed cron expression from this library's point of // view. func Parse(cronLine string) (*Expression, error) { + return ParseWithOptions(cronLine, Options{DSTFlags: DSTLeapUnskip | DSTFallFireEarly}) +} + +// ParseWithOptions is used to build a Expression pointer with custom options. +func ParseWithOptions(cronLine string, options Options) (*Expression, error) { + if options.DSTFlags == 0 { + return nil, fmt.Errorf("missing DST flags") + } // Maybe one of the built-in aliases is being used cron := cronNormalizer.Replace(cronLine) @@ -81,7 +114,7 @@ func Parse(cronLine string) (*Expression, error) { fieldCount = 7 } - var expr = Expression{} + var expr = Expression{options: options} var field = 0 var err error diff --git a/cronexpr_next.go b/cronexpr_next.go index a0ebdb6..7af2f15 100644 --- a/cronexpr_next.go +++ b/cronexpr_next.go @@ -53,7 +53,8 @@ func (expr *Expression) nextYear(t time.Time) time.Time { 0, t.Location())) } - return time.Date( + + next := time.Date( expr.yearList[i], time.Month(expr.monthList[0]), expr.actualDaysOfMonthList[0], @@ -61,7 +62,9 @@ func (expr *Expression) nextYear(t time.Time) time.Time { expr.minuteList[0], expr.secondList[0], 0, - t.Location()) + time.UTC) + + return expr.nextTime(t, next) } /******************************************************************************/ @@ -87,7 +90,7 @@ func (expr *Expression) nextMonth(t time.Time) time.Time { t.Location())) } - return time.Date( + next := time.Date( t.Year(), time.Month(expr.monthList[i]), expr.actualDaysOfMonthList[0], @@ -95,7 +98,9 @@ func (expr *Expression) nextMonth(t time.Time) time.Time { expr.minuteList[0], expr.secondList[0], 0, - t.Location()) + time.UTC) + + return expr.nextTime(t, next) } /******************************************************************************/ @@ -108,7 +113,7 @@ func (expr *Expression) nextDayOfMonth(t time.Time) time.Time { return expr.nextMonth(t) } - return time.Date( + next := time.Date( t.Year(), t.Month(), expr.actualDaysOfMonthList[i], @@ -116,7 +121,9 @@ func (expr *Expression) nextDayOfMonth(t time.Time) time.Time { expr.minuteList[0], expr.secondList[0], 0, - t.Location()) + time.UTC) + + return expr.nextTime(t, next) } /******************************************************************************/ @@ -129,7 +136,7 @@ func (expr *Expression) nextHour(t time.Time) time.Time { return expr.nextDayOfMonth(t) } - return time.Date( + next := time.Date( t.Year(), t.Month(), t.Day(), @@ -137,7 +144,9 @@ func (expr *Expression) nextHour(t time.Time) time.Time { expr.minuteList[0], expr.secondList[0], 0, - t.Location()) + time.UTC) + + return expr.nextTime(t, next) } /******************************************************************************/ @@ -150,7 +159,7 @@ func (expr *Expression) nextMinute(t time.Time) time.Time { return expr.nextHour(t) } - return time.Date( + next := time.Date( t.Year(), t.Month(), t.Day(), @@ -158,7 +167,9 @@ func (expr *Expression) nextMinute(t time.Time) time.Time { expr.minuteList[i], expr.secondList[0], 0, - t.Location()) + time.UTC) + + return expr.nextTime(t, next) } /******************************************************************************/ @@ -174,7 +185,7 @@ func (expr *Expression) nextSecond(t time.Time) time.Time { return expr.nextMinute(t) } - return time.Date( + next := time.Date( t.Year(), t.Month(), t.Day(), @@ -182,7 +193,106 @@ func (expr *Expression) nextSecond(t time.Time) time.Time { t.Minute(), expr.secondList[i], 0, - t.Location()) + time.UTC) + + return expr.nextTime(t, next) +} + +func (expr *Expression) roundTime(t time.Time) time.Time { + roundingExpr := new(Expression) + *roundingExpr = *expr + roundingExpr.rounding = true + + i := sort.SearchInts(expr.hourList, t.Hour()) + if i == len(expr.hourList) || expr.hourList[i] != t.Hour() { + return roundingExpr.nextHour(t) + } + + i = sort.SearchInts(expr.minuteList, t.Minute()) + if i == len(expr.minuteList) || expr.minuteList[i] != t.Minute() { + return roundingExpr.nextMinute(t) + } + + i = sort.SearchInts(expr.secondList, t.Second()) + if i == len(expr.secondList) || expr.secondList[i] != t.Second() { + return roundingExpr.nextSecond(t) + } + + return t +} + +func (expr *Expression) isRounded(t time.Time) bool { + i := sort.SearchInts(expr.hourList, t.Hour()) + if i == len(expr.hourList) || expr.hourList[i] != t.Hour() { + return false + } + + i = sort.SearchInts(expr.minuteList, t.Minute()) + if i == len(expr.minuteList) || expr.minuteList[i] != t.Minute() { + return false + } + + i = sort.SearchInts(expr.secondList, t.Second()) + if i == len(expr.secondList) || expr.secondList[i] != t.Second() { + return false + } + + return true +} + +func (expr *Expression) nextTime(prev, next time.Time) time.Time { + dstFlags := expr.options.DSTFlags + t := prev.Add(noTZDiff(prev, next)) + offsetDiff := utcOffset(t) - utcOffset(prev) + + // a dst leap occurred + if offsetDiff > 0 { + if dstFlags&DSTLeapUnskip != 0 { + return findTimeOfDSTChange(prev, t).Add(1 * time.Second) + } + + return expr.roundTime(t) + } + + // a dst fall occurred + if offsetDiff < 0 { + twinT := findTwinTime(prev) + + if !twinT.IsZero() { + if dstFlags&DSTFallFireLate != 0 { + return twinT + } + if dstFlags&DSTFallFireEarly != 0 { + // skip the twin time + return expr.Next(expr.roundTime(twinT)) + } + } + + if dstFlags&DSTFallFireEarly != 0 { + return t + } + + return expr.roundTime(t) + } + + twinT := findTwinTime(t) + if !twinT.IsZero() { + if dstFlags&DSTFallFireEarly != 0 { + return t + } + if dstFlags&DSTFallFireLate != 0 { + return twinT + } + } + + if dstFlags&DSTFallFireLate == 0 && !expr.rounding { + twinT = findTwinTime(prev) + if !twinT.IsZero() && twinT.Before(prev) && !expr.isRounded(prev) { + return expr.Next(t) + } + } + + return t } /******************************************************************************/ @@ -290,3 +400,83 @@ func workdayOfMonth(targetDom, lastDom time.Time) int { } return dom } + +func utcOffset(t time.Time) int { + _, offset := t.Zone() + return offset +} + +func noTZ(t time.Time) time.Time { + return t.UTC().Add(time.Duration(utcOffset(t)) * time.Second) +} + +func noTZDiff(t1, t2 time.Time) time.Duration { + t1 = noTZ(t1) + t2 = noTZ(t2) + return t2.Sub(t1) +} + +// findTimeOfDSTChange returns the time a second before a DST change occurs, +// and returns zero time in case there's no DST change. +func findTimeOfDSTChange(t1, t2 time.Time) time.Time { + if t1.Location() != t2.Location() || utcOffset(t1) == utcOffset(t2) || t1.Location() == time.UTC { + return time.Time{} + } + + // make sure t2 > t1 + if t2.Before(t1) { + t := t2 + t1 = t2 + t2 = t + } + + // do a binary search to find the time one second before the dst change + len := t2.Unix() - t1.Unix() + var a int64 + b := len + for len > 1 { + len = (b - a + 1) / 2 + if utcOffset(t1.Add(time.Duration(a+len)*time.Second)) != utcOffset(t1) { + b = a + len + } else { + a = a + len + } + } + + return t1.Add(time.Duration(a) * time.Second) +} + +// When a DST fall accurs, a certain interval of time is repeated. Once +// in DST time and once in standard time. +// findTwinTime tries to find the repated "twin" time if one exists. +func findTwinTime(t time.Time) time.Time { + offsetDiff := utcOffset(t.Add(12*time.Hour)) - utcOffset(t) + // a fall occurs within the next 8 hours + if offsetDiff < 0 { + border := findTimeOfDSTChange(t, t.Add(12*time.Hour)) + t0 := border.Add(time.Duration(offsetDiff) * time.Second) + + if t0.After(t) { + return t + } + + dur := t.Sub(t0) + return border.Add(dur) + } + + offsetDiff = utcOffset(t) - utcOffset(t.Add(-12*time.Second)) + // a fall occurred in the past 8 hours + if offsetDiff < 0 { + border := findTimeOfDSTChange(t.Add(-12*time.Hour), t) + t0 := border.Add(time.Duration(offsetDiff) * time.Second) + + if t0.Add(time.Duration(-2*offsetDiff) * time.Second).Before(t) { + return t + } + + dur := t.Sub(border) + return t0.Add(dur) + } + + return time.Time{} +} diff --git a/cronexpr_test.go b/cronexpr_test.go index b729170..691aa84 100644 --- a/cronexpr_test.go +++ b/cronexpr_test.go @@ -15,6 +15,7 @@ package cronexpr_test /******************************************************************************/ import ( + "fmt" "testing" "time" @@ -278,12 +279,501 @@ func TestNextN_every5min(t *testing.T) { for i, next := range result { nextStr := next.Format("Mon, 2 Jan 2006 15:04:05") if nextStr != expected[i] { - t.Errorf(`MustParse("*/5 * * * *").NextN("2013-09-02 08:44:30", 5):\n"`) + t.Errorf(`MustParse("*/5 * * * *").NextN("2013-09-02 08:44:30", 5):\n"`) t.Errorf(` result[%d]: expected "%s" but got "%s"`, i, expected[i], nextStr) } } } +func TestDST(t *testing.T) { + var locs [3]*time.Location + + // 1 hour DST, negative UTC offset + // time.Date(2014, 3, 9, 2, 0, 0, 0, locs[0]) Leap PST -> PDT + // time.Date(2014, 11, 2, 1, 59, 59, 0, locs[0]) Fall PDT -> PST + locs[0], _ = time.LoadLocation("America/Los_Angeles") + + // biggest tz leap ever (3 hours), occurred from YAKT to MAGST + // at time.Date(1981, 4, 1, 3, 0, 0, 0, locs[1]), + locs[1], _ = time.LoadLocation("Asia/Ust-Nera") + + // 30 mins DST, positive UTC offset + // time.Date(2014, 10, 5, 2, 30, 0, 0, locs[2]) Leap LHST -> LHDT + // time.Date(2015, 4, 5, 1, 30, 0, 0, locs[2]) Fall LHDT -> LHST + locs[2], _ = time.LoadLocation("Australia/LHI") + + cases := []struct { + name string + expr string + opts cronexpr.Options + from time.Time + expected []time.Time + }{ + { + fmt.Sprintf("%s daily leap skip", locs[0]), + "0 0 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2014, 3, 9, 1, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 3, 10, 2, 0, 0, 0, locs[0]), + time.Date(2014, 3, 11, 2, 0, 0, 0, locs[0]), + time.Date(2014, 3, 12, 2, 0, 0, 0, locs[0]), + time.Date(2014, 3, 13, 2, 0, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s daily leap unskip", locs[0]), + "0 0 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireEarly}, + time.Date(2014, 3, 9, 1, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 3, 9, 3, 0, 0, 0, locs[0]), + time.Date(2014, 3, 10, 2, 0, 0, 0, locs[0]), + time.Date(2014, 3, 11, 2, 0, 0, 0, locs[0]), + time.Date(2014, 3, 12, 2, 0, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s hourly leap skip", locs[0]), + "0 0 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2014, 3, 9, 1, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 3, 9, 3, 0, 0, 0, locs[0]), + time.Date(2014, 3, 9, 4, 0, 0, 0, locs[0]), + time.Date(2014, 3, 9, 5, 0, 0, 0, locs[0]), + time.Date(2014, 3, 9, 6, 0, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s hourly leap unskip", locs[0]), + "0 0 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireEarly}, + time.Date(2014, 3, 9, 1, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 3, 9, 3, 0, 0, 0, locs[0]), + time.Date(2014, 3, 9, 4, 0, 0, 0, locs[0]), + time.Date(2014, 3, 9, 5, 0, 0, 0, locs[0]), + time.Date(2014, 3, 9, 6, 0, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s daily quarter-hourly leap skip", locs[0]), + "0 */15 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2014, 3, 9, 1, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 3, 10, 2, 0, 0, 0, locs[0]), + time.Date(2014, 3, 10, 2, 15, 0, 0, locs[0]), + time.Date(2014, 3, 10, 2, 30, 0, 0, locs[0]), + time.Date(2014, 3, 10, 2, 45, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s daily quarter-hourly leap unskip", locs[0]), + "0 */15 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireEarly}, + time.Date(2014, 3, 9, 1, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 3, 9, 3, 0, 0, 0, locs[0]), + time.Date(2014, 3, 9, 3, 15, 0, 0, locs[0]), + time.Date(2014, 3, 9, 3, 30, 0, 0, locs[0]), + time.Date(2014, 3, 9, 3, 45, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s daily fall fire early", locs[0]), + "0 0 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2014, 11, 1, 2, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 11, 2, 1, 0, 0, 0, locs[0]).Add(1 * time.Hour), + time.Date(2014, 11, 3, 2, 0, 0, 0, locs[0]), + time.Date(2014, 11, 4, 2, 0, 0, 0, locs[0]), + time.Date(2014, 11, 5, 2, 0, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s daily fall fire late", locs[0]), + "0 0 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireLate}, + time.Date(2014, 11, 1, 2, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 11, 2, 2, 0, 0, 0, locs[0]), + time.Date(2014, 11, 3, 2, 0, 0, 0, locs[0]), + time.Date(2014, 11, 4, 2, 0, 0, 0, locs[0]), + time.Date(2014, 11, 5, 2, 0, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s daily fall fire both", locs[0]), + "0 0 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly | cronexpr.DSTFallFireLate}, + time.Date(2014, 11, 1, 2, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 11, 2, 1, 0, 0, 0, locs[0]).Add(1 * time.Hour), + time.Date(2014, 11, 2, 2, 0, 0, 0, locs[0]), + time.Date(2014, 11, 3, 2, 0, 0, 0, locs[0]), + time.Date(2014, 11, 4, 2, 0, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s hourly fall fire early", locs[0]), + "0 0 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2014, 11, 2, 0, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 11, 2, 1, 0, 0, 0, locs[0]), + time.Date(2014, 11, 2, 2, 0, 0, 0, locs[0]), + time.Date(2014, 11, 2, 3, 0, 0, 0, locs[0]), + time.Date(2014, 11, 2, 4, 0, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s hourly fall fire late", locs[0]), + "0 0 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireLate}, + time.Date(2014, 11, 2, 0, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 11, 2, 1, 0, 0, 0, locs[0]).Add(1 * time.Hour), + time.Date(2014, 11, 2, 2, 0, 0, 0, locs[0]), + time.Date(2014, 11, 2, 3, 0, 0, 0, locs[0]), + time.Date(2014, 11, 2, 4, 0, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s hourly fall fire twice", locs[0]), + "0 0 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly | cronexpr.DSTFallFireLate}, + time.Date(2014, 11, 2, 0, 0, 0, 0, locs[0]), + []time.Time{ + time.Date(2014, 11, 2, 1, 0, 0, 0, locs[0]), + time.Date(2014, 11, 2, 1, 0, 0, 0, locs[0]).Add(1 * time.Hour), + time.Date(2014, 11, 2, 2, 0, 0, 0, locs[0]), + time.Date(2014, 11, 2, 3, 0, 0, 0, locs[0]), + }, + }, + { + fmt.Sprintf("%s daily leap skip", locs[1]), + "0 0 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(1981, 3, 31, 2, 0, 0, 0, locs[1]), + []time.Time{ + time.Date(1981, 4, 2, 2, 0, 0, 0, locs[1]), + time.Date(1981, 4, 3, 2, 0, 0, 0, locs[1]), + time.Date(1981, 4, 4, 2, 0, 0, 0, locs[1]), + time.Date(1981, 4, 5, 2, 0, 0, 0, locs[1]), + }, + }, + { + fmt.Sprintf("%s daily leap unskip", locs[1]), + "0 0 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireEarly}, + time.Date(1981, 3, 31, 2, 0, 0, 0, locs[1]), + []time.Time{ + time.Date(1981, 4, 1, 3, 0, 0, 0, locs[1]), + time.Date(1981, 4, 2, 2, 0, 0, 0, locs[1]), + time.Date(1981, 4, 3, 2, 0, 0, 0, locs[1]), + time.Date(1981, 4, 4, 2, 0, 0, 0, locs[1]), + }, + }, + { + fmt.Sprintf("%s hourly leap skip", locs[1]), + "0 0 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(1981, 3, 31, 23, 0, 0, 0, locs[1]), + []time.Time{ + time.Date(1981, 4, 1, 3, 0, 0, 0, locs[1]), + time.Date(1981, 4, 1, 4, 0, 0, 0, locs[1]), + time.Date(1981, 4, 1, 5, 0, 0, 0, locs[1]), + time.Date(1981, 4, 1, 6, 0, 0, 0, locs[1]), + }, + }, + { + fmt.Sprintf("%s hourly leap unskip", locs[1]), + "0 0 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireEarly}, + time.Date(1981, 3, 31, 23, 0, 0, 0, locs[1]), + []time.Time{ + time.Date(1981, 4, 1, 3, 0, 0, 0, locs[1]), + time.Date(1981, 4, 1, 4, 0, 0, 0, locs[1]), + time.Date(1981, 4, 1, 5, 0, 0, 0, locs[1]), + time.Date(1981, 4, 1, 6, 0, 0, 0, locs[1]), + }, + }, + { + fmt.Sprintf("%s quarter-hourly leap skip", locs[1]), + "0 */15 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(1981, 3, 31, 23, 15, 0, 0, locs[1]), + []time.Time{ + time.Date(1981, 3, 31, 23, 30, 0, 0, locs[1]), + time.Date(1981, 3, 31, 23, 45, 0, 0, locs[1]), + time.Date(1981, 4, 1, 3, 0, 0, 0, locs[1]), + time.Date(1981, 4, 1, 3, 15, 0, 0, locs[1]), + }, + }, + { + fmt.Sprintf("%s quarter-hourly leap unskip", locs[1]), + "0 */15 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireEarly}, + time.Date(1981, 3, 31, 23, 15, 0, 0, locs[1]), + []time.Time{ + time.Date(1981, 3, 31, 23, 30, 0, 0, locs[1]), + time.Date(1981, 3, 31, 23, 45, 0, 0, locs[1]), + time.Date(1981, 4, 1, 3, 0, 0, 0, locs[1]), + time.Date(1981, 4, 1, 3, 15, 0, 0, locs[1]), + }, + }, + { + fmt.Sprintf("%s daily third-hourly leap skip", locs[1]), + "0 */20 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(1981, 3, 31, 2, 40, 0, 0, locs[1]), + []time.Time{ + time.Date(1981, 4, 2, 2, 0, 0, 0, locs[1]), + time.Date(1981, 4, 2, 2, 20, 0, 0, locs[1]), + time.Date(1981, 4, 2, 2, 40, 0, 0, locs[1]), + time.Date(1981, 4, 3, 2, 0, 0, 0, locs[1]), + }, + }, + { + fmt.Sprintf("%s daily third-hourly leap unskip", locs[1]), + "0 */20 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireEarly}, + time.Date(1981, 3, 31, 2, 40, 0, 0, locs[1]), + []time.Time{ + time.Date(1981, 4, 1, 3, 0, 0, 0, locs[1]), + time.Date(1981, 4, 1, 3, 20, 0, 0, locs[1]), + time.Date(1981, 4, 1, 3, 40, 0, 0, locs[1]), + time.Date(1981, 4, 2, 2, 0, 0, 0, locs[1]), + }, + }, + { + fmt.Sprintf("%s daily leap skip", locs[2]), + "0 0 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2014, 10, 4, 2, 0, 0, 0, locs[2]), + []time.Time{ + time.Date(2014, 10, 6, 2, 0, 0, 0, locs[2]), + time.Date(2014, 10, 7, 2, 0, 0, 0, locs[2]), + time.Date(2014, 10, 8, 2, 0, 0, 0, locs[2]), + time.Date(2014, 10, 9, 2, 0, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s daily leap unskip", locs[2]), + "0 0 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireEarly}, + time.Date(2014, 10, 4, 2, 0, 0, 0, locs[2]), + []time.Time{ + time.Date(2014, 10, 5, 2, 30, 0, 0, locs[2]), + time.Date(2014, 10, 6, 2, 0, 0, 0, locs[2]), + time.Date(2014, 10, 7, 2, 0, 0, 0, locs[2]), + time.Date(2014, 10, 8, 2, 0, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s hourly leap skip", locs[2]), + "0 0 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2014, 10, 5, 1, 0, 0, 0, locs[2]), + []time.Time{ + time.Date(2014, 10, 5, 3, 0, 0, 0, locs[2]), + time.Date(2014, 10, 5, 4, 0, 0, 0, locs[2]), + time.Date(2014, 10, 5, 5, 0, 0, 0, locs[2]), + time.Date(2014, 10, 5, 6, 0, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s hourly leap unskip", locs[2]), + "0 0 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireEarly}, + time.Date(2014, 10, 5, 1, 0, 0, 0, locs[2]), + []time.Time{ + time.Date(2014, 10, 5, 2, 30, 0, 0, locs[2]), + time.Date(2014, 10, 5, 3, 0, 0, 0, locs[2]), + time.Date(2014, 10, 5, 4, 0, 0, 0, locs[2]), + time.Date(2014, 10, 5, 5, 0, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s quarter-hourly leap skip", locs[2]), + "0 */15 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2014, 10, 5, 1, 15, 0, 0, locs[2]), + []time.Time{ + time.Date(2014, 10, 5, 1, 30, 0, 0, locs[2]), + time.Date(2014, 10, 5, 1, 45, 0, 0, locs[2]), + time.Date(2014, 10, 5, 2, 30, 0, 0, locs[2]), + time.Date(2014, 10, 5, 2, 45, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s quarter-hourly leap unskip", locs[2]), + "0 */15 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireEarly}, + time.Date(2014, 10, 5, 1, 15, 0, 0, locs[2]), + []time.Time{ + time.Date(2014, 10, 5, 1, 30, 0, 0, locs[2]), + time.Date(2014, 10, 5, 1, 45, 0, 0, locs[2]), + time.Date(2014, 10, 5, 2, 30, 0, 0, locs[2]), + time.Date(2014, 10, 5, 2, 45, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s third-hourly leap skip", locs[2]), + "0 */20 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2014, 10, 4, 2, 40, 0, 0, locs[2]), + []time.Time{ + time.Date(2014, 10, 5, 2, 40, 0, 0, locs[2]), + time.Date(2014, 10, 6, 2, 0, 0, 0, locs[2]), + time.Date(2014, 10, 6, 2, 20, 0, 0, locs[2]), + time.Date(2014, 10, 6, 2, 40, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s third-hourly leap unskip", locs[2]), + "0 */20 2 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireEarly}, + time.Date(2014, 10, 4, 2, 40, 0, 0, locs[2]), + []time.Time{ + time.Date(2014, 10, 5, 2, 30, 0, 0, locs[2]), + time.Date(2014, 10, 5, 2, 40, 0, 0, locs[2]), + time.Date(2014, 10, 6, 2, 0, 0, 0, locs[2]), + time.Date(2014, 10, 6, 2, 20, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s daily fall fire early", locs[2]), + "0 45 1 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2015, 4, 5, 1, 0, 0, 0, locs[2]).Add(45 * time.Minute), + []time.Time{ + time.Date(2015, 4, 6, 1, 45, 0, 0, locs[2]), + time.Date(2015, 4, 7, 1, 45, 0, 0, locs[2]), + time.Date(2015, 4, 8, 1, 45, 0, 0, locs[2]), + time.Date(2015, 4, 9, 1, 45, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s daily fall fire late", locs[2]), + "0 45 1 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireLate}, + time.Date(2015, 4, 5, 1, 45, 0, 0, locs[2]), + []time.Time{ + time.Date(2015, 4, 6, 1, 45, 0, 0, locs[2]), + time.Date(2015, 4, 7, 1, 45, 0, 0, locs[2]), + time.Date(2015, 4, 8, 1, 45, 0, 0, locs[2]), + time.Date(2015, 4, 9, 1, 45, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s daily fall fire twice", locs[2]), + "0 45 1 * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly | cronexpr.DSTFallFireLate}, + time.Date(2015, 4, 5, 1, 0, 0, 0, locs[2]), + []time.Time{ + time.Date(2015, 4, 5, 1, 0, 0, 0, locs[2]).Add(45 * time.Minute), + time.Date(2015, 4, 5, 1, 45, 0, 0, locs[2]), + time.Date(2015, 4, 6, 1, 45, 0, 0, locs[2]), + time.Date(2015, 4, 7, 1, 45, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s hourly fall fire early", locs[2]), + "0 30 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2015, 4, 5, 0, 30, 0, 0, locs[2]), + []time.Time{ + time.Date(2015, 4, 5, 0, 30, 0, 0, locs[2]).Add(1 * time.Hour), + time.Date(2015, 4, 5, 2, 30, 0, 0, locs[2]), + time.Date(2015, 4, 5, 3, 30, 0, 0, locs[2]), + time.Date(2015, 4, 5, 4, 30, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s hourly fall fire late", locs[2]), + "0 30 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireLate}, + time.Date(2015, 4, 5, 0, 30, 0, 0, locs[2]), + []time.Time{ + time.Date(2015, 4, 5, 1, 30, 0, 0, locs[2]), + time.Date(2015, 4, 5, 2, 30, 0, 0, locs[2]), + time.Date(2015, 4, 5, 3, 30, 0, 0, locs[2]), + time.Date(2015, 4, 5, 4, 30, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s hourly fall fire twice", locs[2]), + "0 30 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly | cronexpr.DSTFallFireLate}, + time.Date(2015, 4, 5, 0, 30, 0, 0, locs[2]), + []time.Time{ + time.Date(2015, 4, 5, 0, 30, 0, 0, locs[2]).Add(1 * time.Hour), + time.Date(2015, 4, 5, 1, 30, 0, 0, locs[2]), + time.Date(2015, 4, 5, 2, 30, 0, 0, locs[2]), + time.Date(2015, 4, 5, 3, 30, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s half-hourly fall fire early", locs[2]), + "0 */30 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly}, + time.Date(2015, 4, 5, 1, 0, 0, 0, locs[2]), + []time.Time{ + time.Date(2015, 4, 5, 1, 0, 0, 0, locs[2]).Add(30 * time.Minute), + time.Date(2015, 4, 5, 2, 0, 0, 0, locs[2]), + time.Date(2015, 4, 5, 2, 30, 0, 0, locs[2]), + time.Date(2015, 4, 5, 3, 0, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s half-hourly fall fire late", locs[2]), + "0 */30 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireLate}, + time.Date(2015, 4, 5, 1, 0, 0, 0, locs[2]), + []time.Time{ + time.Date(2015, 4, 5, 1, 30, 0, 0, locs[2]), + time.Date(2015, 4, 5, 2, 0, 0, 0, locs[2]), + time.Date(2015, 4, 5, 2, 30, 0, 0, locs[2]), + time.Date(2015, 4, 5, 3, 0, 0, 0, locs[2]), + }, + }, + { + fmt.Sprintf("%s half-hourly fall fire twice", locs[2]), + "0 */30 * * * * *", + cronexpr.Options{DSTFlags: cronexpr.DSTFallFireEarly | cronexpr.DSTFallFireLate}, + time.Date(2015, 4, 5, 1, 0, 0, 0, locs[2]), + []time.Time{ + time.Date(2015, 4, 5, 1, 0, 0, 0, locs[2]).Add(30 * time.Minute), + time.Date(2015, 4, 5, 1, 30, 0, 0, locs[2]), + time.Date(2015, 4, 5, 2, 0, 0, 0, locs[2]), + time.Date(2015, 4, 5, 2, 30, 0, 0, locs[2]), + }, + }, + } + + for _, tc := range cases { + s, err := cronexpr.ParseWithOptions(tc.expr, tc.opts) + if err != nil { + t.Fatalf("parser error: %s", err) + } + + runs := s.NextN(tc.from, 4) + if len(runs) != 4 { + t.Errorf("Case %s: Expected 4 runs, got %d", tc.name, len(runs)) + } + + for i := 0; i < len(runs); i++ { + if !runs[i].Equal(tc.expected[i]) { + t.Errorf("Case %s: Expected %v, got %v", tc.name, tc.expected[i], runs[i]) + } + } + } +} + /******************************************************************************/ var benchmarkExpressions = []string{ diff --git a/example_test.go b/example_test.go index 3dc27d0..9673b7b 100644 --- a/example_test.go +++ b/example_test.go @@ -35,3 +35,81 @@ func ExampleMustParse() { // Sun, 29 Feb 2032 00:00:00 UTC } } + +// Configure the parser to skip times in DST leaps and +// repeat times in DST falls +func ExampleParseWithOptions_dst1() { + loc, _ := time.LoadLocation("America/Los_Angeles") + t := time.Date(2014, 3, 8, 1, 0, 0, 0, loc) + expr, _ := cronexpr.ParseWithOptions("0 0 2 * * * *", cronexpr.Options{ + DSTFlags: cronexpr.DSTFallFireEarly | cronexpr.DSTFallFireLate, + }) + + fmt.Println("DST leap times:") + nextTimes := expr.NextN(t, 4) + for i := range nextTimes { + fmt.Println(nextTimes[i].Format(time.RFC1123)) + } + + t = time.Date(2014, 10, 31, 1, 0, 0, 0, loc) + expr, _ = cronexpr.ParseWithOptions("0 0 1 * * * *", cronexpr.Options{ + DSTFlags: cronexpr.DSTFallFireEarly | cronexpr.DSTFallFireLate, + }) + + fmt.Println("DST fall times:") + nextTimes = expr.NextN(t, 4) + for i := range nextTimes { + fmt.Println(nextTimes[i].Format(time.RFC1123)) + } + + // Output: + // DST leap times: + // Sat, 08 Mar 2014 02:00:00 PST + // Mon, 10 Mar 2014 02:00:00 PDT + // Tue, 11 Mar 2014 02:00:00 PDT + // Wed, 12 Mar 2014 02:00:00 PDT + // DST fall times: + // Sat, 01 Nov 2014 01:00:00 PDT + // Sun, 02 Nov 2014 01:00:00 PDT + // Sun, 02 Nov 2014 01:00:00 PST + // Mon, 03 Nov 2014 01:00:00 PST +} + +// Configure the parser to unskip times in DST leaps and +// fire late in DST falls +func ExampleParseWithOptions_dst2() { + loc, _ := time.LoadLocation("America/Los_Angeles") + t := time.Date(2014, 3, 8, 1, 0, 0, 0, loc) + expr, _ := cronexpr.ParseWithOptions("0 0 2 * * * *", cronexpr.Options{ + DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireLate, + }) + + fmt.Println("DST leap times:") + nextTimes := expr.NextN(t, 4) + for i := range nextTimes { + fmt.Println(nextTimes[i].Format(time.RFC1123)) + } + + t = time.Date(2014, 10, 31, 1, 0, 0, 0, loc) + expr, _ = cronexpr.ParseWithOptions("0 0 1 * * * *", cronexpr.Options{ + DSTFlags: cronexpr.DSTLeapUnskip | cronexpr.DSTFallFireLate, + }) + + fmt.Println("DST fall times:") + nextTimes = expr.NextN(t, 4) + for i := range nextTimes { + fmt.Println(nextTimes[i].Format(time.RFC1123)) + } + + // Output: + // DST leap times: + // Sat, 08 Mar 2014 02:00:00 PST + // Sun, 09 Mar 2014 03:00:00 PDT + // Mon, 10 Mar 2014 02:00:00 PDT + // Tue, 11 Mar 2014 02:00:00 PDT + // DST fall times: + // Sat, 01 Nov 2014 01:00:00 PDT + // Sun, 02 Nov 2014 01:00:00 PST + // Mon, 03 Nov 2014 01:00:00 PST + // Tue, 04 Nov 2014 01:00:00 PST +}