Browse Source

Merge pull request #75 from webconnex/parser-options

Parser options
Rob Figueiredo 9 years ago
parent
commit
990e14eb2f
3 changed files with 147 additions and 79 deletions
  1. 142 73
      parser.go
  2. 5 5
      parser_test.go
  3. 0 1
      spec.go

+ 142 - 73
parser.go

@@ -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)
@@ -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".
@@ -138,18 +208,17 @@ 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:
@@ -157,10 +226,10 @@ func getRange(expr string, r bounds) (uint64, error) {
 		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)
 		}
 	}
 
@@ -170,7 +239,7 @@ 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".
@@ -178,23 +247,23 @@ func getRange(expr string, r bounds) (uint64, error) {
 			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.

+ 5 - 5
parser_test.go

@@ -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)
@@ -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)

+ 0 - 1
spec.go

@@ -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
 	}