parent
662c567db4
commit
e869d999cf
611
cronexpr.go
611
cronexpr.go
|
@ -14,10 +14,8 @@ package cronexpr
|
||||||
/******************************************************************************/
|
/******************************************************************************/
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"regexp"
|
"fmt"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@ -33,12 +31,13 @@ type Expression struct {
|
||||||
daysOfMonth map[int]bool
|
daysOfMonth map[int]bool
|
||||||
workdaysOfMonth map[int]bool
|
workdaysOfMonth map[int]bool
|
||||||
lastDayOfMonth bool
|
lastDayOfMonth bool
|
||||||
|
lastWorkdayOfMonth bool
|
||||||
daysOfMonthRestricted bool
|
daysOfMonthRestricted bool
|
||||||
actualDaysOfMonthList []int
|
actualDaysOfMonthList []int
|
||||||
monthList []int
|
monthList []int
|
||||||
daysOfWeek map[int]bool
|
daysOfWeek map[int]bool
|
||||||
specificWeekdaysOfWeek map[int]bool
|
specificWeekDaysOfWeek map[int]bool
|
||||||
lastWeekdaysOfWeek map[int]bool
|
lastWeekDaysOfWeek map[int]bool
|
||||||
daysOfWeekRestricted bool
|
daysOfWeekRestricted bool
|
||||||
yearList []int
|
yearList []int
|
||||||
}
|
}
|
||||||
|
@ -51,50 +50,90 @@ type Expression struct {
|
||||||
// about what is a well-formed cron expression from this library's point of
|
// about what is a well-formed cron expression from this library's point of
|
||||||
// view.
|
// view.
|
||||||
func MustParse(cronLine string) *Expression {
|
func MustParse(cronLine string) *Expression {
|
||||||
cronLineNormalized := cronNormalize(cronLine)
|
expr, err := Parse(cronLine)
|
||||||
|
if err != nil {
|
||||||
// Split into fields
|
panic(err)
|
||||||
cronFields := regexp.MustCompile(`\s+`).Split(cronLineNormalized, -1)
|
|
||||||
|
|
||||||
// Our cron expression parser expects 7 fields:
|
|
||||||
// second minute hour dayofmonth month dayofweek year
|
|
||||||
// Standard cron is 6 fields with year field being optional
|
|
||||||
// minute hour dayofmonth month dayofweek {year}
|
|
||||||
// Thus...
|
|
||||||
// If we have 5 fields, append wildcard year field
|
|
||||||
if len(cronFields) < 6 {
|
|
||||||
cronFields = append(cronFields, "*")
|
|
||||||
}
|
}
|
||||||
// If we have 6 fields, prepend match-once second field
|
return expr
|
||||||
if len(cronFields) < 7 {
|
|
||||||
cronFields = append(cronFields, "")
|
|
||||||
copy(cronFields[1:], cronFields[0:])
|
|
||||||
cronFields[0] = "0"
|
|
||||||
}
|
|
||||||
// At this point, we should have at least 7 fields. Fields beyond the
|
|
||||||
// seventh one, if any, are ignored.
|
|
||||||
if len(cronFields) < 7 {
|
|
||||||
panic("MustParse(): Not enough fields in the cron time expression\n")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Generic parser can be used for most fields
|
/******************************************************************************/
|
||||||
cronExpr := &Expression{
|
|
||||||
expression: cronLine,
|
// Parse returns a new Expression pointer. An error is returned if a malformed
|
||||||
secondList: genericFieldParse(cronFields[0], 0, 59),
|
// cron expression is supplied.
|
||||||
minuteList: genericFieldParse(cronFields[1], 0, 59),
|
// See <https://github.com/gorhill/cronexpr#implementation> for documentation
|
||||||
hourList: genericFieldParse(cronFields[2], 0, 23),
|
// about what is a well-formed cron expression from this library's point of
|
||||||
monthList: genericFieldParse(cronFields[4], 1, 12),
|
// view.
|
||||||
yearList: genericFieldParse(cronFields[6], 1970, 2099),
|
func Parse(cronLine string) (*Expression, error) {
|
||||||
|
|
||||||
|
// Maybe one of the built-in aliases is being used
|
||||||
|
cron := cronNormalizer.Replace(cronLine)
|
||||||
|
|
||||||
|
indices := fieldFinder.FindAllStringIndex(cron, -1)
|
||||||
|
if len(indices) < 5 {
|
||||||
|
return nil, fmt.Errorf("Missing field(s)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Days of month/days of week is a bit more complicated, due
|
expr := Expression{}
|
||||||
// to their extended syntax, and the fact that days per
|
field := 0
|
||||||
// month is a variable quantity, and relation between
|
|
||||||
// days of week and days of month depends on the month/year.
|
|
||||||
cronExpr.dayofmonthFieldParse(cronFields[3])
|
|
||||||
cronExpr.dayofweekFieldParse(cronFields[5])
|
|
||||||
|
|
||||||
return cronExpr
|
// second field (optional)
|
||||||
|
if len(indices) >= 7 {
|
||||||
|
err := expr.secondFieldHandler(cron[indices[field][0]:indices[field][1]])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
field += 1
|
||||||
|
} else {
|
||||||
|
expr.secondList = []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
// minute field
|
||||||
|
err := expr.minuteFieldHandler(cron[indices[field][0]:indices[field][1]])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
field += 1
|
||||||
|
|
||||||
|
// hour field
|
||||||
|
err = expr.hourFieldHandler(cron[indices[field][0]:indices[field][1]])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
field += 1
|
||||||
|
|
||||||
|
// day of month field
|
||||||
|
err = expr.domFieldHandler(cron[indices[field][0]:indices[field][1]])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
field += 1
|
||||||
|
|
||||||
|
// month field
|
||||||
|
err = expr.monthFieldHandler(cron[indices[field][0]:indices[field][1]])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
field += 1
|
||||||
|
|
||||||
|
// day of week field
|
||||||
|
err = expr.dowFieldHandler(cron[indices[field][0]:indices[field][1]])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
field += 1
|
||||||
|
|
||||||
|
// year field
|
||||||
|
if field < len(indices) {
|
||||||
|
err = expr.yearFieldHandler(cron[indices[field][0]:indices[field][1]])
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
expr.yearList = yearDescriptor.defaultList
|
||||||
|
}
|
||||||
|
|
||||||
|
return &expr, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
/******************************************************************************/
|
/******************************************************************************/
|
||||||
|
@ -200,7 +239,7 @@ func (expr *Expression) Next(fromTime time.Time) time.Time {
|
||||||
// matching time instants exist, the number of returned entries will be less
|
// matching time instants exist, the number of returned entries will be less
|
||||||
// than `n`.
|
// than `n`.
|
||||||
func (expr *Expression) NextN(fromTime time.Time, n uint) []time.Time {
|
func (expr *Expression) NextN(fromTime time.Time, n uint) []time.Time {
|
||||||
nextTimes := make([]time.Time, 0)
|
nextTimes := make([]time.Time, 0, n)
|
||||||
if n > 0 {
|
if n > 0 {
|
||||||
fromTime = expr.Next(fromTime)
|
fromTime = expr.Next(fromTime)
|
||||||
for {
|
for {
|
||||||
|
@ -217,487 +256,3 @@ func (expr *Expression) NextN(fromTime time.Time, n uint) []time.Time {
|
||||||
}
|
}
|
||||||
return nextTimes
|
return nextTimes
|
||||||
}
|
}
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
func (expr *Expression) nextYear(t time.Time) time.Time {
|
|
||||||
// Find index at which item in list is greater or equal to
|
|
||||||
// candidate year
|
|
||||||
i := sort.SearchInts(expr.yearList, t.Year()+1)
|
|
||||||
if i == len(expr.yearList) {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
// Year changed, need to recalculate actual days of month
|
|
||||||
expr.actualDaysOfMonthList = expr.calculateActualDaysOfMonth(expr.yearList[i], expr.monthList[0])
|
|
||||||
if len(expr.actualDaysOfMonthList) == 0 {
|
|
||||||
return expr.nextMonth(time.Date(
|
|
||||||
expr.yearList[i],
|
|
||||||
time.Month(expr.monthList[0]),
|
|
||||||
1,
|
|
||||||
expr.hourList[0],
|
|
||||||
expr.minuteList[0],
|
|
||||||
expr.secondList[0],
|
|
||||||
0,
|
|
||||||
t.Location()))
|
|
||||||
}
|
|
||||||
return time.Date(
|
|
||||||
expr.yearList[i],
|
|
||||||
time.Month(expr.monthList[0]),
|
|
||||||
expr.actualDaysOfMonthList[0],
|
|
||||||
expr.hourList[0],
|
|
||||||
expr.minuteList[0],
|
|
||||||
expr.secondList[0],
|
|
||||||
0,
|
|
||||||
t.Location())
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
func (expr *Expression) nextMonth(t time.Time) time.Time {
|
|
||||||
// Find index at which item in list is greater or equal to
|
|
||||||
// candidate month
|
|
||||||
i := sort.SearchInts(expr.monthList, int(t.Month())+1)
|
|
||||||
if i == len(expr.monthList) {
|
|
||||||
return expr.nextYear(t)
|
|
||||||
}
|
|
||||||
// Month changed, need to recalculate actual days of month
|
|
||||||
expr.actualDaysOfMonthList = expr.calculateActualDaysOfMonth(t.Year(), expr.monthList[i])
|
|
||||||
if len(expr.actualDaysOfMonthList) == 0 {
|
|
||||||
return expr.nextMonth(time.Date(
|
|
||||||
t.Year(),
|
|
||||||
time.Month(expr.monthList[i]),
|
|
||||||
1,
|
|
||||||
expr.hourList[0],
|
|
||||||
expr.minuteList[0],
|
|
||||||
expr.secondList[0],
|
|
||||||
0,
|
|
||||||
t.Location()))
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Date(
|
|
||||||
t.Year(),
|
|
||||||
time.Month(expr.monthList[i]),
|
|
||||||
expr.actualDaysOfMonthList[0],
|
|
||||||
expr.hourList[0],
|
|
||||||
expr.minuteList[0],
|
|
||||||
expr.secondList[0],
|
|
||||||
0,
|
|
||||||
t.Location())
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
func (expr *Expression) calculateActualDaysOfMonth(year, month int) []int {
|
|
||||||
actualDaysOfMonthMap := make(map[int]bool)
|
|
||||||
timeOrigin := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
|
||||||
lastDayOfMonth := timeOrigin.AddDate(0, 1, -1).Day()
|
|
||||||
|
|
||||||
// As per crontab man page (http://linux.die.net/man/5/crontab#):
|
|
||||||
// "The day of a command's execution can be specified by two
|
|
||||||
// "fields - day of month, and day of week. If both fields are
|
|
||||||
// "restricted (ie, aren't *), the command will be run when
|
|
||||||
// "either field matches the current time"
|
|
||||||
if expr.daysOfMonthRestricted || expr.daysOfWeekRestricted == false {
|
|
||||||
// Last day of month
|
|
||||||
if expr.lastDayOfMonth {
|
|
||||||
actualDaysOfMonthMap[lastDayOfMonth] = true
|
|
||||||
}
|
|
||||||
// Days of month
|
|
||||||
for v := range expr.daysOfMonth {
|
|
||||||
// Ignore days beyond end of month
|
|
||||||
if v <= lastDayOfMonth {
|
|
||||||
actualDaysOfMonthMap[v] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Work days of month
|
|
||||||
// As per Wikipedia: month boundaries are not crossed.
|
|
||||||
for v := range expr.workdaysOfMonth {
|
|
||||||
// Ignore days beyond end of month
|
|
||||||
if v <= lastDayOfMonth {
|
|
||||||
// If saturday, then friday
|
|
||||||
if timeOrigin.AddDate(0, 0, v-1).Weekday() == time.Saturday {
|
|
||||||
if v > 1 {
|
|
||||||
v -= 1
|
|
||||||
} else {
|
|
||||||
v += 2
|
|
||||||
}
|
|
||||||
// If sunday, then monday
|
|
||||||
} else if timeOrigin.AddDate(0, 0, v-1).Weekday() == time.Sunday {
|
|
||||||
if v < lastDayOfMonth {
|
|
||||||
v += 1
|
|
||||||
} else {
|
|
||||||
v -= 2
|
|
||||||
}
|
|
||||||
}
|
|
||||||
actualDaysOfMonthMap[v] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if expr.daysOfWeekRestricted {
|
|
||||||
// How far first sunday is from first day of month
|
|
||||||
offset := 7 - int(timeOrigin.Weekday())
|
|
||||||
// days of week
|
|
||||||
// offset : (7 - day_of_week_of_1st_day_of_month)
|
|
||||||
// target : 1 + (7 * week_of_month) + (offset + day_of_week) % 7
|
|
||||||
for w := 0; w <= 4; w += 1 {
|
|
||||||
for v := range expr.daysOfWeek {
|
|
||||||
v := 1 + w*7 + (offset+v)%7
|
|
||||||
if v <= lastDayOfMonth {
|
|
||||||
actualDaysOfMonthMap[v] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// days of week of specific week in the month
|
|
||||||
// offset : (7 - day_of_week_of_1st_day_of_month)
|
|
||||||
// target : 1 + (7 * week_of_month) + (offset + day_of_week) % 7
|
|
||||||
for v := range expr.specificWeekdaysOfWeek {
|
|
||||||
v := 1 + 7*(v/7) + (offset+v)%7
|
|
||||||
if v <= lastDayOfMonth {
|
|
||||||
actualDaysOfMonthMap[v] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Last days of week of the month
|
|
||||||
lastWeekOrigin := timeOrigin.AddDate(0, 1, -7)
|
|
||||||
offset = 7 - int(lastWeekOrigin.Weekday())
|
|
||||||
for v := range expr.lastWeekdaysOfWeek {
|
|
||||||
v := lastWeekOrigin.Day() + (offset+v)%7
|
|
||||||
if v <= lastDayOfMonth {
|
|
||||||
actualDaysOfMonthMap[v] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return toList(actualDaysOfMonthMap)
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
func (expr *Expression) nextDayOfMonth(t time.Time) time.Time {
|
|
||||||
// Find index at which item in list is greater or equal to
|
|
||||||
// candidate day of month
|
|
||||||
i := sort.SearchInts(expr.actualDaysOfMonthList, t.Day()+1)
|
|
||||||
if i == len(expr.actualDaysOfMonthList) {
|
|
||||||
return expr.nextMonth(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Date(
|
|
||||||
t.Year(),
|
|
||||||
t.Month(),
|
|
||||||
expr.actualDaysOfMonthList[i],
|
|
||||||
expr.hourList[0],
|
|
||||||
expr.minuteList[0],
|
|
||||||
expr.secondList[0],
|
|
||||||
0,
|
|
||||||
t.Location())
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
func (expr *Expression) nextHour(t time.Time) time.Time {
|
|
||||||
// Find index at which item in list is greater or equal to
|
|
||||||
// candidate hour
|
|
||||||
i := sort.SearchInts(expr.hourList, t.Hour()+1)
|
|
||||||
if i == len(expr.hourList) {
|
|
||||||
return expr.nextDayOfMonth(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Date(
|
|
||||||
t.Year(),
|
|
||||||
t.Month(),
|
|
||||||
t.Day(),
|
|
||||||
expr.hourList[i],
|
|
||||||
expr.minuteList[0],
|
|
||||||
expr.secondList[0],
|
|
||||||
0,
|
|
||||||
t.Location())
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
func (expr *Expression) nextMinute(t time.Time) time.Time {
|
|
||||||
// Find index at which item in list is greater or equal to
|
|
||||||
// candidate minute
|
|
||||||
i := sort.SearchInts(expr.minuteList, t.Minute()+1)
|
|
||||||
if i == len(expr.minuteList) {
|
|
||||||
return expr.nextHour(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Date(
|
|
||||||
t.Year(),
|
|
||||||
t.Month(),
|
|
||||||
t.Day(),
|
|
||||||
t.Hour(),
|
|
||||||
expr.minuteList[i],
|
|
||||||
expr.secondList[0],
|
|
||||||
0,
|
|
||||||
t.Location())
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
func (expr *Expression) nextSecond(t time.Time) time.Time {
|
|
||||||
// nextSecond() assumes all other fields are exactly matched
|
|
||||||
// to the cron expression
|
|
||||||
|
|
||||||
// Find index at which item in list is greater or equal to
|
|
||||||
// candidate second
|
|
||||||
i := sort.SearchInts(expr.secondList, t.Second()+1)
|
|
||||||
if i == len(expr.secondList) {
|
|
||||||
return expr.nextMinute(t)
|
|
||||||
}
|
|
||||||
|
|
||||||
return time.Date(
|
|
||||||
t.Year(),
|
|
||||||
t.Month(),
|
|
||||||
t.Day(),
|
|
||||||
t.Hour(),
|
|
||||||
t.Minute(),
|
|
||||||
expr.secondList[i],
|
|
||||||
0,
|
|
||||||
t.Location())
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
var cronNormalizer = strings.NewReplacer(
|
|
||||||
// Order is important!
|
|
||||||
"@yearly", "0 0 0 1 1 * *",
|
|
||||||
"@annually", "0 0 0 1 1 * *",
|
|
||||||
"@monthly", "0 0 0 1 * * *",
|
|
||||||
"@weekly", "0 0 0 * * 0 *",
|
|
||||||
"@daily", "0 0 0 * * * *",
|
|
||||||
"@hourly", "0 0 * * * * *",
|
|
||||||
"january", "1",
|
|
||||||
"february", "2",
|
|
||||||
"march", "3",
|
|
||||||
"april", "4",
|
|
||||||
"may", "5",
|
|
||||||
"june", "6",
|
|
||||||
"july", "7",
|
|
||||||
"august", "8",
|
|
||||||
"september", "9",
|
|
||||||
"october", "0",
|
|
||||||
"november", "1",
|
|
||||||
"december", "2",
|
|
||||||
"sunday", "0",
|
|
||||||
"monday", "1",
|
|
||||||
"tuesday", "2",
|
|
||||||
"wednesday", "3",
|
|
||||||
"thursday", "4",
|
|
||||||
"friday", "5",
|
|
||||||
"saturday", "6",
|
|
||||||
"jan", "1",
|
|
||||||
"feb", "2",
|
|
||||||
"mar", "3",
|
|
||||||
"apr", "4",
|
|
||||||
"jun", "6",
|
|
||||||
"jul", "7",
|
|
||||||
"aug", "8",
|
|
||||||
"sep", "9",
|
|
||||||
"oct", "10",
|
|
||||||
"nov", "11",
|
|
||||||
"dec", "12",
|
|
||||||
"sun", "0",
|
|
||||||
"mon", "1",
|
|
||||||
"tue", "2",
|
|
||||||
"wed", "3",
|
|
||||||
"thu", "4",
|
|
||||||
"fri", "5",
|
|
||||||
"sat", "6",
|
|
||||||
"?", "*")
|
|
||||||
|
|
||||||
func cronNormalize(cronLine string) string {
|
|
||||||
cronLine = strings.TrimSpace(cronLine)
|
|
||||||
cronLine = strings.ToLower(cronLine)
|
|
||||||
cronLine = cronNormalizer.Replace(cronLine)
|
|
||||||
return cronLine
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
func (expr *Expression) dayofweekFieldParse(cronField string) {
|
|
||||||
// Defaults
|
|
||||||
expr.daysOfWeekRestricted = true
|
|
||||||
expr.daysOfWeek = make(map[int]bool)
|
|
||||||
expr.lastWeekdaysOfWeek = make(map[int]bool)
|
|
||||||
expr.specificWeekdaysOfWeek = make(map[int]bool)
|
|
||||||
|
|
||||||
// "You can also mix all of the above, as in: 1-5,10,12,20-30/5"
|
|
||||||
cronList := strings.Split(cronField, ",")
|
|
||||||
for _, s := range cronList {
|
|
||||||
// "/"
|
|
||||||
step, s := extractInterval(s)
|
|
||||||
// "*"
|
|
||||||
if s == "*" {
|
|
||||||
expr.daysOfWeekRestricted = (step > 1)
|
|
||||||
populateMany(expr.daysOfWeek, 0, 6, step)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// "-"
|
|
||||||
// week day interval for all weeks
|
|
||||||
i := strings.Index(s, "-")
|
|
||||||
if i >= 0 {
|
|
||||||
min := atoi(s[:i]) % 7
|
|
||||||
max := atoi(s[i+1:]) % 7
|
|
||||||
populateMany(expr.daysOfWeek, min, max, step)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// single value
|
|
||||||
// "l": week day for last week
|
|
||||||
i = strings.Index(s, "l")
|
|
||||||
if i >= 0 {
|
|
||||||
populateOne(expr.lastWeekdaysOfWeek, atoi(s[:i])%7)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// "#": week day for specific week
|
|
||||||
i = strings.Index(s, "#")
|
|
||||||
if i >= 0 {
|
|
||||||
// v#w
|
|
||||||
v := atoi(s[:i]) % 7
|
|
||||||
w := atoi(s[i+1:])
|
|
||||||
// v domain = [0,7]
|
|
||||||
// w domain = [1,5]
|
|
||||||
populateOne(expr.specificWeekdaysOfWeek, (w-1)*7+(v%7))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// week day interval for all weeks
|
|
||||||
if step > 0 {
|
|
||||||
v := atoi(s) % 7
|
|
||||||
populateMany(expr.daysOfWeek, v, 6, step)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// single week day for all weeks
|
|
||||||
v := atoi(s) % 7
|
|
||||||
populateOne(expr.daysOfWeek, v)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
func (expr *Expression) dayofmonthFieldParse(cronField string) {
|
|
||||||
// Defaults
|
|
||||||
expr.daysOfMonthRestricted = true
|
|
||||||
expr.lastDayOfMonth = false
|
|
||||||
|
|
||||||
expr.daysOfMonth = make(map[int]bool) // days of month map
|
|
||||||
expr.workdaysOfMonth = make(map[int]bool) // work day of month map
|
|
||||||
|
|
||||||
// Comma separator is used to mix different allowed syntax
|
|
||||||
cronList := strings.Split(cronField, ",")
|
|
||||||
for _, s := range cronList {
|
|
||||||
// "/"
|
|
||||||
step, s := extractInterval(s)
|
|
||||||
// "*"
|
|
||||||
if s == "*" {
|
|
||||||
expr.daysOfMonthRestricted = (step > 1)
|
|
||||||
populateMany(expr.daysOfMonth, 1, 31, step)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// "-"
|
|
||||||
i := strings.Index(s, "-")
|
|
||||||
if i >= 0 {
|
|
||||||
populateMany(expr.daysOfMonth, atoi(s[:i]), atoi(s[i+1:]), step)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// single value
|
|
||||||
// "l": last day of month
|
|
||||||
if s == "l" {
|
|
||||||
expr.lastDayOfMonth = true
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// "w": week day
|
|
||||||
i = strings.Index(s, "w")
|
|
||||||
if i >= 0 {
|
|
||||||
populateOne(expr.workdaysOfMonth, atoi(s[:i]))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// single value with interval
|
|
||||||
if step > 0 {
|
|
||||||
populateMany(expr.daysOfMonth, atoi(s), 31, step)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// single value
|
|
||||||
populateOne(expr.daysOfMonth, atoi(s))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
func genericFieldParse(cronField string, min, max int) []int {
|
|
||||||
// Defaults
|
|
||||||
values := make(map[int]bool)
|
|
||||||
|
|
||||||
// Comma separator is used to mix different allowed syntax
|
|
||||||
cronList := strings.Split(cronField, ",")
|
|
||||||
for _, s := range cronList {
|
|
||||||
// "/"
|
|
||||||
step, s := extractInterval(s)
|
|
||||||
// "*"
|
|
||||||
if s == "*" {
|
|
||||||
populateMany(values, min, max, step)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// "-"
|
|
||||||
i := strings.Index(s, "-")
|
|
||||||
if i >= 0 {
|
|
||||||
populateMany(values, atoi(s[:i]), atoi(s[i+1:]), step)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// single value with interval
|
|
||||||
if step > 0 {
|
|
||||||
populateMany(values, atoi(s), max, step)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
// single value
|
|
||||||
populateOne(values, atoi(s))
|
|
||||||
}
|
|
||||||
|
|
||||||
return toList(values)
|
|
||||||
}
|
|
||||||
|
|
||||||
/******************************************************************************/
|
|
||||||
|
|
||||||
// Local helpers
|
|
||||||
|
|
||||||
func extractInterval(s string) (int, string) {
|
|
||||||
step := 0
|
|
||||||
i := strings.Index(s, "/")
|
|
||||||
if i >= 0 {
|
|
||||||
step = atoi(s[i+1:])
|
|
||||||
s = s[:i]
|
|
||||||
}
|
|
||||||
return step, s
|
|
||||||
}
|
|
||||||
|
|
||||||
func atoi(s string) int {
|
|
||||||
v, err := strconv.Atoi(s)
|
|
||||||
if err != nil {
|
|
||||||
panic(err)
|
|
||||||
}
|
|
||||||
return v
|
|
||||||
}
|
|
||||||
|
|
||||||
func populateOne(values map[int]bool, v int) {
|
|
||||||
values[v] = true
|
|
||||||
}
|
|
||||||
|
|
||||||
func populateMany(values map[int]bool, min, max, step int) {
|
|
||||||
if step == 0 {
|
|
||||||
step = 1
|
|
||||||
}
|
|
||||||
for i := min; i <= max; i += step {
|
|
||||||
values[i] = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func toList(set map[int]bool) []int {
|
|
||||||
list := make([]int, len(set))
|
|
||||||
i := 0
|
|
||||||
for k := range set {
|
|
||||||
list[i] = k
|
|
||||||
i += 1
|
|
||||||
}
|
|
||||||
sort.Ints(list)
|
|
||||||
return list
|
|
||||||
}
|
|
||||||
|
|
|
@ -0,0 +1,739 @@
|
||||||
|
/*!
|
||||||
|
* Copyright 2013 Raymond Hill
|
||||||
|
*
|
||||||
|
* Project: github.com/gorhill/cronexpr
|
||||||
|
* File: cronexpr_impl.go
|
||||||
|
* Version: 1.0
|
||||||
|
* License: GPL v3 see <https://www.gnu.org/licenses/gpl.html>
|
||||||
|
*
|
||||||
|
*/
|
||||||
|
|
||||||
|
package cronexpr
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
var (
|
||||||
|
genericDefaultList []int = []int{
|
||||||
|
0, 1, 2, 3, 4, 5, 6, 7, 8, 9,
|
||||||
|
10, 11, 12, 13, 14, 15, 16, 17, 18, 19,
|
||||||
|
20, 21, 22, 23, 24, 25, 26, 27, 28, 29,
|
||||||
|
30, 31, 32, 33, 34, 35, 36, 37, 38, 39,
|
||||||
|
40, 41, 42, 43, 44, 45, 46, 47, 48, 49,
|
||||||
|
50, 51, 52, 53, 54, 55, 56, 57, 58, 59,
|
||||||
|
}
|
||||||
|
yearDefaultList []int = []int{
|
||||||
|
1970, 1971, 1972, 1973, 1974, 1975, 1976, 1977, 1978, 1979,
|
||||||
|
1980, 1981, 1982, 1983, 1984, 1985, 1986, 1987, 1988, 1989,
|
||||||
|
1990, 1991, 1992, 1993, 1994, 1995, 1996, 1997, 1998, 1999,
|
||||||
|
2000, 2001, 2002, 2003, 2004, 2005, 2006, 2007, 2008, 2009,
|
||||||
|
2010, 2011, 2012, 2013, 2014, 2015, 2016, 2017, 2018, 2019,
|
||||||
|
2020, 2021, 2022, 2023, 2024, 2025, 2026, 2027, 2028, 2029,
|
||||||
|
2030, 2031, 2032, 2033, 2034, 2035, 2036, 2037, 2038, 2039,
|
||||||
|
2040, 2041, 2042, 2043, 2044, 2045, 2046, 2047, 2048, 2049,
|
||||||
|
2050, 2051, 2052, 2053, 2054, 2055, 2056, 2057, 2058, 2059,
|
||||||
|
2060, 2061, 2062, 2063, 2064, 2065, 2066, 2067, 2068, 2069,
|
||||||
|
2070, 2071, 2072, 2073, 2074, 2075, 2076, 2077, 2078, 2079,
|
||||||
|
2080, 2081, 2082, 2083, 2084, 2085, 2086, 2087, 2088, 2089,
|
||||||
|
2090, 2091, 2092, 2093, 2094, 2095, 2096, 2097, 2098, 2099,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
var (
|
||||||
|
numberTokens map[string]int = map[string]int{
|
||||||
|
"0": 0, "1": 1, "2": 2, "3": 3, "4": 4, "5": 5, "6": 6, "7": 7, "8": 8, "9": 9,
|
||||||
|
"10": 10, "11": 11, "12": 12, "13": 13, "14": 14, "15": 15, "16": 16, "17": 17, "18": 18, "19": 19,
|
||||||
|
"20": 20, "21": 21, "22": 22, "23": 23, "24": 24, "25": 25, "26": 26, "27": 27, "28": 28, "29": 29,
|
||||||
|
"30": 30, "31": 31, "32": 32, "33": 33, "34": 34, "35": 35, "36": 36, "37": 37, "38": 38, "39": 39,
|
||||||
|
"40": 40, "41": 41, "42": 42, "43": 43, "44": 44, "45": 45, "46": 46, "47": 47, "48": 48, "49": 49,
|
||||||
|
"50": 50, "51": 51, "52": 52, "53": 53, "54": 54, "55": 55, "56": 56, "57": 57, "58": 58, "59": 59,
|
||||||
|
"1970": 1970, "1971": 1971, "1972": 1972, "1973": 1973, "1974": 1974, "1975": 1975, "1976": 1976, "1977": 1977, "1978": 1978, "1979": 1979,
|
||||||
|
"1980": 1980, "1981": 1981, "1982": 1982, "1983": 1983, "1984": 1984, "1985": 1985, "1986": 1986, "1987": 1987, "1988": 1988, "1989": 1989,
|
||||||
|
"1990": 1990, "1991": 1991, "1992": 1992, "1993": 1993, "1994": 1994, "1995": 1995, "1996": 1996, "1997": 1997, "1998": 1998, "1999": 1999,
|
||||||
|
"2000": 2000, "2001": 2001, "2002": 2002, "2003": 2003, "2004": 2004, "2005": 2005, "2006": 2006, "2007": 2007, "2008": 2008, "2009": 2009,
|
||||||
|
"2010": 2010, "2011": 2011, "2012": 2012, "2013": 2013, "2014": 2014, "2015": 2015, "2016": 2016, "2017": 2017, "2018": 2018, "2019": 2019,
|
||||||
|
"2020": 2020, "2021": 2021, "2022": 2022, "2023": 2023, "2024": 2024, "2025": 2025, "2026": 2026, "2027": 2027, "2028": 2028, "2029": 2029,
|
||||||
|
"2030": 2030, "2031": 2031, "2032": 2032, "2033": 2033, "2034": 2034, "2035": 2035, "2036": 2036, "2037": 2037, "2038": 2038, "2039": 2039,
|
||||||
|
"2040": 2040, "2041": 2041, "2042": 2042, "2043": 2043, "2044": 2044, "2045": 2045, "2046": 2046, "2047": 2047, "2048": 2048, "2049": 2049,
|
||||||
|
"2050": 2050, "2051": 2051, "2052": 2052, "2053": 2053, "2054": 2054, "2055": 2055, "2056": 2056, "2057": 2057, "2058": 2058, "2059": 2059,
|
||||||
|
"2060": 2060, "2061": 2061, "2062": 2062, "2063": 2063, "2064": 2064, "2065": 2065, "2066": 2066, "2067": 2067, "2068": 2068, "2069": 2069,
|
||||||
|
"2070": 2070, "2071": 2071, "2072": 2072, "2073": 2073, "2074": 2074, "2075": 2075, "2076": 2076, "2077": 2077, "2078": 2078, "2079": 2079,
|
||||||
|
"2080": 2080, "2081": 2081, "2082": 2082, "2083": 2083, "2084": 2084, "2085": 2085, "2086": 2086, "2087": 2087, "2088": 2088, "2089": 2089,
|
||||||
|
"2090": 2090, "2091": 2091, "2092": 2092, "2093": 2093, "2094": 2094, "2095": 2095, "2096": 2096, "2097": 2097, "2098": 2098, "2099": 2099,
|
||||||
|
}
|
||||||
|
monthTokens map[string]int = map[string]int{
|
||||||
|
`1`: 1, `jan`: 1, `january`: 1,
|
||||||
|
`2`: 2, `feb`: 2, `february`: 2,
|
||||||
|
`3`: 3, `mar`: 3, `march`: 3,
|
||||||
|
`4`: 4, `apr`: 4, `april`: 4,
|
||||||
|
`5`: 5, `may`: 5,
|
||||||
|
`6`: 6, `jun`: 6, `june`: 6,
|
||||||
|
`7`: 7, `jul`: 7, `july`: 7,
|
||||||
|
`8`: 8, `aug`: 8, `august`: 8,
|
||||||
|
`9`: 9, `sep`: 9, `september`: 9,
|
||||||
|
`10`: 10, `oct`: 10, `october`: 10,
|
||||||
|
`11`: 11, `nov`: 11, `november`: 11,
|
||||||
|
`12`: 12, `dec`: 12, `december`: 12,
|
||||||
|
}
|
||||||
|
dowTokens map[string]int = map[string]int{
|
||||||
|
`0`: 0, `sun`: 0, `sunday`: 0,
|
||||||
|
`1`: 1, `mon`: 1, `monday`: 1,
|
||||||
|
`2`: 2, `tue`: 2, `tuesday`: 2,
|
||||||
|
`3`: 3, `wed`: 3, `wednesday`: 3,
|
||||||
|
`4`: 4, `thu`: 4, `thursday`: 4,
|
||||||
|
`5`: 5, `fri`: 5, `friday`: 5,
|
||||||
|
`6`: 6, `sat`: 6, `saturday`: 6,
|
||||||
|
`7`: 0,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func atoi(s string) int {
|
||||||
|
return numberTokens[s]
|
||||||
|
}
|
||||||
|
|
||||||
|
type fieldDescriptor struct {
|
||||||
|
name string
|
||||||
|
min, max int
|
||||||
|
defaultList []int
|
||||||
|
valuePattern string
|
||||||
|
atoi func(string) int
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
secondDescriptor fieldDescriptor = fieldDescriptor{
|
||||||
|
name: "second",
|
||||||
|
min: 0,
|
||||||
|
max: 59,
|
||||||
|
defaultList: genericDefaultList[0:60],
|
||||||
|
valuePattern: `[0-9]|[1-5][0-9]`,
|
||||||
|
atoi: atoi,
|
||||||
|
}
|
||||||
|
minuteDescriptor fieldDescriptor = fieldDescriptor{
|
||||||
|
name: "minute",
|
||||||
|
min: 0,
|
||||||
|
max: 59,
|
||||||
|
defaultList: genericDefaultList[0:60],
|
||||||
|
valuePattern: `[0-9]|[1-5][0-9]`,
|
||||||
|
atoi: atoi,
|
||||||
|
}
|
||||||
|
hourDescriptor fieldDescriptor = fieldDescriptor{
|
||||||
|
name: "hour",
|
||||||
|
min: 0,
|
||||||
|
max: 23,
|
||||||
|
defaultList: genericDefaultList[0:24],
|
||||||
|
valuePattern: `[0-9]|1[0-9]|2[0-3]`,
|
||||||
|
atoi: atoi,
|
||||||
|
}
|
||||||
|
domDescriptor fieldDescriptor = fieldDescriptor{
|
||||||
|
name: "day-of-month",
|
||||||
|
min: 1,
|
||||||
|
max: 31,
|
||||||
|
defaultList: genericDefaultList[1:32],
|
||||||
|
valuePattern: `[1-9]|[12][0-9]|3[01]`,
|
||||||
|
atoi: atoi,
|
||||||
|
}
|
||||||
|
monthDescriptor fieldDescriptor = fieldDescriptor{
|
||||||
|
name: "month",
|
||||||
|
min: 1,
|
||||||
|
max: 12,
|
||||||
|
defaultList: genericDefaultList[1:13],
|
||||||
|
valuePattern: `[1-9]|1[012]|jan|feb|mar|apr|may|jun|jul|aug|sep|oct|nov|dec|january|february|march|april|march|april|june|july|august|september|october|november|december`,
|
||||||
|
atoi: func(s string) int {
|
||||||
|
return monthTokens[s]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
dowDescriptor fieldDescriptor = fieldDescriptor{
|
||||||
|
name: "day-of-week",
|
||||||
|
min: 0,
|
||||||
|
max: 6,
|
||||||
|
defaultList: genericDefaultList[0:7],
|
||||||
|
valuePattern: `[0-7]|sun|mon|tue|wed|thu|fri|sat|sunday|monday|tuesday|wednesday|thursday|friday|saturday`,
|
||||||
|
atoi: func(s string) int {
|
||||||
|
return dowTokens[s]
|
||||||
|
},
|
||||||
|
}
|
||||||
|
yearDescriptor fieldDescriptor = fieldDescriptor{
|
||||||
|
name: "year",
|
||||||
|
min: 1970,
|
||||||
|
max: 2099,
|
||||||
|
defaultList: yearDefaultList[:],
|
||||||
|
valuePattern: `19[789][0-9]|20[0-9]{2}`,
|
||||||
|
atoi: atoi,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
var (
|
||||||
|
layoutDomValue string = `[1-9]|[12][0-9]|3[01]`
|
||||||
|
layoutDowValue string = `[0-7]|sun|mon|tue|wed|thu|fri|sat|sunday|monday|tuesday|wednesday|thursday|friday|saturday`
|
||||||
|
layoutWildcard string = `^\*|\?$`
|
||||||
|
layoutValue string = `^(%value%)$`
|
||||||
|
layoutRange string = `^(%value%)-(%value%)$`
|
||||||
|
layoutWildcardAndInterval string = `^\*/(\d+)$`
|
||||||
|
layoutValueAndInterval string = `^(%value%)/(\d+)$`
|
||||||
|
layoutRangeAndInterval string = `^(%value%)-(%value%)/(\d+)$`
|
||||||
|
layoutLastDom string = `^l$`
|
||||||
|
layoutWorkdom string = `^(%value%)w$`
|
||||||
|
layoutLastWorkdom string = `^lw$`
|
||||||
|
layoutDowOfLastWeek string = `^(%value%)l$`
|
||||||
|
layoutDowOfSpecificWeek string = `^(%value%)#(\d+)$`
|
||||||
|
|
||||||
|
layoutRegexp map[string]*regexp.Regexp = make(map[string]*regexp.Regexp)
|
||||||
|
fieldFinder *regexp.Regexp = regexp.MustCompile(`\S+`)
|
||||||
|
entryFinder *regexp.Regexp = regexp.MustCompile(`[^,]+`)
|
||||||
|
)
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
var cronNormalizer = strings.NewReplacer(
|
||||||
|
"@yearly", "0 0 0 1 1 * *",
|
||||||
|
"@annually", "0 0 0 1 1 * *",
|
||||||
|
"@monthly", "0 0 0 1 * * *",
|
||||||
|
"@weekly", "0 0 0 * * 0 *",
|
||||||
|
"@daily", "0 0 0 * * * *",
|
||||||
|
"@hourly", "0 0 * * * * *")
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func (expr *Expression) nextYear(t time.Time) time.Time {
|
||||||
|
// Find index at which item in list is greater or equal to
|
||||||
|
// candidate year
|
||||||
|
i := sort.SearchInts(expr.yearList, t.Year()+1)
|
||||||
|
if i == len(expr.yearList) {
|
||||||
|
return time.Time{}
|
||||||
|
}
|
||||||
|
// Year changed, need to recalculate actual days of month
|
||||||
|
expr.actualDaysOfMonthList = expr.calculateActualDaysOfMonth(expr.yearList[i], expr.monthList[0])
|
||||||
|
if len(expr.actualDaysOfMonthList) == 0 {
|
||||||
|
return expr.nextMonth(time.Date(
|
||||||
|
expr.yearList[i],
|
||||||
|
time.Month(expr.monthList[0]),
|
||||||
|
1,
|
||||||
|
expr.hourList[0],
|
||||||
|
expr.minuteList[0],
|
||||||
|
expr.secondList[0],
|
||||||
|
0,
|
||||||
|
t.Location()))
|
||||||
|
}
|
||||||
|
return time.Date(
|
||||||
|
expr.yearList[i],
|
||||||
|
time.Month(expr.monthList[0]),
|
||||||
|
expr.actualDaysOfMonthList[0],
|
||||||
|
expr.hourList[0],
|
||||||
|
expr.minuteList[0],
|
||||||
|
expr.secondList[0],
|
||||||
|
0,
|
||||||
|
t.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func (expr *Expression) nextMonth(t time.Time) time.Time {
|
||||||
|
// Find index at which item in list is greater or equal to
|
||||||
|
// candidate month
|
||||||
|
i := sort.SearchInts(expr.monthList, int(t.Month())+1)
|
||||||
|
if i == len(expr.monthList) {
|
||||||
|
return expr.nextYear(t)
|
||||||
|
}
|
||||||
|
// Month changed, need to recalculate actual days of month
|
||||||
|
expr.actualDaysOfMonthList = expr.calculateActualDaysOfMonth(t.Year(), expr.monthList[i])
|
||||||
|
if len(expr.actualDaysOfMonthList) == 0 {
|
||||||
|
return expr.nextMonth(time.Date(
|
||||||
|
t.Year(),
|
||||||
|
time.Month(expr.monthList[i]),
|
||||||
|
1,
|
||||||
|
expr.hourList[0],
|
||||||
|
expr.minuteList[0],
|
||||||
|
expr.secondList[0],
|
||||||
|
0,
|
||||||
|
t.Location()))
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Date(
|
||||||
|
t.Year(),
|
||||||
|
time.Month(expr.monthList[i]),
|
||||||
|
expr.actualDaysOfMonthList[0],
|
||||||
|
expr.hourList[0],
|
||||||
|
expr.minuteList[0],
|
||||||
|
expr.secondList[0],
|
||||||
|
0,
|
||||||
|
t.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func workdayOfMonth(targetDom, lastDom time.Time) int {
|
||||||
|
dom := targetDom.Day()
|
||||||
|
dow := targetDom.Weekday()
|
||||||
|
// If saturday, then friday
|
||||||
|
if dow == time.Saturday {
|
||||||
|
if dom > 1 {
|
||||||
|
dom -= 1
|
||||||
|
} else {
|
||||||
|
dom += 2
|
||||||
|
}
|
||||||
|
// If sunday, then monday
|
||||||
|
} else if dow == time.Sunday {
|
||||||
|
if dom < lastDom.Day() {
|
||||||
|
dom += 1
|
||||||
|
} else {
|
||||||
|
dom -= 2
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dom
|
||||||
|
}
|
||||||
|
|
||||||
|
func (expr *Expression) calculateActualDaysOfMonth(year, month int) []int {
|
||||||
|
actualDaysOfMonthMap := make(map[int]bool)
|
||||||
|
firstDayOfMonth := time.Date(year, time.Month(month), 1, 0, 0, 0, 0, time.UTC)
|
||||||
|
lastDayOfMonth := firstDayOfMonth.AddDate(0, 1, -1)
|
||||||
|
|
||||||
|
// As per crontab man page (http://linux.die.net/man/5/crontab#):
|
||||||
|
// "The day of a command's execution can be specified by two
|
||||||
|
// "fields - day of month, and day of week. If both fields are
|
||||||
|
// "restricted (ie, aren't *), the command will be run when
|
||||||
|
// "either field matches the current time"
|
||||||
|
if expr.daysOfMonthRestricted || expr.daysOfWeekRestricted == false {
|
||||||
|
// Last day of month
|
||||||
|
if expr.lastDayOfMonth {
|
||||||
|
actualDaysOfMonthMap[lastDayOfMonth.Day()] = true
|
||||||
|
}
|
||||||
|
// Last work day of month
|
||||||
|
if expr.lastWorkdayOfMonth {
|
||||||
|
actualDaysOfMonthMap[workdayOfMonth(lastDayOfMonth, lastDayOfMonth)] = true
|
||||||
|
}
|
||||||
|
// Days of month
|
||||||
|
for v := range expr.daysOfMonth {
|
||||||
|
// Ignore days beyond end of month
|
||||||
|
if v <= lastDayOfMonth.Day() {
|
||||||
|
actualDaysOfMonthMap[v] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Work days of month
|
||||||
|
// As per Wikipedia: month boundaries are not crossed.
|
||||||
|
for v := range expr.workdaysOfMonth {
|
||||||
|
// Ignore days beyond end of month
|
||||||
|
if v <= lastDayOfMonth.Day() {
|
||||||
|
actualDaysOfMonthMap[workdayOfMonth(firstDayOfMonth.AddDate(0, 0, v-1), lastDayOfMonth)] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if expr.daysOfWeekRestricted {
|
||||||
|
// How far first sunday is from first day of month
|
||||||
|
offset := 7 - int(firstDayOfMonth.Weekday())
|
||||||
|
// days of week
|
||||||
|
// offset : (7 - day_of_week_of_1st_day_of_month)
|
||||||
|
// target : 1 + (7 * week_of_month) + (offset + day_of_week) % 7
|
||||||
|
for w := 0; w <= 4; w += 1 {
|
||||||
|
for v := range expr.daysOfWeek {
|
||||||
|
v := 1 + w*7 + (offset+v)%7
|
||||||
|
if v <= lastDayOfMonth.Day() {
|
||||||
|
actualDaysOfMonthMap[v] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// days of week of specific week in the month
|
||||||
|
// offset : (7 - day_of_week_of_1st_day_of_month)
|
||||||
|
// target : 1 + (7 * week_of_month) + (offset + day_of_week) % 7
|
||||||
|
for v := range expr.specificWeekDaysOfWeek {
|
||||||
|
v := 1 + 7*(v/7) + (offset+v)%7
|
||||||
|
if v <= lastDayOfMonth.Day() {
|
||||||
|
actualDaysOfMonthMap[v] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Last days of week of the month
|
||||||
|
lastWeekOrigin := firstDayOfMonth.AddDate(0, 1, -7)
|
||||||
|
offset = 7 - int(lastWeekOrigin.Weekday())
|
||||||
|
for v := range expr.lastWeekDaysOfWeek {
|
||||||
|
v := lastWeekOrigin.Day() + (offset+v)%7
|
||||||
|
if v <= lastDayOfMonth.Day() {
|
||||||
|
actualDaysOfMonthMap[v] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return toList(actualDaysOfMonthMap)
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func (expr *Expression) nextDayOfMonth(t time.Time) time.Time {
|
||||||
|
// Find index at which item in list is greater or equal to
|
||||||
|
// candidate day of month
|
||||||
|
i := sort.SearchInts(expr.actualDaysOfMonthList, t.Day()+1)
|
||||||
|
if i == len(expr.actualDaysOfMonthList) {
|
||||||
|
return expr.nextMonth(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Date(
|
||||||
|
t.Year(),
|
||||||
|
t.Month(),
|
||||||
|
expr.actualDaysOfMonthList[i],
|
||||||
|
expr.hourList[0],
|
||||||
|
expr.minuteList[0],
|
||||||
|
expr.secondList[0],
|
||||||
|
0,
|
||||||
|
t.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func (expr *Expression) nextHour(t time.Time) time.Time {
|
||||||
|
// Find index at which item in list is greater or equal to
|
||||||
|
// candidate hour
|
||||||
|
i := sort.SearchInts(expr.hourList, t.Hour()+1)
|
||||||
|
if i == len(expr.hourList) {
|
||||||
|
return expr.nextDayOfMonth(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Date(
|
||||||
|
t.Year(),
|
||||||
|
t.Month(),
|
||||||
|
t.Day(),
|
||||||
|
expr.hourList[i],
|
||||||
|
expr.minuteList[0],
|
||||||
|
expr.secondList[0],
|
||||||
|
0,
|
||||||
|
t.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func (expr *Expression) nextMinute(t time.Time) time.Time {
|
||||||
|
// Find index at which item in list is greater or equal to
|
||||||
|
// candidate minute
|
||||||
|
i := sort.SearchInts(expr.minuteList, t.Minute()+1)
|
||||||
|
if i == len(expr.minuteList) {
|
||||||
|
return expr.nextHour(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Date(
|
||||||
|
t.Year(),
|
||||||
|
t.Month(),
|
||||||
|
t.Day(),
|
||||||
|
t.Hour(),
|
||||||
|
expr.minuteList[i],
|
||||||
|
expr.secondList[0],
|
||||||
|
0,
|
||||||
|
t.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func (expr *Expression) nextSecond(t time.Time) time.Time {
|
||||||
|
// nextSecond() assumes all other fields are exactly matched
|
||||||
|
// to the cron expression
|
||||||
|
|
||||||
|
// Find index at which item in list is greater or equal to
|
||||||
|
// candidate second
|
||||||
|
i := sort.SearchInts(expr.secondList, t.Second()+1)
|
||||||
|
if i == len(expr.secondList) {
|
||||||
|
return expr.nextMinute(t)
|
||||||
|
}
|
||||||
|
|
||||||
|
return time.Date(
|
||||||
|
t.Year(),
|
||||||
|
t.Month(),
|
||||||
|
t.Day(),
|
||||||
|
t.Hour(),
|
||||||
|
t.Minute(),
|
||||||
|
expr.secondList[i],
|
||||||
|
0,
|
||||||
|
t.Location())
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func (expr *Expression) secondFieldHandler(s string) error {
|
||||||
|
var err error
|
||||||
|
expr.secondList, err = genericFieldHandler(s, secondDescriptor)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func (expr *Expression) minuteFieldHandler(s string) error {
|
||||||
|
var err error
|
||||||
|
expr.minuteList, err = genericFieldHandler(s, minuteDescriptor)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func (expr *Expression) hourFieldHandler(s string) error {
|
||||||
|
var err error
|
||||||
|
expr.hourList, err = genericFieldHandler(s, hourDescriptor)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func (expr *Expression) monthFieldHandler(s string) error {
|
||||||
|
var err error
|
||||||
|
expr.monthList, err = genericFieldHandler(s, monthDescriptor)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func (expr *Expression) yearFieldHandler(s string) error {
|
||||||
|
var err error
|
||||||
|
expr.yearList, err = genericFieldHandler(s, yearDescriptor)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
const (
|
||||||
|
none = 0
|
||||||
|
one = 1
|
||||||
|
span = 2
|
||||||
|
all = 3
|
||||||
|
)
|
||||||
|
|
||||||
|
type cronDirective struct {
|
||||||
|
kind int
|
||||||
|
first int
|
||||||
|
last int
|
||||||
|
step int
|
||||||
|
sbeg int
|
||||||
|
send int
|
||||||
|
}
|
||||||
|
|
||||||
|
func genericFieldHandler(s string, desc fieldDescriptor) ([]int, error) {
|
||||||
|
directives, err := genericFieldParse(s, desc)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
values := make(map[int]bool)
|
||||||
|
for _, directive := range directives {
|
||||||
|
switch directive.kind {
|
||||||
|
case none:
|
||||||
|
return nil, fmt.Errorf("Syntax error in %s field: '%s'", desc.name, s[directive.sbeg:directive.send])
|
||||||
|
case one:
|
||||||
|
populateOne(values, directive.first)
|
||||||
|
case span:
|
||||||
|
populateMany(values, directive.first, directive.last, directive.step)
|
||||||
|
case all:
|
||||||
|
return desc.defaultList, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return toList(values), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (expr *Expression) dowFieldHandler(s string) error {
|
||||||
|
expr.daysOfWeekRestricted = true
|
||||||
|
expr.daysOfWeek = make(map[int]bool)
|
||||||
|
expr.lastWeekDaysOfWeek = make(map[int]bool)
|
||||||
|
expr.specificWeekDaysOfWeek = make(map[int]bool)
|
||||||
|
|
||||||
|
directives, err := genericFieldParse(s, dowDescriptor)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, directive := range directives {
|
||||||
|
switch directive.kind {
|
||||||
|
case none:
|
||||||
|
sdirective := s[directive.sbeg:directive.send]
|
||||||
|
snormal := strings.ToLower(sdirective)
|
||||||
|
// `5L`
|
||||||
|
pairs := makeLayoutRegexp(layoutDowOfLastWeek, dowDescriptor.valuePattern).FindStringSubmatchIndex(snormal)
|
||||||
|
if len(pairs) > 0 {
|
||||||
|
populateOne(expr.lastWeekDaysOfWeek, dowDescriptor.atoi(snormal[pairs[2]:pairs[3]]))
|
||||||
|
} else {
|
||||||
|
// `5#3`
|
||||||
|
pairs := makeLayoutRegexp(layoutDowOfSpecificWeek, dowDescriptor.valuePattern).FindStringSubmatchIndex(snormal)
|
||||||
|
if len(pairs) > 0 {
|
||||||
|
populateOne(expr.specificWeekDaysOfWeek, (dowDescriptor.atoi(snormal[pairs[4]:pairs[5]])-1)*7+(dowDescriptor.atoi(snormal[pairs[2]:pairs[3]])%7))
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("Syntax error in day-of-week field: '%s'", sdirective)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case one:
|
||||||
|
populateOne(expr.daysOfWeek, directive.first)
|
||||||
|
case span:
|
||||||
|
populateMany(expr.daysOfWeek, directive.first, directive.last, directive.step)
|
||||||
|
case all:
|
||||||
|
populateMany(expr.daysOfWeek, directive.first, directive.last, directive.step)
|
||||||
|
expr.daysOfWeekRestricted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (expr *Expression) domFieldHandler(s string) error {
|
||||||
|
expr.daysOfMonthRestricted = true
|
||||||
|
expr.lastDayOfMonth = false
|
||||||
|
expr.lastWorkdayOfMonth = false
|
||||||
|
expr.daysOfMonth = make(map[int]bool) // days of month map
|
||||||
|
expr.workdaysOfMonth = make(map[int]bool) // work days of month map
|
||||||
|
|
||||||
|
directives, err := genericFieldParse(s, domDescriptor)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, directive := range directives {
|
||||||
|
switch directive.kind {
|
||||||
|
case none:
|
||||||
|
sdirective := s[directive.sbeg:directive.send]
|
||||||
|
snormal := strings.ToLower(sdirective)
|
||||||
|
// `L`
|
||||||
|
if makeLayoutRegexp(layoutLastDom, domDescriptor.valuePattern).MatchString(snormal) {
|
||||||
|
expr.lastDayOfMonth = true
|
||||||
|
} else {
|
||||||
|
// `LW`
|
||||||
|
if makeLayoutRegexp(layoutLastWorkdom, domDescriptor.valuePattern).MatchString(snormal) {
|
||||||
|
expr.lastWorkdayOfMonth = true
|
||||||
|
} else {
|
||||||
|
// `15W`
|
||||||
|
pairs := makeLayoutRegexp(layoutWorkdom, domDescriptor.valuePattern).FindStringSubmatchIndex(snormal)
|
||||||
|
if len(pairs) > 0 {
|
||||||
|
populateOne(expr.workdaysOfMonth, domDescriptor.atoi(snormal[pairs[2]:pairs[3]]))
|
||||||
|
} else {
|
||||||
|
return fmt.Errorf("Syntax error in day-of-month field: '%s'", sdirective)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
case one:
|
||||||
|
populateOne(expr.daysOfMonth, directive.first)
|
||||||
|
case span:
|
||||||
|
populateMany(expr.daysOfMonth, directive.first, directive.last, directive.step)
|
||||||
|
case all:
|
||||||
|
populateMany(expr.daysOfMonth, directive.first, directive.last, directive.step)
|
||||||
|
expr.daysOfMonthRestricted = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func genericFieldParse(s string, desc fieldDescriptor) ([]*cronDirective, error) {
|
||||||
|
// At least one entry must be present
|
||||||
|
indices := entryFinder.FindAllStringIndex(s, -1)
|
||||||
|
if len(indices) == 0 {
|
||||||
|
return nil, fmt.Errorf("%s field: missing directive", desc.name)
|
||||||
|
}
|
||||||
|
|
||||||
|
directives := make([]*cronDirective, 0, len(indices))
|
||||||
|
|
||||||
|
for i := range indices {
|
||||||
|
directive := cronDirective{
|
||||||
|
sbeg: indices[i][0],
|
||||||
|
send: indices[i][1],
|
||||||
|
}
|
||||||
|
snormal := strings.ToLower(s[indices[i][0]:indices[i][1]])
|
||||||
|
// `*`
|
||||||
|
if makeLayoutRegexp(layoutWildcard, desc.valuePattern).MatchString(snormal) {
|
||||||
|
directive.kind = all
|
||||||
|
directive.first = desc.min
|
||||||
|
directive.last = desc.max
|
||||||
|
directive.step = 1
|
||||||
|
directives = append(directives, &directive)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// `5`
|
||||||
|
if makeLayoutRegexp(layoutValue, desc.valuePattern).MatchString(snormal) {
|
||||||
|
directive.kind = one
|
||||||
|
directive.first = desc.atoi(snormal)
|
||||||
|
directives = append(directives, &directive)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// `5-20`
|
||||||
|
pairs := makeLayoutRegexp(layoutRange, desc.valuePattern).FindStringSubmatchIndex(snormal)
|
||||||
|
if len(pairs) > 0 {
|
||||||
|
directive.kind = span
|
||||||
|
directive.first = desc.atoi(snormal[pairs[2]:pairs[3]])
|
||||||
|
directive.last = desc.atoi(snormal[pairs[4]:pairs[5]])
|
||||||
|
directive.step = 1
|
||||||
|
directives = append(directives, &directive)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// `*/2`
|
||||||
|
pairs = makeLayoutRegexp(layoutWildcardAndInterval, desc.valuePattern).FindStringSubmatchIndex(snormal)
|
||||||
|
if len(pairs) > 0 {
|
||||||
|
directive.kind = span
|
||||||
|
directive.first = desc.min
|
||||||
|
directive.last = desc.max
|
||||||
|
directive.step = atoi(snormal[pairs[2]:pairs[3]])
|
||||||
|
directives = append(directives, &directive)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// `5/2`
|
||||||
|
pairs = makeLayoutRegexp(layoutValueAndInterval, desc.valuePattern).FindStringSubmatchIndex(snormal)
|
||||||
|
if len(pairs) > 0 {
|
||||||
|
directive.kind = span
|
||||||
|
directive.first = desc.atoi(snormal[pairs[2]:pairs[3]])
|
||||||
|
directive.last = desc.max
|
||||||
|
directive.step = atoi(snormal[pairs[4]:pairs[5]])
|
||||||
|
directives = append(directives, &directive)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// `5-20/2`
|
||||||
|
pairs = makeLayoutRegexp(layoutRangeAndInterval, desc.valuePattern).FindStringSubmatchIndex(snormal)
|
||||||
|
if len(pairs) > 0 {
|
||||||
|
directive.kind = span
|
||||||
|
directive.first = desc.atoi(snormal[pairs[2]:pairs[3]])
|
||||||
|
directive.last = desc.atoi(snormal[pairs[4]:pairs[5]])
|
||||||
|
directive.step = atoi(snormal[pairs[6]:pairs[7]])
|
||||||
|
directives = append(directives, &directive)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// No behavior for this one, let caller deal with it
|
||||||
|
directive.kind = none
|
||||||
|
directives = append(directives, &directive)
|
||||||
|
}
|
||||||
|
return directives, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func populateOne(values map[int]bool, v int) {
|
||||||
|
values[v] = true
|
||||||
|
}
|
||||||
|
|
||||||
|
func populateMany(values map[int]bool, min, max, step int) {
|
||||||
|
for i := min; i <= max; i += step {
|
||||||
|
values[i] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func toList(set map[int]bool) []int {
|
||||||
|
list := make([]int, len(set))
|
||||||
|
i := 0
|
||||||
|
for k := range set {
|
||||||
|
list[i] = k
|
||||||
|
i += 1
|
||||||
|
}
|
||||||
|
sort.Ints(list)
|
||||||
|
return list
|
||||||
|
}
|
||||||
|
|
||||||
|
/******************************************************************************/
|
||||||
|
|
||||||
|
func makeLayoutRegexp(layout, value string) *regexp.Regexp {
|
||||||
|
layout = strings.Replace(layout, `%value%`, value, -1)
|
||||||
|
re := layoutRegexp[layout]
|
||||||
|
if re == nil {
|
||||||
|
re = regexp.MustCompile(layout)
|
||||||
|
layoutRegexp[layout] = re
|
||||||
|
}
|
||||||
|
return re
|
||||||
|
}
|
|
@ -116,6 +116,51 @@ var crontests = []crontest{
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// Work day of month
|
||||||
|
{
|
||||||
|
"0 0 14W * *",
|
||||||
|
"Mon 2006-01-02 15:04",
|
||||||
|
[]crontimes{
|
||||||
|
{"2013-03-31 00:00:00", "Mon 2013-04-15 00:00"},
|
||||||
|
{"2013-08-31 00:00:00", "Fri 2013-09-13 00:00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Work day of month -- end of month
|
||||||
|
{
|
||||||
|
"0 0 30W * *",
|
||||||
|
"Mon 2006-01-02 15:04",
|
||||||
|
[]crontimes{
|
||||||
|
{"2013-03-02 00:00:00", "Fri 2013-03-29 00:00"},
|
||||||
|
{"2013-06-02 00:00:00", "Fri 2013-06-28 00:00"},
|
||||||
|
{"2013-09-02 00:00:00", "Mon 2013-09-30 00:00"},
|
||||||
|
{"2013-11-02 00:00:00", "Fri 2013-11-29 00:00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Last day of month
|
||||||
|
{
|
||||||
|
"0 0 L * *",
|
||||||
|
"Mon 2006-01-02 15:04",
|
||||||
|
[]crontimes{
|
||||||
|
{"2013-09-02 00:00:00", "Mon 2013-09-30 00:00"},
|
||||||
|
{"2014-01-01 00:00:00", "Fri 2014-01-31 00:00"},
|
||||||
|
{"2014-02-01 00:00:00", "Fri 2014-02-28 00:00"},
|
||||||
|
{"2016-02-15 00:00:00", "Mon 2016-02-29 00:00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
// Last work day of month
|
||||||
|
{
|
||||||
|
"0 0 LW * *",
|
||||||
|
"Mon 2006-01-02 15:04",
|
||||||
|
[]crontimes{
|
||||||
|
{"2013-09-02 00:00:00", "Mon 2013-09-30 00:00"},
|
||||||
|
{"2013-11-02 00:00:00", "Fri 2013-11-29 00:00"},
|
||||||
|
{"2014-08-15 00:00:00", "Fri 2014-08-29 00:00"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
// TODO: more tests
|
// TODO: more tests
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -123,7 +168,11 @@ func TestExpressions(t *testing.T) {
|
||||||
for _, test := range crontests {
|
for _, test := range crontests {
|
||||||
for _, times := range test.times {
|
for _, times := range test.times {
|
||||||
from, _ := time.Parse("2006-01-02 15:04:05", times.from)
|
from, _ := time.Parse("2006-01-02 15:04:05", times.from)
|
||||||
next := cronexpr.MustParse(test.expr).Next(from)
|
expr, err := cronexpr.Parse(test.expr)
|
||||||
|
if err != nil {
|
||||||
|
t.Errorf(`cronexpr.Parse("%s") returned "%s"`, test.expr, err.Error())
|
||||||
|
}
|
||||||
|
next := expr.Next(from)
|
||||||
nextstr := next.Format(test.layout)
|
nextstr := next.Format(test.layout)
|
||||||
if nextstr != times.next {
|
if nextstr != times.next {
|
||||||
t.Errorf(`("%s").Next("%s") = "%s", got "%s"`, test.expr, times.from, times.next, nextstr)
|
t.Errorf(`("%s").Next("%s") = "%s", got "%s"`, test.expr, times.from, times.next, nextstr)
|
||||||
|
|
Loading…
Reference in New Issue