cron.gno
9.62 Kb · 327 lines
1// Package cron parses the classic five-field cron expression and answers the
2// only question a schedule exists to answer: when does it next fire.
3//
4// The grammar is the one every crontab uses, in the order minute, hour,
5// day-of-month, month, day-of-week:
6//
7// */15 9-17 * * mon-fri
8// │ │ │ │ │
9// │ │ │ │ └── day of week, 0-7 or sun..sat (0 and 7 are both Sunday)
10// │ │ │ └──── month, 1-12 or jan..dec
11// │ │ └────── day of month, 1-31
12// │ └─────────── hour, 0-23
13// └──────────────── minute, 0-59
14//
15// Each field is a comma-separated list whose items are a value, a range, a
16// star, or any of those followed by a step: 5, 5-30, *, */15, 5-30/5, 5/20.
17// The seven @macros are accepted too (@hourly, @daily, @weekly, @monthly,
18// @yearly, @annually, @midnight).
19//
20// A Schedule is immutable once parsed and holds no state, so it is safe to
21// build one at package scope and share it. Everything is computed in UTC: a
22// block timestamp has no local zone, and a schedule whose answer depended on
23// one would not be the same schedule on every validating node.
24package cron
25
26import (
27 "errors"
28 "strconv"
29 "strings"
30 "time"
31)
32
33// horizonYears bounds the search in Next. A cron expression can be perfectly
34// well formed and still never fire (February the 30th), so the walk needs a
35// stop condition that is not "a match eventually appears". Four years clears
36// the longest real gap, the leap day, with a year to spare.
37const horizonYears = 5
38
39// Schedule is a parsed cron expression.
40type Schedule struct {
41 expr string
42
43 minute [60]bool
44 hour [24]bool
45 dom [32]bool // 1..31
46 month [13]bool // 1..12
47 dow [7]bool // 0..6, Sunday is 0
48
49 // Whether each day field was narrowed from "*". The pair decides how the
50 // two are combined; see matchDay.
51 domRestricted bool
52 dowRestricted bool
53}
54
55var macros = map[string]string{
56 "@yearly": "0 0 1 1 *",
57 "@annually": "0 0 1 1 *",
58 "@monthly": "0 0 1 * *",
59 "@weekly": "0 0 * * 0",
60 "@daily": "0 0 * * *",
61 "@midnight": "0 0 * * *",
62 "@hourly": "0 * * * *",
63}
64
65var monthNames = map[string]int{
66 "jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
67 "jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
68}
69
70var dayNames = map[string]int{
71 "sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
72}
73
74// Parse compiles a cron expression.
75//
76// It validates the grammar and the ranges, and nothing else: an expression
77// that is well formed but can never fire parses successfully, because only
78// Next is in a position to discover that.
79func Parse(expr string) (*Schedule, error) {
80 raw := strings.TrimSpace(expr)
81 if raw == "" {
82 return nil, errors.New("cron: empty expression")
83 }
84
85 spec := raw
86 if strings.HasPrefix(spec, "@") {
87 expanded, ok := macros[strings.ToLower(spec)]
88 if !ok {
89 return nil, errors.New("cron: unknown macro " + strconv.Quote(raw))
90 }
91 spec = expanded
92 }
93
94 fields := strings.Fields(spec)
95 if len(fields) != 5 {
96 return nil, errors.New("cron: want 5 fields, got " + strconv.Itoa(len(fields)) +
97 " in " + strconv.Quote(raw))
98 }
99
100 s := &Schedule{expr: raw}
101
102 minute, err := parseField(fields[0], 0, 59, nil)
103 if err != nil {
104 return nil, fieldError("minute", fields[0], err)
105 }
106 copy(s.minute[:], minute)
107
108 hour, err := parseField(fields[1], 0, 23, nil)
109 if err != nil {
110 return nil, fieldError("hour", fields[1], err)
111 }
112 copy(s.hour[:], hour)
113
114 dom, err := parseField(fields[2], 1, 31, nil)
115 if err != nil {
116 return nil, fieldError("day of month", fields[2], err)
117 }
118 copy(s.dom[:], dom)
119 s.domRestricted = fields[2] != "*"
120
121 month, err := parseField(fields[3], 1, 12, monthNames)
122 if err != nil {
123 return nil, fieldError("month", fields[3], err)
124 }
125 copy(s.month[:], month)
126
127 // Day of week is parsed over 0..7 so that 7 and 5-7 stay legal, then
128 // folded: Sunday has two spellings and one slot.
129 dow, err := parseField(fields[4], 0, 7, dayNames)
130 if err != nil {
131 return nil, fieldError("day of week", fields[4], err)
132 }
133 if dow[7] {
134 dow[0] = true
135 }
136 copy(s.dow[:], dow[:7])
137 s.dowRestricted = fields[4] != "*"
138
139 return s, nil
140}
141
142// MustParse is Parse for an expression written by the programmer rather than
143// supplied by a caller. It panics instead of returning an error, so a constant
144// schedule can be a package-level variable.
145func MustParse(expr string) *Schedule {
146 s, err := Parse(expr)
147 if err != nil {
148 panic(err)
149 }
150 return s
151}
152
153// Expr returns the expression this schedule was parsed from, unmodified. A
154// macro is reported as the macro, not as its expansion.
155func (s *Schedule) Expr() string { return s.expr }
156
157// Matches reports whether the schedule fires during the minute containing t.
158// Seconds and smaller are ignored: cron has no sub-minute resolution.
159func (s *Schedule) Matches(t time.Time) bool {
160 u := t.UTC()
161 return s.month[int(u.Month())] &&
162 s.matchDay(u) &&
163 s.hour[u.Hour()] &&
164 s.minute[u.Minute()]
165}
166
167// Next returns the first instant strictly after `after` at which the schedule
168// fires, and whether there is one.
169//
170// The second return value is not decoration. "0 0 30 2 *" is a legal
171// expression with no instant behind it, and a caller that assumed a match
172// would scan forever.
173func (s *Schedule) Next(after time.Time) (time.Time, bool) {
174 // Strictly after, and aligned to the minute: a schedule handed its own
175 // firing time must answer with the following one, not with itself.
176 t := after.UTC().Truncate(time.Minute).Add(time.Minute)
177 limit := t.Year() + horizonYears
178
179 for t.Year() <= limit {
180 switch {
181 case !s.month[int(t.Month())]:
182 // Skipping to the first of the next month relies on time.Date
183 // normalising month 13 into January of the following year.
184 t = time.Date(t.Year(), t.Month()+1, 1, 0, 0, 0, 0, time.UTC)
185 case !s.matchDay(t):
186 t = time.Date(t.Year(), t.Month(), t.Day()+1, 0, 0, 0, 0, time.UTC)
187 case !s.hour[t.Hour()]:
188 t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour()+1, 0, 0, 0, time.UTC)
189 case !s.minute[t.Minute()]:
190 t = t.Add(time.Minute)
191 default:
192 return t, true
193 }
194 }
195 return time.Time{}, false
196}
197
198// NextN returns the next n firing times, in order. It returns fewer, possibly
199// none, when the schedule runs out before n are found.
200func (s *Schedule) NextN(after time.Time, n int) []time.Time {
201 if n <= 0 {
202 return nil
203 }
204 out := make([]time.Time, 0, n)
205 t := after
206 for i := 0; i < n; i++ {
207 next, ok := s.Next(t)
208 if !ok {
209 break
210 }
211 out = append(out, next)
212 t = next
213 }
214 return out
215}
216
217// matchDay combines the two day fields.
218//
219// This is the rule the whole package is written for, and it is the one every
220// reimplementation gets wrong. When BOTH day fields are narrowed the match is
221// their union, not their intersection: "0 0 13 * 5" is the 13th of the month
222// OR any Friday, which is why a Friday the 13th crontab is written that way
223// and still fires on ordinary Fridays. When only one is narrowed the other
224// matches everything, so the union and the intersection coincide and there is
225// nothing to decide.
226func (s *Schedule) matchDay(t time.Time) bool {
227 dom := s.dom[t.Day()]
228 dow := s.dow[int(t.Weekday())]
229 switch {
230 case s.domRestricted && s.dowRestricted:
231 return dom || dow
232 case s.domRestricted:
233 return dom
234 case s.dowRestricted:
235 return dow
236 default:
237 return true
238 }
239}
240
241// parseField expands one comma-separated field into a bitmap of size max+1.
242func parseField(field string, min, max int, names map[string]int) ([]bool, error) {
243 bits := make([]bool, max+1)
244 for _, part := range strings.Split(field, ",") {
245 if part == "" {
246 return nil, errors.New("empty list item")
247 }
248
249 rangePart := part
250 step := 1
251 if i := strings.Index(part, "/"); i >= 0 {
252 rangePart = part[:i]
253 n, err := strconv.Atoi(part[i+1:])
254 if err != nil {
255 return nil, errors.New("step is not a number: " + strconv.Quote(part[i+1:]))
256 }
257 if n < 1 {
258 return nil, errors.New("step must be at least 1, got " + strconv.Itoa(n))
259 }
260 step = n
261 }
262
263 lo, hi, err := parseRange(rangePart, min, max, names, step)
264 if err != nil {
265 return nil, err
266 }
267 for v := lo; v <= hi; v += step {
268 bits[v] = true
269 }
270 }
271 return bits, nil
272}
273
274// parseRange resolves the pre-step half of a list item into inclusive bounds.
275func parseRange(s string, min, max int, names map[string]int, step int) (int, int, error) {
276 if s == "*" {
277 return min, max, nil
278 }
279 if i := strings.Index(s, "-"); i > 0 {
280 lo, err := parseValue(s[:i], min, max, names)
281 if err != nil {
282 return 0, 0, err
283 }
284 hi, err := parseValue(s[i+1:], min, max, names)
285 if err != nil {
286 return 0, 0, err
287 }
288 if lo > hi {
289 return 0, 0, errors.New("range runs backwards: " + strconv.Quote(s))
290 }
291 return lo, hi, nil
292 }
293 v, err := parseValue(s, min, max, names)
294 if err != nil {
295 return 0, 0, err
296 }
297 if step > 1 {
298 // "5/20" is shorthand for "5-max/20": a bare value with a step means
299 // start here and keep going, not fire once.
300 return v, max, nil
301 }
302 return v, v, nil
303}
304
305// parseValue resolves a single number or three-letter name.
306func parseValue(s string, min, max int, names map[string]int) (int, error) {
307 if names != nil {
308 if v, ok := names[strings.ToLower(s)]; ok {
309 return v, nil
310 }
311 }
312 v, err := strconv.Atoi(s)
313 if err != nil {
314 return 0, errors.New("not a number or a known name: " + strconv.Quote(s))
315 }
316 if v < min || v > max {
317 return 0, errors.New("out of range " + strconv.Itoa(min) + "-" + strconv.Itoa(max) +
318 ": " + strconv.Itoa(v))
319 }
320 return v, nil
321}
322
323// fieldError names the field a parse failure came from. A bare "out of range"
324// is useless when five fields all accept numbers.
325func fieldError(name, field string, err error) error {
326 return errors.New("cron: " + name + " field " + strconv.Quote(field) + ": " + err.Error())
327}