Skip to content

Commit

Permalink
Merge pull request robfig#75 from webconnex/parser-options
Browse files Browse the repository at this point in the history
Parser options
  • Loading branch information
robfig authored Sep 18, 2016
2 parents 783cfcb + edf8e83 commit 990e14e
Show file tree
Hide file tree
Showing 3 changed files with 147 additions and 79 deletions.
215 changes: 142 additions & 73 deletions parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,88 +8,111 @@ import (
"time"
)

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

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

var err error
field := func(field string, r bounds) uint64 {
if err != nil {
return uint64(0)
}
var bits uint64
bits, err = getField(field, r)
return bits
}
var (
minute = field(fields[0], minutes)
hour = field(fields[1], hours)
dayofmonth = field(fields[2], dom)
month = field(fields[3], months)
dayofweek = field(fields[4], dow)
)
if err != nil {
return nil, err
}
var places = []ParseOption{
Second,
Minute,
Hour,
Dom,
Month,
Dow,
}

return &SpecSchedule{
Second: 1 << seconds.min,
Minute: minute,
Hour: hour,
Dom: dayofmonth,
Month: month,
Dow: dayofweek,
}, nil
var defaults = []string{
"0",
"0",
"0",
"*",
"*",
"*",
}

// A custom Parser that can be configured.
type Parser struct {
options ParseOption
optionals int
}

// Creates a custom Parser with custom options.
//
// // Standard parser without descriptors
// specParser := NewParser(Minute | Hour | Dom | Month | Dow)
// sched, err := specParser.Parse("0 0 15 */3 *")
//
// // Same as above, just excludes time fields
// subsParser := NewParser(Dom | Month | Dow)
// sched, err := specParser.Parse("15 */3 *")
//
// // Same as above, just makes Dow optional
// subsParser := NewParser(Dom | Month | DowOptional)
// sched, err := specParser.Parse("15 */3")
//
func NewParser(options ParseOption) Parser {
optionals := 0
if options&DowOptional > 0 {
options |= Dow
optionals++
}
return Parser{options, optionals}
}

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

// Split on whitespace. We require 5 or 6 fields.
// (second) (minute) (hour) (day of month) (month) (day of week, optional)
fields := strings.Fields(spec)
if len(fields) != 5 && len(fields) != 6 {
return nil, fmt.Errorf("Expected 5 or 6 fields, found %d: %s", len(fields), spec)
// Figure out how many fields we need
max := 0
for _, place := range places {
if p.options&place > 0 {
max++
}
}
min := max - p.optionals

// Split fields on whitespace
fields := strings.Fields(spec)

// If a sixth field is not provided (DayOfWeek), then it is equivalent to star.
if len(fields) == 5 {
fields = append(fields, "*")
// Validate number of fields
if count := len(fields); count < min || count > max {
if min == max {
return nil, fmt.Errorf("Expected exactly %d fields, found %d: %s", min, count, spec)
}
return nil, fmt.Errorf("Expected %d to %d fields, found %d: %s", min, max, count, spec)
}

// Fill in missing fields
fields = expandFields(fields, p.options)

var err error
field := func(field string, r bounds) uint64 {
if err != nil {
return uint64(0)
return 0
}
var bits uint64
bits, err = getField(field, r)
return bits
}

var (
second = field(fields[0], seconds)
minute = field(fields[1], minutes)
Expand All @@ -112,6 +135,53 @@ func Parse(spec string) (Schedule, error) {
}, nil
}

func expandFields(fields []string, options ParseOption) []string {
n := 0
count := len(fields)
expFields := make([]string, len(places))
copy(expFields, defaults)
for i, place := range places {
if options&place > 0 {
expFields[i] = fields[n]
n++
}
if n == count {
break
}
}
return expFields
}

var standardParser = NewParser(
Minute | Hour | Dom | Month | Dow | Descriptor,
)

// ParseStandard returns a new crontab schedule representing the given standardSpec
// (https://en.wikipedia.org/wiki/Cron). It differs from Parse requiring to always
// pass 5 entries representing: minute, hour, day of month, month and day of week,
// in that order. It returns a descriptive error if the spec is not valid.
//
// It accepts
// - Standard crontab specs, e.g. "* * * * ?"
// - Descriptors, e.g. "@midnight", "@every 1h30m"
func ParseStandard(standardSpec string) (Schedule, error) {
return standardParser.Parse(standardSpec)
}

var defaultParser = NewParser(
Second | Minute | Hour | Dom | Month | DowOptional | Descriptor,
)

// Parse returns a new crontab schedule representing the given spec.
// It returns a descriptive error if the spec is not valid.
//
// It accepts
// - Full crontab specs, e.g. "* * * * * ?"
// - Descriptors, e.g. "@midnight", "@every 1h30m"
func Parse(spec string) (Schedule, error) {
return defaultParser.Parse(spec)
}

// getField returns an Int with the bits set representing all of the times that
// the field represents or error parsing field value. A "field" is a comma-separated
// list of "ranges".
Expand All @@ -138,29 +208,28 @@ func getRange(expr string, r bounds) (uint64, error) {
lowAndHigh = strings.Split(rangeAndStep[0], "-")
singleDigit = len(lowAndHigh) == 1
err error
zero = uint64(0)
)

var extra_star uint64
var extra uint64
if lowAndHigh[0] == "*" || lowAndHigh[0] == "?" {
start = r.min
end = r.max
extra_star = starBit
extra = starBit
} else {
start, err = parseIntOrName(lowAndHigh[0], r.names)
if err != nil {
return zero, err
return 0, err
}
switch len(lowAndHigh) {
case 1:
end = start
case 2:
end, err = parseIntOrName(lowAndHigh[1], r.names)
if err != nil {
return zero, err
return 0, err
}
default:
return zero, fmt.Errorf("Too many hyphens: %s", expr)
return 0, fmt.Errorf("Too many hyphens: %s", expr)
}
}

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

// Special handling: "N/step" means "N-max/step".
if singleDigit {
end = r.max
}
default:
return zero, fmt.Errorf("Too many slashes: %s", expr)
return 0, fmt.Errorf("Too many slashes: %s", expr)
}

if start < r.min {
return zero, fmt.Errorf("Beginning of range (%d) below minimum (%d): %s", start, r.min, expr)
return 0, fmt.Errorf("Beginning of range (%d) below minimum (%d): %s", start, r.min, expr)
}
if end > r.max {
return zero, fmt.Errorf("End of range (%d) above maximum (%d): %s", end, r.max, expr)
return 0, fmt.Errorf("End of range (%d) above maximum (%d): %s", end, r.max, expr)
}
if start > end {
return zero, fmt.Errorf("Beginning of range (%d) beyond end of range (%d): %s", start, end, expr)
return 0, fmt.Errorf("Beginning of range (%d) beyond end of range (%d): %s", start, end, expr)
}
if step == 0 {
return zero, fmt.Errorf("Step of range should be a positive number: %s", expr)
return 0, fmt.Errorf("Step of range should be a positive number: %s", expr)
}

return getBits(start, end, step) | extra_star, nil
return getBits(start, end, step) | extra, nil
}

// parseIntOrName returns the (possibly-named) integer contained in expr.
Expand Down
10 changes: 5 additions & 5 deletions parser_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,17 +173,17 @@ func TestParse(t *testing.T) {
},
{
expr: "* * * *",
err: "Expected 5 or 6 fields",
err: "Expected 5 to 6 fields",
},
}

for _, c := range entries {
actual, err := Parse(c.expr)
if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) {
t.Error("%s => expected %v, got %v", c.expr, c.err, err)
t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
}
if len(c.err) == 0 && err != nil {
t.Error("%s => unexpected error %v", c.expr, err)
t.Errorf("%s => unexpected error %v", c.expr, err)
}
if !reflect.DeepEqual(actual, c.expected) {
t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual)
Expand Down Expand Up @@ -218,10 +218,10 @@ func TestStandardSpecSchedule(t *testing.T) {
for _, c := range entries {
actual, err := ParseStandard(c.expr)
if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) {
t.Error("%s => expected %v, got %v", c.expr, c.err, err)
t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
}
if len(c.err) == 0 && err != nil {
t.Error("%s => unexpected error %v", c.expr, err)
t.Errorf("%s => unexpected error %v", c.expr, err)
}
if !reflect.DeepEqual(actual, c.expected) {
t.Errorf("%s => expected %b, got %b", c.expr, c.expected, actual)
Expand Down
1 change: 0 additions & 1 deletion spec.go
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,6 @@ func dayMatches(s *SpecSchedule, t time.Time) bool {
domMatch bool = 1<<uint(t.Day())&s.Dom > 0
dowMatch bool = 1<<uint(t.Weekday())&s.Dow > 0
)

if s.Dom&starBit > 0 || s.Dow&starBit > 0 {
return domMatch && dowMatch
}
Expand Down

0 comments on commit 990e14e

Please sign in to comment.