Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}