Skip to content

Commit 990e14e

Browse files
authored
Merge pull request robfig#75 from webconnex/parser-options
Parser options
2 parents 783cfcb + edf8e83 commit 990e14e

File tree

3 files changed

+147
-79
lines changed

3 files changed

+147
-79
lines changed

parser.go

Lines changed: 142 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -8,88 +8,111 @@ import (
88
"time"
99
)
1010

11-
// ParseStandard returns a new crontab schedule representing the given standardSpec
12-
// (https://en.wikipedia.org/wiki/Cron). It differs from Parse requiring to always
13-
// pass 5 entries representing: minute, hour, day of month, month and day of week,
14-
// in that order. It returns a descriptive error if the spec is not valid.
15-
//
16-
// It accepts
17-
// - Standard crontab specs, e.g. "* * * * ?"
18-
// - Descriptors, e.g. "@midnight", "@every 1h30m"
19-
func ParseStandard(standardSpec string) (Schedule, error) {
20-
if standardSpec[0] == '@' {
21-
return parseDescriptor(standardSpec)
22-
}
11+
// Configuration options for creating a parser. Most options specify which
12+
// fields should be included, while others enable features. If a field is not
13+
// included the parser will assume a default value. These options do not change
14+
// the order fields are parse in.
15+
type ParseOption int
2316

24-
// Split on whitespace. We require exactly 5 fields.
25-
// (minute) (hour) (day of month) (month) (day of week)
26-
fields := strings.Fields(standardSpec)
27-
if len(fields) != 5 {
28-
return nil, fmt.Errorf("Expected exactly 5 fields, found %d: %s", len(fields), standardSpec)
29-
}
17+
const (
18+
Second ParseOption = 1 << iota // Seconds field, default 0
19+
Minute // Minutes field, default 0
20+
Hour // Hours field, default 0
21+
Dom // Day of month field, default *
22+
Month // Month field, default *
23+
Dow // Day of week field, default *
24+
DowOptional // Optional day of week field, default *
25+
Descriptor // Allow descriptors such as @monthly, @weekly, etc.
26+
)
3027

31-
var err error
32-
field := func(field string, r bounds) uint64 {
33-
if err != nil {
34-
return uint64(0)
35-
}
36-
var bits uint64
37-
bits, err = getField(field, r)
38-
return bits
39-
}
40-
var (
41-
minute = field(fields[0], minutes)
42-
hour = field(fields[1], hours)
43-
dayofmonth = field(fields[2], dom)
44-
month = field(fields[3], months)
45-
dayofweek = field(fields[4], dow)
46-
)
47-
if err != nil {
48-
return nil, err
49-
}
28+
var places = []ParseOption{
29+
Second,
30+
Minute,
31+
Hour,
32+
Dom,
33+
Month,
34+
Dow,
35+
}
5036

51-
return &SpecSchedule{
52-
Second: 1 << seconds.min,
53-
Minute: minute,
54-
Hour: hour,
55-
Dom: dayofmonth,
56-
Month: month,
57-
Dow: dayofweek,
58-
}, nil
37+
var defaults = []string{
38+
"0",
39+
"0",
40+
"0",
41+
"*",
42+
"*",
43+
"*",
44+
}
45+
46+
// A custom Parser that can be configured.
47+
type Parser struct {
48+
options ParseOption
49+
optionals int
50+
}
51+
52+
// Creates a custom Parser with custom options.
53+
//
54+
// // Standard parser without descriptors
55+
// specParser := NewParser(Minute | Hour | Dom | Month | Dow)
56+
// sched, err := specParser.Parse("0 0 15 */3 *")
57+
//
58+
// // Same as above, just excludes time fields
59+
// subsParser := NewParser(Dom | Month | Dow)
60+
// sched, err := specParser.Parse("15 */3 *")
61+
//
62+
// // Same as above, just makes Dow optional
63+
// subsParser := NewParser(Dom | Month | DowOptional)
64+
// sched, err := specParser.Parse("15 */3")
65+
//
66+
func NewParser(options ParseOption) Parser {
67+
optionals := 0
68+
if options&DowOptional > 0 {
69+
options |= Dow
70+
optionals++
71+
}
72+
return Parser{options, optionals}
5973
}
6074

6175
// Parse returns a new crontab schedule representing the given spec.
6276
// It returns a descriptive error if the spec is not valid.
63-
//
64-
// It accepts
65-
// - Full crontab specs, e.g. "* * * * * ?"
66-
// - Descriptors, e.g. "@midnight", "@every 1h30m"
67-
func Parse(spec string) (Schedule, error) {
68-
if spec[0] == '@' {
77+
// It accepts crontab specs and features configured by NewParser.
78+
func (p Parser) Parse(spec string) (Schedule, error) {
79+
if spec[0] == '@' && p.options&Descriptor > 0 {
6980
return parseDescriptor(spec)
7081
}
7182

72-
// Split on whitespace. We require 5 or 6 fields.
73-
// (second) (minute) (hour) (day of month) (month) (day of week, optional)
74-
fields := strings.Fields(spec)
75-
if len(fields) != 5 && len(fields) != 6 {
76-
return nil, fmt.Errorf("Expected 5 or 6 fields, found %d: %s", len(fields), spec)
83+
// Figure out how many fields we need
84+
max := 0
85+
for _, place := range places {
86+
if p.options&place > 0 {
87+
max++
88+
}
7789
}
90+
min := max - p.optionals
91+
92+
// Split fields on whitespace
93+
fields := strings.Fields(spec)
7894

79-
// If a sixth field is not provided (DayOfWeek), then it is equivalent to star.
80-
if len(fields) == 5 {
81-
fields = append(fields, "*")
95+
// Validate number of fields
96+
if count := len(fields); count < min || count > max {
97+
if min == max {
98+
return nil, fmt.Errorf("Expected exactly %d fields, found %d: %s", min, count, spec)
99+
}
100+
return nil, fmt.Errorf("Expected %d to %d fields, found %d: %s", min, max, count, spec)
82101
}
83102

103+
// Fill in missing fields
104+
fields = expandFields(fields, p.options)
105+
84106
var err error
85107
field := func(field string, r bounds) uint64 {
86108
if err != nil {
87-
return uint64(0)
109+
return 0
88110
}
89111
var bits uint64
90112
bits, err = getField(field, r)
91113
return bits
92114
}
115+
93116
var (
94117
second = field(fields[0], seconds)
95118
minute = field(fields[1], minutes)
@@ -112,6 +135,53 @@ func Parse(spec string) (Schedule, error) {
112135
}, nil
113136
}
114137

138+
func expandFields(fields []string, options ParseOption) []string {
139+
n := 0
140+
count := len(fields)
141+
expFields := make([]string, len(places))
142+
copy(expFields, defaults)
143+
for i, place := range places {
144+
if options&place > 0 {
145+
expFields[i] = fields[n]
146+
n++
147+
}
148+
if n == count {
149+
break
150+
}
151+
}
152+
return expFields
153+
}
154+
155+
var standardParser = NewParser(
156+
Minute | Hour | Dom | Month | Dow | Descriptor,
157+
)
158+
159+
// ParseStandard returns a new crontab schedule representing the given standardSpec
160+
// (https://en.wikipedia.org/wiki/Cron). It differs from Parse requiring to always
161+
// pass 5 entries representing: minute, hour, day of month, month and day of week,
162+
// in that order. It returns a descriptive error if the spec is not valid.
163+
//
164+
// It accepts
165+
// - Standard crontab specs, e.g. "* * * * ?"
166+
// - Descriptors, e.g. "@midnight", "@every 1h30m"
167+
func ParseStandard(standardSpec string) (Schedule, error) {
168+
return standardParser.Parse(standardSpec)
169+
}
170+
171+
var defaultParser = NewParser(
172+
Second | Minute | Hour | Dom | Month | DowOptional | Descriptor,
173+
)
174+
175+
// Parse returns a new crontab schedule representing the given spec.
176+
// It returns a descriptive error if the spec is not valid.
177+
//
178+
// It accepts
179+
// - Full crontab specs, e.g. "* * * * * ?"
180+
// - Descriptors, e.g. "@midnight", "@every 1h30m"
181+
func Parse(spec string) (Schedule, error) {
182+
return defaultParser.Parse(spec)
183+
}
184+
115185
// getField returns an Int with the bits set representing all of the times that
116186
// the field represents or error parsing field value. A "field" is a comma-separated
117187
// list of "ranges".
@@ -138,29 +208,28 @@ func getRange(expr string, r bounds) (uint64, error) {
138208
lowAndHigh = strings.Split(rangeAndStep[0], "-")
139209
singleDigit = len(lowAndHigh) == 1
140210
err error
141-
zero = uint64(0)
142211
)
143212

144-
var extra_star uint64
213+
var extra uint64
145214
if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" {
146215
start = r.min
147216
end = r.max
148-
extra_star = starBit
217+
extra = starBit
149218
} else {
150219
start, err = parseIntOrName(lowAndHigh[0], r.names)
151220
if err != nil {
152-
return zero, err
221+
return 0, err
153222
}
154223
switch len(lowAndHigh) {
155224
case 1:
156225
end = start
157226
case 2:
158227
end, err = parseIntOrName(lowAndHigh[1], r.names)
159228
if err != nil {
160-
return zero, err
229+
return 0, err
161230
}
162231
default:
163-
return zero, fmt.Errorf("Too many hyphens: %s", expr)
232+
return 0, fmt.Errorf("Too many hyphens: %s", expr)
164233
}
165234
}
166235

@@ -170,31 +239,31 @@ func getRange(expr string, r bounds) (uint64, error) {
170239
case 2:
171240
step, err = mustParseInt(rangeAndStep[1])
172241
if err != nil {
173-
return zero, err
242+
return 0, err
174243
}
175244

176245
// Special handling: "N/step" means "N-max/step".
177246
if singleDigit {
178247
end = r.max
179248
}
180249
default:
181-
return zero, fmt.Errorf("Too many slashes: %s", expr)
250+
return 0, fmt.Errorf("Too many slashes: %s", expr)
182251
}
183252

184253
if start < r.min {
185-
return zero, fmt.Errorf("Beginning of range (%d) below minimum (%d): %s", start, r.min, expr)
254+
return 0, fmt.Errorf("Beginning of range (%d) below minimum (%d): %s", start, r.min, expr)
186255
}
187256
if end > r.max {
188-
return zero, fmt.Errorf("End of range (%d) above maximum (%d): %s", end, r.max, expr)
257+
return 0, fmt.Errorf("End of range (%d) above maximum (%d): %s", end, r.max, expr)
189258
}
190259
if start > end {
191-
return zero, fmt.Errorf("Beginning of range (%d) beyond end of range (%d): %s", start, end, expr)
260+
return 0, fmt.Errorf("Beginning of range (%d) beyond end of range (%d): %s", start, end, expr)
192261
}
193262
if step == 0 {
194-
return zero, fmt.Errorf("Step of range should be a positive number: %s", expr)
263+
return 0, fmt.Errorf("Step of range should be a positive number: %s", expr)
195264
}
196265

197-
return getBits(start, end, step) | extra_star, nil
266+
return getBits(start, end, step) | extra, nil
198267
}
199268

200269
// parseIntOrName returns the (possibly-named) integer contained in expr.

parser_test.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -173,17 +173,17 @@ func TestParse(t *testing.T) {
173173
},
174174
{
175175
expr: "* * * *",
176-
err: "Expected 5 or 6 fields",
176+
err: "Expected 5 to 6 fields",
177177
},
178178
}
179179

180180
for _, c := range entries {
181181
actual, err := Parse(c.expr)
182182
if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) {
183-
t.Error("%s => expected %v, got %v", c.expr, c.err, err)
183+
t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
184184
}
185185
if len(c.err) == 0 && err != nil {
186-
t.Error("%s => unexpected error %v", c.expr, err)
186+
t.Errorf("%s => unexpected error %v", c.expr, err)
187187
}
188188
if !reflect.DeepEqual(actual, c.expected) {
189189
t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual)
@@ -218,10 +218,10 @@ func TestStandardSpecSchedule(t *testing.T) {
218218
for _, c := range entries {
219219
actual, err := ParseStandard(c.expr)
220220
if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) {
221-
t.Error("%s => expected %v, got %v", c.expr, c.err, err)
221+
t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
222222
}
223223
if len(c.err) == 0 && err != nil {
224-
t.Error("%s => unexpected error %v", c.expr, err)
224+
t.Errorf("%s => unexpected error %v", c.expr, err)
225225
}
226226
if !reflect.DeepEqual(actual, c.expected) {
227227
t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual)

spec.go

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -151,7 +151,6 @@ func dayMatches(s *SpecSchedule, t time.Time) bool {
151151
domMatch bool = 1<<uint(t.Day())&s.Dom > 0
152152
dowMatch bool = 1<<uint(t.Weekday())&s.Dow > 0
153153
)
154-
155154
if s.Dom&starBit > 0 || s.Dow&starBit > 0 {
156155
return domMatch && dowMatch
157156
}

0 commit comments

Comments
 (0)