1// Package scheduler provides portable, in-process calendar scheduling.
2package scheduler
3
4import (
5 "fmt"
6 "strconv"
7 "strings"
8 "time"
9)
10
11// Cron is a five-field cron schedule (minute, hour, day of month, month, day
12// of week). It evaluates calendar times in the location captured when the
13// schedule is created. A repeated local minute during a DST fall-back matches
14// twice; a local minute skipped during a spring-forward does not match.
15type Cron struct {
16 minute field
17 hour field
18 day field
19 month field
20 week field
21 loc *time.Location
22}
23
24type field struct {
25 values []bool
26 wildcard bool
27}
28
29var cronMacros = map[string]string{
30 "@yearly": "0 0 1 1 *",
31 "@annually": "0 0 1 1 *",
32 "@monthly": "0 0 1 * *",
33 "@weekly": "0 0 * * 0",
34 "@daily": "0 0 * * *",
35 "@midnight": "0 0 * * *",
36 "@hourly": "0 * * * *",
37}
38
39var monthNames = map[string]int{
40 "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
41 "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
42}
43
44var weekdayNames = map[string]int{
45 "sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
46}
47
48// ParseCron parses a conventional five-field cron expression. Month and
49// weekday names use the English three-letter abbreviations. The supplied
50// location is retained by the schedule, so later changes to time.Local do not
51// change an existing task's calendar semantics.
52func ParseCron(expression string, location *time.Location) (Cron, error) {
53 if location == nil {
54 return Cron{}, fmt.Errorf("cron timezone is required")
55 }
56 expression = strings.ToLower(strings.TrimSpace(expression))
57 if replacement, ok := cronMacros[expression]; ok {
58 expression = replacement
59 }
60 parts := strings.Fields(expression)
61 if len(parts) != 5 {
62 return Cron{}, fmt.Errorf("cron requires five fields (minute hour day-of-month month day-of-week)")
63 }
64 minute, err := parseField(parts[0], 0, 59, nil, false)
65 if err != nil {
66 return Cron{}, fmt.Errorf("invalid cron minute: %w", err)
67 }
68 hour, err := parseField(parts[1], 0, 23, nil, false)
69 if err != nil {
70 return Cron{}, fmt.Errorf("invalid cron hour: %w", err)
71 }
72 day, err := parseField(parts[2], 1, 31, nil, false)
73 if err != nil {
74 return Cron{}, fmt.Errorf("invalid cron day-of-month: %w", err)
75 }
76 month, err := parseField(parts[3], 1, 12, monthNames, false)
77 if err != nil {
78 return Cron{}, fmt.Errorf("invalid cron month: %w", err)
79 }
80 week, err := parseField(parts[4], 0, 7, weekdayNames, true)
81 if err != nil {
82 return Cron{}, fmt.Errorf("invalid cron day-of-week: %w", err)
83 }
84 return Cron{minute: minute, hour: hour, day: day, month: month, week: week, loc: location}, nil
85}
86
87// Next returns the first schedule occurrence strictly after after. An invalid
88// calendar expression such as 31 February has no occurrence and returns the
89// zero time.
90func (c Cron) Next(after time.Time) time.Time {
91 if c.loc == nil {
92 return time.Time{}
93 }
94 candidate := after.In(c.loc).Truncate(time.Minute).Add(time.Minute)
95 // Eight years covers every leap-year placement while keeping impossible
96 // expressions bounded. Normal schedules resolve in a handful of steps.
97 const maxMinutes = 8 * 366 * 24 * 60
98 for range maxMinutes {
99 if c.matches(candidate) {
100 return candidate
101 }
102 candidate = candidate.Add(time.Minute)
103 }
104 return time.Time{}
105}
106
107func (c Cron) matches(t time.Time) bool {
108 if !c.minute.matches(t.Minute()) || !c.hour.matches(t.Hour()) ||
109 !c.month.matches(int(t.Month())) {
110 return false
111 }
112 dom := c.day.matches(t.Day())
113 dow := c.week.matches(int(t.Weekday()))
114 // Traditional cron treats restricted day-of-month and day-of-week fields
115 // as an OR. When either is *, the other field controls matching.
116 if !c.day.wildcard && !c.week.wildcard {
117 return dom || dow
118 }
119 return dom && dow
120}
121
122func (f field) matches(value int) bool {
123 return value >= 0 && value < len(f.values) && f.values[value]
124}
125
126func parseField(raw string, min, max int, aliases map[string]int, sundaySeven bool) (field, error) {
127 out := field{values: make([]bool, max+1), wildcard: raw == "*"}
128 if raw == "" {
129 return field{}, fmt.Errorf("empty field")
130 }
131 for _, part := range strings.Split(raw, ",") {
132 if part == "" {
133 return field{}, fmt.Errorf("empty list item")
134 }
135 base, step, hasStep, err := splitStep(part)
136 if err != nil {
137 return field{}, err
138 }
139 start, end := min, max
140 switch {
141 case base == "*":
142 case strings.Contains(base, "-"):
143 pieces := strings.Split(base, "-")
144 if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" {
145 return field{}, fmt.Errorf("invalid range %q", base)
146 }
147 start, err = parseCronValue(pieces[0], min, max, aliases, sundaySeven)
148 if err != nil {
149 return field{}, err
150 }
151 end, err = parseCronValue(pieces[1], min, max, aliases, sundaySeven)
152 if err != nil {
153 return field{}, err
154 }
155 if start > end {
156 return field{}, fmt.Errorf("range %q descends", base)
157 }
158 default:
159 if hasStep {
160 return field{}, fmt.Errorf("step requires * or a range")
161 }
162 start, err = parseCronValue(base, min, max, aliases, sundaySeven)
163 if err != nil {
164 return field{}, err
165 }
166 end = start
167 }
168 if !hasStep {
169 step = 1
170 }
171 for value := start; value <= end; value += step {
172 out.values[value] = true
173 }
174 }
175 return out, nil
176}
177
178func splitStep(part string) (base string, step int, hasStep bool, err error) {
179 pieces := strings.Split(part, "/")
180 if len(pieces) == 1 {
181 return part, 0, false, nil
182 }
183 if len(pieces) != 2 || pieces[0] == "" || pieces[1] == "" {
184 return "", 0, false, fmt.Errorf("invalid step %q", part)
185 }
186 step, err = strconv.Atoi(pieces[1])
187 if err != nil || step <= 0 {
188 return "", 0, false, fmt.Errorf("invalid step %q", part)
189 }
190 return pieces[0], step, true, nil
191}
192
193func parseCronValue(raw string, min, max int, aliases map[string]int, sundaySeven bool) (int, error) {
194 if value, ok := aliases[raw]; ok {
195 return value, nil
196 }
197 value, err := strconv.Atoi(raw)
198 if err != nil {
199 return 0, fmt.Errorf("%q is not a number", raw)
200 }
201 if sundaySeven && value == 7 {
202 return 0, nil
203 }
204 if value < min || value > max {
205 return 0, fmt.Errorf("%d is outside %d-%d", value, min, max)
206 }
207 return value, nil
208}