cron.go

v0.6.0
Doc Versions Source
1
package chronos
2
3
import (
4
	"fmt"
5
	"strconv"
6
	"strings"
7
	"time"
8
)
9
10
// cronSchedule is a parsed cron expression matched against wall-clock
11
// fields. Each field is a bitset whose set bits are the permitted values
12
// for that field; [cronSchedule.Next] advances to the next instant whose
13
// fields all match. It is the [Schedule] backing [parseSpec] for
14
// calendar specs (the @every form reuses [everySchedule]).
15
type cronSchedule struct {
16
	sec    uint64 // 0-59 (only consulted when hasSec)
17
	min    uint64 // 0-59
18
	hour   uint64 // 0-23
19
	dom    uint64 // 1-31
20
	month  uint64 // 1-12
21
	dow    uint64 // 0-6, Sunday=0
22
	hasSec bool   // true for 6-field specs with a leading seconds field
23
}
24
25
// field describes the value domain of a single cron field for the parser.
26
type field struct {
27
	min, max int
28
	names    map[string]int // optional symbolic aliases (months, weekdays)
29
}
30
31
var (
32
	monthNames = map[string]int{
33
		"jan": 1, "feb": 2, "mar": 3, "apr": 4, "may": 5, "jun": 6,
34
		"jul": 7, "aug": 8, "sep": 9, "oct": 10, "nov": 11, "dec": 12,
35
	}
36
	dowNames = map[string]int{
37
		"sun": 0, "mon": 1, "tue": 2, "wed": 3, "thu": 4, "fri": 5, "sat": 6,
38
	}
39
)
40
41
// macros maps the @-shorthands to their 5-field equivalents.
42
var macros = map[string]string{
43
	"@hourly":  "0 * * * *",
44
	"@daily":   "0 0 * * *",
45
	"@weekly":  "0 0 * * 0",
46
	"@monthly": "0 0 1 * *",
47
	"@yearly":  "0 0 1 1 *",
48
}
49
50
// parseSpec parses a cron spec into a [Schedule]. It accepts the @every
51
// duration form (reusing [everySchedule]), the @-macros, and 5- or
52
// 6-field cron expressions. It fails fast on any malformed input (PC1):
53
// no field is ever silently normalized to a default.
54
func parseSpec(spec string) (Schedule, error) {
55
	spec = strings.TrimSpace(spec)
56
	if spec == "" {
57
		return nil, fmt.Errorf("chronos: empty cron spec")
58
	}
59
60
	if strings.HasPrefix(spec, "@every ") {
61
		durStr := strings.TrimSpace(strings.TrimPrefix(spec, "@every "))
62
		d, err := time.ParseDuration(durStr)
63
		if err != nil {
64
			return nil, fmt.Errorf("chronos: @every duration %q: %w", durStr, err)
65
		}
66
		if d <= 0 {
67
			return nil, fmt.Errorf("chronos: @every duration %q must be positive", durStr)
68
		}
69
		return everySchedule{span: Span{Dur: d}}, nil
70
	}
71
72
	if strings.HasPrefix(spec, "@") {
73
		expanded, ok := macros[spec]
74
		if !ok {
75
			return nil, fmt.Errorf("chronos: unknown macro %q", spec)
76
		}
77
		spec = expanded
78
	}
79
80
	return parseFields(spec)
81
}
82
83
// parseFields parses a 5- or 6-field cron expression into a cronSchedule.
84
func parseFields(spec string) (Schedule, error) {
85
	fields := strings.Fields(spec)
86
	var hasSec bool
87
	switch len(fields) {
88
	case 5:
89
		hasSec = false
90
	case 6:
91
		hasSec = true
92
	default:
93
		return nil, fmt.Errorf("chronos: cron spec %q: want 5 or 6 fields, got %d", spec, len(fields))
94
	}
95
96
	cs := cronSchedule{hasSec: hasSec}
97
	idx := 0
98
	if hasSec {
99
		sec, err := parseField(fields[idx], field{min: 0, max: 59})
100
		if err != nil {
101
			return nil, fmt.Errorf("chronos: seconds field: %w", err)
102
		}
103
		cs.sec = sec
104
		idx++
105
	}
106
107
	parsers := []struct {
108
		name string
109
		f    field
110
		dst  *uint64
111
	}{
112
		{"minutes", field{min: 0, max: 59}, &cs.min},
113
		{"hours", field{min: 0, max: 23}, &cs.hour},
114
		{"day-of-month", field{min: 1, max: 31}, &cs.dom},
115
		{"month", field{min: 1, max: 12, names: monthNames}, &cs.month},
116
		{"day-of-week", field{min: 0, max: 7, names: dowNames}, &cs.dow},
117
	}
118
	for _, p := range parsers {
119
		v, err := parseField(fields[idx], p.f)
120
		if err != nil {
121
			return nil, fmt.Errorf("chronos: %s field: %w", p.name, err)
122
		}
123
		*p.dst = v
124
		idx++
125
	}
126
127
	// Normalize dow: 7 means Sunday, fold onto bit 0.
128
	if cs.dow&(1<<7) != 0 {
129
		cs.dow = (cs.dow &^ (1 << 7)) | (1 << 0)
130
	}
131
132
	return cs, nil
133
}
134
135
// parseField parses one cron field into a bitset over [f.min, f.max].
136
// It handles "*", comma lists, "a-b" ranges, and "*/n" / "a-b/n" steps,
137
// rejecting empty elements, out-of-range values, and bad tokens (PC1).
138
func parseField(s string, f field) (uint64, error) {
139
	if s == "" {
140
		return 0, fmt.Errorf("empty field")
141
	}
142
	var bits uint64
143
	for _, part := range strings.Split(s, ",") {
144
		if part == "" {
145
			return 0, fmt.Errorf("empty list element in %q", s)
146
		}
147
		b, err := parseElement(part, f)
148
		if err != nil {
149
			return 0, err
150
		}
151
		bits |= b
152
	}
153
	return bits, nil
154
}
155
156
// parseElement parses a single comma-separated element of a field.
157
func parseElement(part string, f field) (uint64, error) {
158
	rangePart := part
159
	step := 1
160
	if slash := strings.IndexByte(part, '/'); slash >= 0 {
161
		rangePart = part[:slash]
162
		stepStr := part[slash+1:]
163
		n, err := strconv.Atoi(stepStr)
164
		if err != nil {
165
			return 0, fmt.Errorf("bad step %q in %q", stepStr, part)
166
		}
167
		if n < 1 {
168
			return 0, fmt.Errorf("step must be >= 1 in %q", part)
169
		}
170
		step = n
171
	}
172
173
	var lo, hi int
174
	switch {
175
	case rangePart == "*":
176
		lo, hi = f.min, f.max
177
	case strings.ContainsRune(rangePart, '-'):
178
		dash := strings.IndexByte(rangePart, '-')
179
		a, err := parseValue(rangePart[:dash], f)
180
		if err != nil {
181
			return 0, err
182
		}
183
		b, err := parseValue(rangePart[dash+1:], f)
184
		if err != nil {
185
			return 0, err
186
		}
187
		if a > b {
188
			return 0, fmt.Errorf("descending range %q", rangePart)
189
		}
190
		lo, hi = a, b
191
	default:
192
		v, err := parseValue(rangePart, f)
193
		if err != nil {
194
			return 0, err
195
		}
196
		lo, hi = v, v
197
	}
198
199
	var bits uint64
200
	for v := lo; v <= hi; v += step {
201
		bits |= 1 << uint(v)
202
	}
203
	return bits, nil
204
}
205
206
// parseValue parses a single numeric or symbolic value, validating it
207
// against the field's [f.min, f.max] domain.
208
func parseValue(s string, f field) (int, error) {
209
	if f.names != nil {
210
		if v, ok := f.names[strings.ToLower(s)]; ok {
211
			return v, nil
212
		}
213
	}
214
	v, err := strconv.Atoi(s)
215
	if err != nil {
216
		return 0, fmt.Errorf("bad value %q", s)
217
	}
218
	if v < f.min || v > f.max {
219
		return 0, fmt.Errorf("value %d out of range [%d,%d]", v, f.min, f.max)
220
	}
221
	return v, nil
222
}
223
224
// maxLookahead caps Next's search: an unsatisfiable spec (e.g. Feb 30)
225
// yields the zero time rather than looping forever (IV1).
226
const maxLookahead = 5 * 366 * 24 * time.Hour
227
228
// Next returns the next instant strictly after t that matches every cron
229
// field, or the zero [time.Time] if none occurs within ~5 years (IV1).
230
// It operates entirely in t's location (IV4), relying on [time.Date] to
231
// normalize DST gaps and repeats rather than computing offsets by hand.
232
func (s cronSchedule) Next(t time.Time) time.Time {
233
	loc := t.Location()
234
	var cur time.Time
235
	if s.hasSec {
236
		cur = t.Truncate(time.Second).Add(time.Second)
237
	} else {
238
		// Round up to the next whole minute strictly after t.
239
		cur = t.Truncate(time.Minute).Add(time.Minute)
240
	}
241
	// Rebuild via time.Date so DST normalization is well-defined.
242
	cur = s.rebuild(cur, loc)
243
244
	limit := t.Add(maxLookahead)
245
	for cur.Before(limit) {
246
		if !bitSet(s.month, int(cur.Month())) {
247
			cur = s.rebuild(time.Date(cur.Year(), cur.Month()+1, 1, 0, 0, 0, 0, loc), loc)
248
			continue
249
		}
250
		if !s.dayMatches(cur) {
251
			cur = s.rebuild(time.Date(cur.Year(), cur.Month(), cur.Day()+1, 0, 0, 0, 0, loc), loc)
252
			continue
253
		}
254
		if !bitSet(s.hour, cur.Hour()) {
255
			cur = s.rebuild(time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour()+1, 0, 0, 0, loc), loc)
256
			continue
257
		}
258
		if !bitSet(s.min, cur.Minute()) {
259
			cur = s.rebuild(time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), cur.Minute()+1, 0, 0, loc), loc)
260
			continue
261
		}
262
		if s.hasSec && !bitSet(s.sec, cur.Second()) {
263
			cur = time.Date(cur.Year(), cur.Month(), cur.Day(), cur.Hour(), cur.Minute(), cur.Second()+1, 0, loc)
264
			continue
265
		}
266
		return cur
267
	}
268
	return time.Time{}
269
}
270
271
// rebuild reconstructs t through time.Date in loc, dropping sub-second
272
// precision below the schedule's resolution so increments stay aligned.
273
func (s cronSchedule) rebuild(t time.Time, loc *time.Location) time.Time {
274
	if s.hasSec {
275
		return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), 0, loc)
276
	}
277
	return time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), 0, 0, loc)
278
}
279
280
// dayMatches applies Vixie or-semantics: when both day-of-month and
281
// day-of-week are restricted (neither is the full range), a day matches
282
// if it satisfies either; when one is unrestricted, only the other
283
// constrains.
284
func (s cronSchedule) dayMatches(t time.Time) bool {
285
	domRestricted := s.dom != fullMask(1, 31)
286
	dowRestricted := s.dow != fullMask(0, 6)
287
	domHit := bitSet(s.dom, t.Day())
288
	dowHit := bitSet(s.dow, int(t.Weekday()))
289
290
	switch {
291
	case domRestricted && dowRestricted:
292
		return domHit || dowHit
293
	case domRestricted:
294
		return domHit
295
	case dowRestricted:
296
		return dowHit
297
	default:
298
		return true
299
	}
300
}
301
302
// bitSet reports whether bit v is set in mask.
303
func bitSet(mask uint64, v int) bool {
304
	return mask&(1<<uint(v)) != 0
305
}
306
307
// fullMask returns the bitset with every bit in [lo, hi] set, matching
308
// the parser's representation of a "*" field.
309
func fullMask(lo, hi int) uint64 {
310
	var m uint64
311
	for v := lo; v <= hi; v++ {
312
		m |= 1 << uint(v)
313
	}
314
	return m
315
}
316

Source Files