package main import ( "errors" "fmt" "strconv" "strings" "time" ) const ( // scheduleCronGap is the shortest time between two runs of a cron // expression. Each run is a full turn, so shorter gaps use the quota // too fast. scheduleCronGap = 5 // scheduleCronYears is how many years the search for the next time // covers. scheduleCronYears = 8 ) // scheduleCronFields are the fields of a cron expression, in order, with their // lowest and highest values. var scheduleCronFields = []struct { name string low int high int }{ { name: "minute", low: 0, high: 59, }, { name: "hour", low: 0, high: 23, }, { name: "day of month", low: 1, high: 31, }, { name: "month", low: 1, high: 12, }, { name: "day of week", low: 0, high: 7, }, } // scheduleParseCron parses a cron expression of five fields: minute, hour, day // of month, month, and day of week, where 0 and 7 are Sunday. A field is a // comma list of items, and an item is *, N, or N-M, where * and N-M can have a // step /S. Names, macros, the characters ? L W #, and N/S are errors. // // It refuses an expression whose runs can be less than scheduleCronGap minutes // apart. The check takes the wall times of one day from the hour and the minute // fields, and the gap across midnight as if the next day matches too. It also // refuses an expression that matches no day in scheduleCronYears years, such as // one on February 30. func scheduleParseCron(expression string) (ScheduleCron, error) { fields := strings.Fields(expression) if len(fields) != len(scheduleCronFields) { return ScheduleCron{}, fmt.Errorf("the cron expression %q has %d fields, not 5: minute, hour, day of month, month, day of week", expression, len(fields)) } var sets [5]uint64 for index, field := range fields { set, err := scheduleCronField(field, scheduleCronFields[index].low, scheduleCronFields[index].high) if err != nil { return ScheduleCron{}, fmt.Errorf("the %s field of %q: %w", scheduleCronFields[index].name, expression, err) } sets[index] = set } c := ScheduleCron{ minutes: sets[0], hours: sets[1], days: sets[2], months: sets[3], weekdays: sets[4], anyDay: strings.HasPrefix(fields[2], "*"), anyWeekday: strings.HasPrefix(fields[4], "*"), description: strings.Join(fields, " "), } if c.weekdays&(1<<7) != 0 { c.weekdays |= 1 } if c.gap() < scheduleCronGap { return ScheduleCron{}, fmt.Errorf("the runs of %q can be less than %d minutes apart: the runs of a schedule must be at least %d minutes apart", expression, scheduleCronGap, scheduleCronGap) } start := time.Date(2024, time.January, 1, 0, 0, 0, 0, time.UTC) _, found := c.Next(start) if !found { return ScheduleCron{}, fmt.Errorf("the cron expression %q matches no day in %d years", expression, scheduleCronYears) } return c, nil } // ScheduleCron is a parsed cron expression. Each set holds a bit for each value // of its field. anyDay and anyWeekday tell that the day field or the weekday // field starts with *. When neither does, a day matches when either field // matches, as in classic cron. Otherwise both fields must match. type ScheduleCron struct { minutes uint64 hours uint64 days uint64 months uint64 weekdays uint64 anyDay bool anyWeekday bool description string } // String returns the expression with one space between its fields. func (c ScheduleCron) String() string { return c.description } // Next returns the first time after after whose wall time in the location of // after matches, or false when no time in scheduleCronYears years matches. A // wall time goes through scheduleWallTime, so a wall time that the clock skips // runs later by the length of the skip, and a wall time that the clock repeats // runs once, at its first occurrence. func (c ScheduleCron) Next(after time.Time) (time.Time, bool) { location := after.Location() year, month, day := after.Date() first := time.Date(year, month, day, 0, 0, 0, 0, time.UTC) last := first.AddDate(scheduleCronYears, 0, 0) for date := first; date.Before(last); date = date.AddDate(0, 0, 1) { if !c.day(date) { continue } // A wall time that the clock skips moves later, so the times of // one day are not always in order. The first time is the // smallest one. var next time.Time for hour := range 24 { if c.hours&(1< offset { earlier := t.Add(-time.Duration(before-offset) * time.Second) if earlier.Day() == day && earlier.Hour() == hour && earlier.Minute() == minute { return earlier } } return t } // scheduleCronField returns the set of the values of one field, from low to // high. func scheduleCronField(field string, low, high int) (uint64, error) { var set uint64 for item := range strings.SplitSeq(field, ",") { base, stepText, stepped := strings.Cut(item, "/") step := 1 if stepped { var err error step, err = scheduleCronNumber(stepText, 1, high) if err != nil { return 0, fmt.Errorf("the step of %q: %w", item, err) } } first, last := low, high startText, endText, ranged := strings.Cut(base, "-") switch { case base == "*": case ranged: var err error first, err = scheduleCronNumber(startText, low, high) if err == nil { last, err = scheduleCronNumber(endText, low, high) } if err == nil && first > last { err = errors.New("the start is after the end") } if err != nil { return 0, fmt.Errorf("the range %q: %w", base, err) } case stepped: return 0, fmt.Errorf("%q has a step after a single value: write a range N-M/S or */S", item) default: var err error first, err = scheduleCronNumber(base, low, high) if err != nil { return 0, err } last = first } for value := first; value <= last; value += step { set |= 1 << value } } return set, nil } // scheduleCronNumber returns the value of text, a decimal number from low to // high. func scheduleCronNumber(text string, low, high int) (int, error) { if text == "" || strings.Trim(text, "0123456789") != "" { return 0, fmt.Errorf("%q is not a number from %d to %d", text, low, high) } value, err := strconv.Atoi(text) if err != nil || value < low || value > high { return 0, fmt.Errorf("%q is not a number from %d to %d", text, low, high) } return value, nil }