diff --git a/doc.go b/doc.go index fa5d08b..7171378 100644 --- a/doc.go +++ b/doc.go @@ -44,7 +44,7 @@ A cron expression represents a set of times, using 5 space-separated fields. ---------- | ---------- | -------------- | -------------------------- Minutes | Yes | 0-59 | * / , - Hours | Yes | 0-23 | * / , - - Day of month | Yes | 1-31 | * / , - ? + Day of month | Yes | 1-31 | * / , - ? L Month | Yes | 1-12 or JAN-DEC | * / , - Day of week | Yes | 0-6 or SUN-SAT | * / , - ? @@ -105,6 +105,9 @@ Question mark ( ? ) Question mark may be used instead of '*' for leaving either day-of-month or day-of-week blank. +'L' stands for "last". When used in the day-of-month field, it specifies +the last day of the month. + Predefined schedules You may use one of several pre-defined schedules in place of a cron expression. diff --git a/parser.go b/parser.go index 8da6547..0355b47 100644 --- a/parser.go +++ b/parser.go @@ -120,6 +120,11 @@ func (p Parser) Parse(spec string) (Schedule, error) { return nil, err } + if fields[3] == "L" { + now := time.Now().In(loc) + fields[3] = strconv.Itoa(ldom(now)) + } + field := func(field string, r bounds) uint64 { if err != nil { return 0 @@ -149,6 +154,7 @@ func (p Parser) Parse(spec string) (Schedule, error) { Month: month, Dow: dayofweek, Location: loc, + CronExpr: spec, }, nil } @@ -373,6 +379,7 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) { Month: 1 << months.min, Dow: all(dow), Location: loc, + CronExpr: descriptor, }, nil case "@monthly": @@ -384,6 +391,7 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) { Month: all(months), Dow: all(dow), Location: loc, + CronExpr: descriptor, }, nil case "@weekly": @@ -395,6 +403,7 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) { Month: all(months), Dow: 1 << dow.min, Location: loc, + CronExpr: descriptor, }, nil case "@daily", "@midnight": @@ -406,6 +415,7 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) { Month: all(months), Dow: all(dow), Location: loc, + CronExpr: descriptor, }, nil case "@hourly": @@ -417,6 +427,7 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) { Month: all(months), Dow: all(dow), Location: loc, + CronExpr: descriptor, }, nil } @@ -432,3 +443,4 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) { return nil, fmt.Errorf("unrecognized descriptor: %s", descriptor) } + diff --git a/parser_test.go b/parser_test.go index 41c8c52..e326f55 100644 --- a/parser_test.go +++ b/parser_test.go @@ -144,17 +144,17 @@ func TestParseSchedule(t *testing.T) { expr string expected Schedule }{ - {secondParser, "0 5 * * * *", every5min(time.Local)}, - {standardParser, "5 * * * *", every5min(time.Local)}, - {secondParser, "CRON_TZ=UTC 0 5 * * * *", every5min(time.UTC)}, - {standardParser, "CRON_TZ=UTC 5 * * * *", every5min(time.UTC)}, - {secondParser, "CRON_TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo)}, + {secondParser, "0 5 * * * *", every5min(time.Local, "0 5 * * * *")}, + {standardParser, "5 * * * *", every5min(time.Local, "5 * * * *")}, + {secondParser, "CRON_TZ=UTC 0 5 * * * *", every5min(time.UTC, "0 5 * * * *")}, + {standardParser, "CRON_TZ=UTC 5 * * * *", every5min(time.UTC, "5 * * * *")}, + {secondParser, "CRON_TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo, "0 5 * * * *")}, {secondParser, "@every 5m", ConstantDelaySchedule{5 * time.Minute}}, - {secondParser, "@midnight", midnight(time.Local)}, - {secondParser, "TZ=UTC @midnight", midnight(time.UTC)}, - {secondParser, "TZ=Asia/Tokyo @midnight", midnight(tokyo)}, - {secondParser, "@yearly", annual(time.Local)}, - {secondParser, "@annually", annual(time.Local)}, + {secondParser, "@midnight", midnight(time.Local, "@midnight")}, + {secondParser, "TZ=UTC @midnight", midnight(time.UTC, "@midnight")}, + {secondParser, "TZ=Asia/Tokyo @midnight", midnight(tokyo, "@midnight")}, + {secondParser, "@yearly", annual(time.Local, "@yearly")}, + {secondParser, "@annually", annual(time.Local, "@annually")}, { parser: secondParser, expr: "* 5 * * * *", @@ -166,6 +166,7 @@ func TestParseSchedule(t *testing.T) { Month: all(months), Dow: all(dow), Location: time.Local, + CronExpr: "* 5 * * * *", }, }, } @@ -187,9 +188,9 @@ func TestOptionalSecondSchedule(t *testing.T) { expr string expected Schedule }{ - {"0 5 * * * *", every5min(time.Local)}, - {"5 5 * * * *", every5min5s(time.Local)}, - {"5 * * * *", every5min(time.Local)}, + {"0 5 * * * *", every5min(time.Local, "0 5 * * * *")}, + {"5 5 * * * *", every5min5s(time.Local, "5 5 * * * *")}, + {"5 * * * *", every5min(time.Local, "5 * * * *")}, } for _, c := range entries { @@ -320,7 +321,7 @@ func TestStandardSpecSchedule(t *testing.T) { }{ { expr: "5 * * * *", - expected: &SpecSchedule{1 << seconds.min, 1 << 5, all(hours), all(dom), all(months), all(dow), time.Local}, + expected: &SpecSchedule{1 << seconds.min, 1 << 5, all(hours), all(dom), all(months), all(dow), time.Local, "5 * * * *"}, }, { expr: "@every 5m", @@ -358,19 +359,19 @@ func TestNoDescriptorParser(t *testing.T) { } } -func every5min(loc *time.Location) *SpecSchedule { - return &SpecSchedule{1 << 0, 1 << 5, all(hours), all(dom), all(months), all(dow), loc} +func every5min(loc *time.Location, spec string) *SpecSchedule { + return &SpecSchedule{1 << 0, 1 << 5, all(hours), all(dom), all(months), all(dow), loc, spec} } -func every5min5s(loc *time.Location) *SpecSchedule { - return &SpecSchedule{1 << 5, 1 << 5, all(hours), all(dom), all(months), all(dow), loc} +func every5min5s(loc *time.Location, spec string) *SpecSchedule { + return &SpecSchedule{1 << 5, 1 << 5, all(hours), all(dom), all(months), all(dow), loc, spec} } -func midnight(loc *time.Location) *SpecSchedule { - return &SpecSchedule{1, 1, 1, all(dom), all(months), all(dow), loc} +func midnight(loc *time.Location, spec string) *SpecSchedule { + return &SpecSchedule{1, 1, 1, all(dom), all(months), all(dow), loc, spec} } -func annual(loc *time.Location) *SpecSchedule { +func annual(loc *time.Location, spec string) *SpecSchedule { return &SpecSchedule{ Second: 1 << seconds.min, Minute: 1 << minutes.min, @@ -379,5 +380,69 @@ func annual(loc *time.Location) *SpecSchedule { Month: 1 << months.min, Dow: all(dow), Location: loc, + CronExpr: spec, } } + +// Test Last Day of Month 'L' +func TestLDOM(t *testing.T) { + parser := NewParser(SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor) + tests := []struct { + time, spec string + expected bool + }{ + {"Tue Jul 31 01:10:30 2021", "30 10 1 L * ?", true}, + {"Tue Jul 31 00:00:00 2021", "0 0 0 L * *", true}, + {"Mon Jan 31 00:00:00 2022", "30 10 1 L 1 ?", false}, + } + + for _, test := range tests { + sched, err := parser.Parse(test.spec) + if err != nil { + t.Error(err) + continue + } + actual := sched.Next(getTime(test.time).Add(-1 * time.Second)) + expected := getTime(test.time) + if test.expected && expected != actual || !test.expected && expected == actual { + t.Errorf("Fail evaluating %s on %s: (expected) %s != %s (actual)", + test.spec, test.time, expected, actual) + } + } +} + +// Test Next activation time for Last Day of Month 'L' +func TestLDOMNext(t *testing.T) { + parser := NewParser(SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor) + runs := []struct { + time, spec string + expected string + }{ + {"TZ=America/New_York 2012-11-04T03:00:00-0500", "0 0 0 L * ?", "2012-11-30T00:00:00-0500"}, + {"TZ=Asia/Kolkata 2021-12-16T12:00:00+0530", "30 10 1 L * ?", "2021-12-31T01:10:30+0530"}, + {"2021-01-01T12:00:00+0000", "30 10 1 L * ?", "2021-01-31T01:10:30+0000"}, + {"2021-01-31T12:00:00+0000", "0 50 5 L * ?", "2021-02-28T05:50:00+0000"}, + {"2021-02-27T11:00:00+0000", "0 0 1 L * ?", "2021-02-28T01:00:00+0000"}, + {"2024-01-31T10:40:10+0530", "40 10 10 L * ?", "2024-02-29T10:10:40+0530"}, + {"2024-02-15T10:40:10+0530", "40 10 10 L * ?", "2024-02-29T10:10:40+0530"}, + {"TZ=UTC 2020-01-31T23:00:00+0000", "0 0 9 L * ?", "2020-02-29T09:00:00+0000"}, + {"2021-01-31T10:30:00+0000", "0 0 18 L * ?", "2021-01-31T18:00:00+0000"}, + {"2024-01-30T12:00:00+0530", "0 55 23 L * *", "2024-01-31T23:55:00+0530"}, + {"2024-01-31T00:00:00+0530", "0 30 2 L * *", "2024-01-31T02:30:00+0530"}, + {"2024-01-30T23:59:00+0530", "0 0 0 L * *", "2024-01-31T00:00:00+0530"}, + {"2024-01-31T23:59:00+0530", "0 0 0 L * *", "2024-02-29T00:00:00+0530"}, + } + + for _, c := range runs { + sched, err := parser.Parse(c.spec) + if err != nil { + t.Error(err) + continue + } + actual := sched.Next(getTime(c.time)) + expected := getTime(c.expected) + if !actual.Equal(expected) { + t.Errorf("%s, \"%s\": (expected) %v != %v (actual)", c.time, c.spec, expected, actual) + } + } +} \ No newline at end of file diff --git a/spec.go b/spec.go index fa1e241..f683266 100644 --- a/spec.go +++ b/spec.go @@ -1,6 +1,9 @@ package cron -import "time" +import ( + "strings" + "time" +) // SpecSchedule specifies a duty cycle (to the second granularity), based on a // traditional crontab specification. It is computed initially and stored as bit sets. @@ -9,6 +12,9 @@ type SpecSchedule struct { // Override location for this schedule. Location *time.Location + + // Cron Expression for this schedule + CronExpr string } // bounds provides a range of acceptable values (plus a map of name to value). @@ -87,6 +93,18 @@ func (s *SpecSchedule) Next(t time.Time) time.Time { // If no time is found within five years, return zero. yearLimit := t.Year() + 5 + // check if last day of month is present in cron spec, if so, update the bits as per current month + edom := false + fields := strings.Fields(s.CronExpr) + + for idx := range fields { + if fields[idx] == "L" { + edom = true + s.Dom = getBits(1, uint(ldom(t)), 1) + break + } + } + WRAP: if t.Year() > yearLimit { return time.Time{} @@ -171,6 +189,13 @@ WRAP: } } + if edom { + year, month, _ := t.In(origLocation).Date() + h, m, s := t.In(origLocation).Clock() + + return time.Date(year, month+1, 0, h, m, s, 0, origLocation) + } + return t.In(origLocation) } @@ -186,3 +211,28 @@ func dayMatches(s *SpecSchedule, t time.Time) bool { } return domMatch || dowMatch } + +// ldom returns the last day of the month in the specified time object +func ldom(t time.Time) int { + + var ( + eom int + leapYear int + ) + + year := t.Year() + if (year%4 == 0 && year%100 != 0) || year%400 == 0 { + leapYear = 1 + } + + switch t.Month() { + case time.April, time.June, time.September, time.November: + eom = 30 + case time.February: + eom = 28 + leapYear + default: + eom = 31 + } + + return eom +}