Browse Source

Functional options, Optional seconds, Overridable parser

1. Allow specifying the Parser that Cron uses for interpreting job specs.
2. Implement functional options for Location, Parser, and ErrorLogger
3. Implement optional seconds (again)
4. Update errors to follow the recommended style of starting with lowercase

Implementation notes:

- Centralize the handling of default and optional fields into a
  single "normalizeFields" function and add a test suite for it.

- Remove the NewWithLocation constructor since it is superseded

- Remove the defaultParser which accepts seconds. Update tests to use a custom
  parser instead.

- Update docs to reflect these changes.
Rob Figueiredo 6 years ago
parent
commit
1f8ec97c87
9 changed files with 460 additions and 150 deletions
  1. 23 9
      README.md
  2. 32 20
      cron.go
  3. 41 19
      cron_test.go
  4. 13 6
      doc.go
  5. 30 0
      option.go
  6. 32 0
      option_test.go
  7. 122 67
      parser.go
  8. 166 28
      parser_test.go
  9. 1 1
      spec_test.go

+ 23 - 9
README.md

@@ -3,24 +3,38 @@
 
 
 # cron
 # cron
 
 
-Documentation here: https://godoc.org/github.com/robfig/cron
-
 ## DRAFT - Upgrading to v3
 ## DRAFT - Upgrading to v3
 
 
 cron v3 is a major upgrade to the library that addresses all outstanding bugs,
 cron v3 is a major upgrade to the library that addresses all outstanding bugs,
 feature requests, and clarifications around usage. It is based on a merge of
 feature requests, and clarifications around usage. It is based on a merge of
-master (containing various fixes) and the v2 branch (containing a couple new
-features), with the addition of Go Modules support. It is currently in
-development.
+master which contains various fixes to issues found over the years and the v2
+branch which contains some backwards-incompatible features like removing cron
+jobs. In addition, it adds support for Go Modules and cleans up rough edges like
+the timezone support.
+
+It is in development and will be considered released once a 3.0 version is
+tagged. It is backwards incompatible with both the v1 and v2 branches.
 
 
-These are the updates required:
+Updates required:
 
 
 - The v1 branch accepted an optional seconds field at the beginning of the cron
 - The v1 branch accepted an optional seconds field at the beginning of the cron
   spec. This is non-standard and has led to a lot of confusion. The new default
   spec. This is non-standard and has led to a lot of confusion. The new default
-  parser conforms to the standard as described by
-  [the Cron wikipedia page]. This behavior is not currently supported in v3.
+  parser conforms to the standard as described by [the Cron wikipedia page].
+
+  UPDATING: To retain the old behavior, construct your Cron with a custom
+  parser:
+
+      cron.New(
+          cron.WithParser(
+              cron.SecondOptional | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor))
+
+- The Cron type now accepts functional options on construction rather than the
+  ad-hoc behavior modification mechanisms before (setting a field, calling a setter).
+
+  UPDATING: Code that sets Cron.ErrorLogger or calls Cron.SetLocation must be
+  updated to provide those values on construction.
 
 
-### Cron spec format
+### Background - Cron spec format
 
 
 There are two cron spec formats in common usage:
 There are two cron spec formats in common usage:
 
 

+ 32 - 20
cron.go

@@ -2,6 +2,7 @@ package cron
 
 
 import (
 import (
 	"log"
 	"log"
+	"os"
 	"runtime"
 	"runtime"
 	"sort"
 	"sort"
 	"time"
 	"time"
@@ -17,8 +18,9 @@ type Cron struct {
 	remove   chan EntryID
 	remove   chan EntryID
 	snapshot chan []Entry
 	snapshot chan []Entry
 	running  bool
 	running  bool
-	ErrorLog *log.Logger
+	logger   *log.Logger
 	location *time.Location
 	location *time.Location
+	parser   Parser
 	nextID   EntryID
 	nextID   EntryID
 }
 }
 
 
@@ -79,25 +81,39 @@ func (s byTime) Less(i, j int) bool {
 	return s[i].Next.Before(s[j].Next)
 	return s[i].Next.Before(s[j].Next)
 }
 }
 
 
-// New returns a new Cron job runner.
-// Jobs added to this cron are interpreted in the Local time zone by default.
-func New() *Cron {
-	return NewWithLocation(time.Local)
-}
-
-// NewWithLocation returns a new Cron job runner in the given time zone.
-// Jobs added to this cron are interpreted in this time zone unless overridden.
-func NewWithLocation(location *time.Location) *Cron {
-	return &Cron{
+// New returns a new Cron job runner, modified by the given options.
+//
+// Available Settings
+//
+//   Time Zone
+//     Description: The time zone in which schedules are interpreted
+//     Default:     time.Local
+//
+//   PanicLogger
+//     Description: How to log Jobs that panic
+//     Default:     Log the panic to os.Stderr
+//
+//   Parser
+//     Description:
+//     Default:     Parser that accepts the spec described here: https://en.wikipedia.org/wiki/Cron
+//
+// See "cron.With*" to modify the default behavior.
+func New(opts ...Option) *Cron {
+	c := &Cron{
 		entries:  nil,
 		entries:  nil,
 		add:      make(chan *Entry),
 		add:      make(chan *Entry),
 		stop:     make(chan struct{}),
 		stop:     make(chan struct{}),
 		snapshot: make(chan []Entry),
 		snapshot: make(chan []Entry),
 		remove:   make(chan EntryID),
 		remove:   make(chan EntryID),
 		running:  false,
 		running:  false,
-		ErrorLog: nil,
-		location: location,
+		logger:   log.New(os.Stderr, "", log.LstdFlags),
+		location: time.Local,
+		parser:   standardParser,
+	}
+	for _, opt := range opts {
+		opt(c)
 	}
 	}
+	return c
 }
 }
 
 
 // FuncJob is a wrapper that turns a func() into a cron.Job
 // FuncJob is a wrapper that turns a func() into a cron.Job
@@ -116,7 +132,7 @@ func (c *Cron) AddFunc(spec string, cmd func()) (EntryID, error) {
 // The spec is parsed using the time zone of this Cron instance as the default.
 // The spec is parsed using the time zone of this Cron instance as the default.
 // An opaque ID is returned that can be used to later remove it.
 // An opaque ID is returned that can be used to later remove it.
 func (c *Cron) AddJob(spec string, cmd Job) (EntryID, error) {
 func (c *Cron) AddJob(spec string, cmd Job) (EntryID, error) {
-	schedule, err := Parse(spec)
+	schedule, err := c.parser.Parse(spec)
 	if err != nil {
 	if err != nil {
 		return 0, err
 		return 0, err
 	}
 	}
@@ -172,7 +188,7 @@ func (c *Cron) Remove(id EntryID) {
 	}
 	}
 }
 }
 
 
-// Start the cron scheduler in its own go-routine, or no-op if already started.
+// Start the cron scheduler in its own goroutine, or no-op if already started.
 func (c *Cron) Start() {
 func (c *Cron) Start() {
 	if c.running {
 	if c.running {
 		return
 		return
@@ -269,11 +285,7 @@ func (c *Cron) now() time.Time {
 
 
 // Logs an error to stderr or to the configured error log
 // Logs an error to stderr or to the configured error log
 func (c *Cron) logf(format string, args ...interface{}) {
 func (c *Cron) logf(format string, args ...interface{}) {
-	if c.ErrorLog != nil {
-		c.ErrorLog.Printf(format, args...)
-	} else {
-		log.Printf(format, args...)
-	}
+	c.logger.Printf(format, args...)
 }
 }
 
 
 // Stop stops the cron scheduler if it is running; otherwise it does nothing.
 // Stop stops the cron scheduler if it is running; otherwise it does nothing.

+ 41 - 19
cron_test.go

@@ -1,9 +1,12 @@
 package cron
 package cron
 
 
 import (
 import (
+	"bytes"
 	"fmt"
 	"fmt"
+	"log"
 	"sync"
 	"sync"
 	"testing"
 	"testing"
+	"strings"
 	"time"
 	"time"
 )
 )
 
 
@@ -12,14 +15,24 @@ import (
 // compensate for a few milliseconds of runtime.
 // compensate for a few milliseconds of runtime.
 const OneSecond = 1*time.Second + 10*time.Millisecond
 const OneSecond = 1*time.Second + 10*time.Millisecond
 
 
+func newBufLogger(buf *bytes.Buffer) *log.Logger {
+	return log.New(buf, "", log.LstdFlags)
+}
+
 func TestFuncPanicRecovery(t *testing.T) {
 func TestFuncPanicRecovery(t *testing.T) {
-	cron := New()
+	var buf bytes.Buffer
+	cron := New(WithParser(secondParser), WithPanicLogger(newBufLogger(&buf)))
 	cron.Start()
 	cron.Start()
 	defer cron.Stop()
 	defer cron.Stop()
-	cron.AddFunc("* * * * * ?", func() { panic("YOLO") })
+	cron.AddFunc("* * * * * ?", func() {
+		panic("YOLO")
+	})
 
 
 	select {
 	select {
 	case <-time.After(OneSecond):
 	case <-time.After(OneSecond):
+		if !strings.Contains(buf.String(), "YOLO") {
+			t.Error("expected a panic to be logged, got none")
+		}
 		return
 		return
 	}
 	}
 }
 }
@@ -33,20 +46,24 @@ func (d DummyJob) Run() {
 func TestJobPanicRecovery(t *testing.T) {
 func TestJobPanicRecovery(t *testing.T) {
 	var job DummyJob
 	var job DummyJob
 
 
-	cron := New()
+	var buf bytes.Buffer
+	cron := New(WithParser(secondParser), WithPanicLogger(newBufLogger(&buf)))
 	cron.Start()
 	cron.Start()
 	defer cron.Stop()
 	defer cron.Stop()
 	cron.AddJob("* * * * * ?", job)
 	cron.AddJob("* * * * * ?", job)
 
 
 	select {
 	select {
 	case <-time.After(OneSecond):
 	case <-time.After(OneSecond):
+		if !strings.Contains(buf.String(), "YOLO") {
+			t.Error("expected a panic to be logged, got none")
+		}
 		return
 		return
 	}
 	}
 }
 }
 
 
 // Start and stop cron with no entries.
 // Start and stop cron with no entries.
 func TestNoEntries(t *testing.T) {
 func TestNoEntries(t *testing.T) {
-	cron := New()
+	cron := newWithSeconds()
 	cron.Start()
 	cron.Start()
 
 
 	select {
 	select {
@@ -61,7 +78,7 @@ func TestStopCausesJobsToNotRun(t *testing.T) {
 	wg := &sync.WaitGroup{}
 	wg := &sync.WaitGroup{}
 	wg.Add(1)
 	wg.Add(1)
 
 
-	cron := New()
+	cron := newWithSeconds()
 	cron.Start()
 	cron.Start()
 	cron.Stop()
 	cron.Stop()
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
@@ -79,7 +96,7 @@ func TestAddBeforeRunning(t *testing.T) {
 	wg := &sync.WaitGroup{}
 	wg := &sync.WaitGroup{}
 	wg.Add(1)
 	wg.Add(1)
 
 
-	cron := New()
+	cron := newWithSeconds()
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	cron.Start()
 	cron.Start()
 	defer cron.Stop()
 	defer cron.Stop()
@@ -97,7 +114,7 @@ func TestAddWhileRunning(t *testing.T) {
 	wg := &sync.WaitGroup{}
 	wg := &sync.WaitGroup{}
 	wg.Add(1)
 	wg.Add(1)
 
 
-	cron := New()
+	cron := newWithSeconds()
 	cron.Start()
 	cron.Start()
 	defer cron.Stop()
 	defer cron.Stop()
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
@@ -111,7 +128,7 @@ func TestAddWhileRunning(t *testing.T) {
 
 
 // Test for #34. Adding a job after calling start results in multiple job invocations
 // Test for #34. Adding a job after calling start results in multiple job invocations
 func TestAddWhileRunningWithDelay(t *testing.T) {
 func TestAddWhileRunningWithDelay(t *testing.T) {
-	cron := New()
+	cron := newWithSeconds()
 	cron.Start()
 	cron.Start()
 	defer cron.Stop()
 	defer cron.Stop()
 	time.Sleep(5 * time.Second)
 	time.Sleep(5 * time.Second)
@@ -129,7 +146,7 @@ func TestRemoveBeforeRunning(t *testing.T) {
 	wg := &sync.WaitGroup{}
 	wg := &sync.WaitGroup{}
 	wg.Add(1)
 	wg.Add(1)
 
 
-	cron := New()
+	cron := newWithSeconds()
 	id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	cron.Remove(id)
 	cron.Remove(id)
 	cron.Start()
 	cron.Start()
@@ -148,7 +165,7 @@ func TestRemoveWhileRunning(t *testing.T) {
 	wg := &sync.WaitGroup{}
 	wg := &sync.WaitGroup{}
 	wg.Add(1)
 	wg.Add(1)
 
 
-	cron := New()
+	cron := newWithSeconds()
 	cron.Start()
 	cron.Start()
 	defer cron.Stop()
 	defer cron.Stop()
 	id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() })
@@ -193,7 +210,7 @@ func TestMultipleEntries(t *testing.T) {
 	wg := &sync.WaitGroup{}
 	wg := &sync.WaitGroup{}
 	wg.Add(2)
 	wg.Add(2)
 
 
-	cron := New()
+	cron := newWithSeconds()
 	cron.AddFunc("0 0 0 1 1 ?", func() {})
 	cron.AddFunc("0 0 0 1 1 ?", func() {})
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	id1, _ := cron.AddFunc("* * * * * ?", func() { t.Fatal() })
 	id1, _ := cron.AddFunc("* * * * * ?", func() { t.Fatal() })
@@ -218,7 +235,7 @@ func TestRunningJobTwice(t *testing.T) {
 	wg := &sync.WaitGroup{}
 	wg := &sync.WaitGroup{}
 	wg.Add(2)
 	wg.Add(2)
 
 
-	cron := New()
+	cron := newWithSeconds()
 	cron.AddFunc("0 0 0 1 1 ?", func() {})
 	cron.AddFunc("0 0 0 1 1 ?", func() {})
 	cron.AddFunc("0 0 0 31 12 ?", func() {})
 	cron.AddFunc("0 0 0 31 12 ?", func() {})
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
@@ -237,7 +254,7 @@ func TestRunningMultipleSchedules(t *testing.T) {
 	wg := &sync.WaitGroup{}
 	wg := &sync.WaitGroup{}
 	wg.Add(2)
 	wg.Add(2)
 
 
-	cron := New()
+	cron := newWithSeconds()
 	cron.AddFunc("0 0 0 1 1 ?", func() {})
 	cron.AddFunc("0 0 0 1 1 ?", func() {})
 	cron.AddFunc("0 0 0 31 12 ?", func() {})
 	cron.AddFunc("0 0 0 31 12 ?", func() {})
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
@@ -264,7 +281,7 @@ func TestLocalTimezone(t *testing.T) {
 	spec := fmt.Sprintf("%d,%d %d %d %d %d ?",
 	spec := fmt.Sprintf("%d,%d %d %d %d %d ?",
 		now.Second()+1, now.Second()+2, now.Minute(), now.Hour(), now.Day(), now.Month())
 		now.Second()+1, now.Second()+2, now.Minute(), now.Hour(), now.Day(), now.Month())
 
 
-	cron := New()
+	cron := newWithSeconds()
 	cron.AddFunc(spec, func() { wg.Done() })
 	cron.AddFunc(spec, func() { wg.Done() })
 	cron.Start()
 	cron.Start()
 	defer cron.Stop()
 	defer cron.Stop()
@@ -291,7 +308,7 @@ func TestNonLocalTimezone(t *testing.T) {
 	spec := fmt.Sprintf("%d,%d %d %d %d %d ?",
 	spec := fmt.Sprintf("%d,%d %d %d %d %d ?",
 		now.Second()+1, now.Second()+2, now.Minute(), now.Hour(), now.Day(), now.Month())
 		now.Second()+1, now.Second()+2, now.Minute(), now.Hour(), now.Day(), now.Month())
 
 
-	cron := NewWithLocation(loc)
+	cron := New(WithLocation(loc), WithParser(secondParser))
 	cron.AddFunc(spec, func() { wg.Done() })
 	cron.AddFunc(spec, func() { wg.Done() })
 	cron.Start()
 	cron.Start()
 	defer cron.Stop()
 	defer cron.Stop()
@@ -333,7 +350,7 @@ func TestBlockingRun(t *testing.T) {
 	wg := &sync.WaitGroup{}
 	wg := &sync.WaitGroup{}
 	wg.Add(1)
 	wg.Add(1)
 
 
-	cron := New()
+	cron := newWithSeconds()
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
 
 
 	var unblockChan = make(chan struct{})
 	var unblockChan = make(chan struct{})
@@ -357,7 +374,7 @@ func TestBlockingRun(t *testing.T) {
 func TestStartNoop(t *testing.T) {
 func TestStartNoop(t *testing.T) {
 	var tickChan = make(chan struct{}, 2)
 	var tickChan = make(chan struct{}, 2)
 
 
-	cron := New()
+	cron := newWithSeconds()
 	cron.AddFunc("* * * * * ?", func() {
 	cron.AddFunc("* * * * * ?", func() {
 		tickChan <- struct{}{}
 		tickChan <- struct{}{}
 	})
 	})
@@ -385,7 +402,7 @@ func TestJob(t *testing.T) {
 	wg := &sync.WaitGroup{}
 	wg := &sync.WaitGroup{}
 	wg.Add(1)
 	wg.Add(1)
 
 
-	cron := New()
+	cron := newWithSeconds()
 	cron.AddJob("0 0 0 30 Feb ?", testJob{wg, "job0"})
 	cron.AddJob("0 0 0 30 Feb ?", testJob{wg, "job0"})
 	cron.AddJob("0 0 0 1 1 ?", testJob{wg, "job1"})
 	cron.AddJob("0 0 0 1 1 ?", testJob{wg, "job1"})
 	cron.AddJob("* * * * * ?", testJob{wg, "job2"})
 	cron.AddJob("* * * * * ?", testJob{wg, "job2"})
@@ -425,7 +442,7 @@ func (*ZeroSchedule) Next(time.Time) time.Time {
 
 
 // Tests that job without time does not run
 // Tests that job without time does not run
 func TestJobWithZeroTimeDoesNotRun(t *testing.T) {
 func TestJobWithZeroTimeDoesNotRun(t *testing.T) {
-	cron := New()
+	cron := newWithSeconds()
 	calls := 0
 	calls := 0
 	cron.AddFunc("* * * * * *", func() { calls += 1 })
 	cron.AddFunc("* * * * * *", func() { calls += 1 })
 	cron.Schedule(new(ZeroSchedule), FuncJob(func() { t.Error("expected zero task will not run") }))
 	cron.Schedule(new(ZeroSchedule), FuncJob(func() { t.Error("expected zero task will not run") }))
@@ -454,3 +471,8 @@ func stop(cron *Cron) chan bool {
 	}()
 	}()
 	return ch
 	return ch
 }
 }
+
+// newWithSeconds returns a Cron with the seconds field enabled.
+func newWithSeconds() *Cron {
+	return New(WithParser(secondParser))
+}

+ 13 - 6
doc.go

@@ -44,8 +44,12 @@ The specific interpretation of the format is based on [the Cron Wikipedia page].
 
 
 Alternative Formats
 Alternative Formats
 
 
-Alternative Cron expression formats (like [Quartz]) support other fields (like
-seconds). You can implement that by [creating a custom Parser](#NewParser).
+Alternative Cron expression formats (like [Quartz]) support other fields like
+seconds. You can implement that by [creating a custom Parser](#NewParser), e.g.
+
+      cron.New(
+          cron.WithParser(
+              cron.SecondOptional | cron.Hour | cron.Dom | cron.Month | cron.Dow | cron.Descriptor))
 
 
 [Quartz]: http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html
 [Quartz]: http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html
 
 
@@ -114,7 +118,10 @@ Time zones
 
 
 By default, all interpretation and scheduling is done in the machine's local
 By default, all interpretation and scheduling is done in the machine's local
 time zone ([time.Local](https://golang.org/pkg/time/#Location)). You can change
 time zone ([time.Local](https://golang.org/pkg/time/#Location)). You can change
-that default using [Cron.SetLocation](#Cron.SetLocation).
+that default using the [WithLocation](#WithLocation) option:
+
+      cron.New(
+          cron.WithLocation(time.UTC))
 
 
 Individual cron schedules may also override the time zone they are to be
 Individual cron schedules may also override the time zone they are to be
 interpreted in by providing an additional space-separated field at the beginning
 interpreted in by providing an additional space-separated field at the beginning
@@ -126,15 +133,15 @@ For example:
 	cron.New().AddFunc("0 6 * * ?", ...)
 	cron.New().AddFunc("0 6 * * ?", ...)
 
 
 	# Runs at 6am in America/New_York
 	# Runs at 6am in America/New_York
-	c := cron.New()
-	c.SetLocation("America/New_York")
+	nyc, _ := time.LoadLocation("America/New_York")
+	c := cron.New(cron.WithLocation(nyc))
 	c.AddFunc("0 6 * * ?", ...)
 	c.AddFunc("0 6 * * ?", ...)
 
 
 	# Runs at 6am in Asia/Tokyo
 	# Runs at 6am in Asia/Tokyo
 	cron.New().AddFunc("TZ=Asia/Tokyo 0 6 * * ?", ...)
 	cron.New().AddFunc("TZ=Asia/Tokyo 0 6 * * ?", ...)
 
 
 	# Runs at 6am in Asia/Tokyo
 	# Runs at 6am in Asia/Tokyo
-	c := cron.New()
+	c := cron.New(cron.WithLocation(nyc))
 	c.SetLocation("America/New_York")
 	c.SetLocation("America/New_York")
 	c.AddFunc("TZ=Asia/Tokyo 0 6 * * ?", ...)
 	c.AddFunc("TZ=Asia/Tokyo 0 6 * * ?", ...)
 
 

+ 30 - 0
option.go

@@ -0,0 +1,30 @@
+package cron
+
+import (
+	"log"
+	"time"
+)
+
+// Option represents a modification to the default behavior of a Cron.
+type Option func(*Cron)
+
+// WithLocation overrides the timezone of the cron instance.
+func WithLocation(loc *time.Location) Option {
+	return func(c *Cron) {
+		c.location = loc
+	}
+}
+
+// WithParser overrides the parser used for interpreting job schedules.
+func WithParser(p Parser) Option {
+	return func(c *Cron) {
+		c.parser = p
+	}
+}
+
+// WithPanicLogger overrides the logger used for logging job panics.
+func WithPanicLogger(l *log.Logger) Option {
+	return func(c *Cron) {
+		c.logger = l
+	}
+}

+ 32 - 0
option_test.go

@@ -0,0 +1,32 @@
+package cron
+
+import (
+	"bytes"
+	"log"
+	"testing"
+	"time"
+)
+
+func TestWithLocation(t *testing.T) {
+	c := New(WithLocation(time.UTC))
+	if c.location != time.UTC {
+		t.Errorf("expected UTC, got %v", c.location)
+	}
+}
+
+func TestWithParser(t *testing.T) {
+	var parser = NewParser(Dow)
+	c := New(WithParser(parser))
+	if c.parser != parser {
+		t.Error("expected provided parser")
+	}
+}
+
+func TestWithPanicLogger(t *testing.T) {
+	var b bytes.Buffer
+	var logger = log.New(&b, "", log.LstdFlags)
+	c := New(WithPanicLogger(logger))
+	if c.logger != logger {
+		t.Error("expected provided logger")
+	}
+}

+ 122 - 67
parser.go

@@ -15,14 +15,15 @@ import (
 type ParseOption int
 type ParseOption int
 
 
 const (
 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.
+	Second         ParseOption = 1 << iota // Seconds field, default 0
+	SecondOptional                         // Optional 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 places = []ParseOption{
 var places = []ParseOption{
@@ -45,11 +46,15 @@ var defaults = []string{
 
 
 // A custom Parser that can be configured.
 // A custom Parser that can be configured.
 type Parser struct {
 type Parser struct {
-	options   ParseOption
-	optionals int
+	options ParseOption
 }
 }
 
 
-// Creates a custom Parser with custom options.
+// NewParser creates a Parser with custom options.
+//
+// It panics if more than one Optional is given, since it would be impossible to
+// correctly infer which optional is provided or missing in general.
+//
+// Examples
 //
 //
 //  // Standard parser without descriptors
 //  // Standard parser without descriptors
 //  specParser := NewParser(Minute | Hour | Dom | Month | Dow)
 //  specParser := NewParser(Minute | Hour | Dom | Month | Dow)
@@ -66,10 +71,15 @@ type Parser struct {
 func NewParser(options ParseOption) Parser {
 func NewParser(options ParseOption) Parser {
 	optionals := 0
 	optionals := 0
 	if options&DowOptional > 0 {
 	if options&DowOptional > 0 {
-		options |= Dow
 		optionals++
 		optionals++
 	}
 	}
-	return Parser{options, optionals}
+	if options&SecondOptional > 0 {
+		optionals++
+	}
+	if optionals > 1 {
+		panic("multiple optionals may not be configured")
+	}
+	return Parser{options}
 }
 }
 
 
 // Parse returns a new crontab schedule representing the given spec.
 // Parse returns a new crontab schedule representing the given spec.
@@ -77,7 +87,7 @@ func NewParser(options ParseOption) Parser {
 // It accepts crontab specs and features configured by NewParser.
 // It accepts crontab specs and features configured by NewParser.
 func (p Parser) Parse(spec string) (Schedule, error) {
 func (p Parser) Parse(spec string) (Schedule, error) {
 	if len(spec) == 0 {
 	if len(spec) == 0 {
-		return nil, fmt.Errorf("Empty spec string")
+		return nil, fmt.Errorf("empty spec string")
 	}
 	}
 
 
 	// Extract timezone if present
 	// Extract timezone if present
@@ -86,7 +96,7 @@ func (p Parser) Parse(spec string) (Schedule, error) {
 		var err error
 		var err error
 		i := strings.Index(spec, " ")
 		i := strings.Index(spec, " ")
 		if loc, err = time.LoadLocation(spec[3:i]); err != nil {
 		if loc, err = time.LoadLocation(spec[3:i]); err != nil {
-			return nil, fmt.Errorf("Provided bad location %s: %v", spec[3:i], err)
+			return nil, fmt.Errorf("provided bad location %s: %v", spec[3:i], err)
 		}
 		}
 		spec = strings.TrimSpace(spec[i:])
 		spec = strings.TrimSpace(spec[i:])
 	}
 	}
@@ -96,30 +106,16 @@ func (p Parser) Parse(spec string) (Schedule, error) {
 		return parseDescriptor(spec, loc)
 		return parseDescriptor(spec, loc)
 	}
 	}
 
 
-	// Figure out how many fields we need
-	max := 0
-	for _, place := range places {
-		if p.options&place > 0 {
-			max++
-		}
-	}
-	min := max - p.optionals
-
 	// Split on whitespace.
 	// Split on whitespace.
 	fields := strings.Fields(spec)
 	fields := strings.Fields(spec)
 
 
-	// 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)
+	// Validate & fill in any omitted or optional fields
+	var err error
+	fields, err = normalizeFields(fields, p.options)
+	if err != nil {
+		return nil, err
 	}
 	}
 
 
-	// Fill in missing fields
-	fields = expandFields(fields, p.options)
-
-	var err error
 	field := func(field string, r bounds) uint64 {
 	field := func(field string, r bounds) uint64 {
 		if err != nil {
 		if err != nil {
 			return 0
 			return 0
@@ -148,25 +144,98 @@ func (p Parser) Parse(spec string) (Schedule, error) {
 		Dom:      dayofmonth,
 		Dom:      dayofmonth,
 		Month:    month,
 		Month:    month,
 		Dow:      dayofweek,
 		Dow:      dayofweek,
-                Location: loc,
+		Location: loc,
 	}, nil
 	}, nil
 }
 }
 
 
-func expandFields(fields []string, options ParseOption) []string {
+// normalizeFields takes a subset set of the time fields and returns the full set
+// with defaults (zeroes) populated for unset fields.
+//
+// As part of performing this function, it also validates that the provided
+// fields are compatible with the configured options.
+func normalizeFields(fields []string, options ParseOption) ([]string, error) {
+	// Validate optionals & add their field to options
+	optionals := 0
+	if options&SecondOptional > 0 {
+		options |= Second
+		optionals++
+	}
+	if options&DowOptional > 0 {
+		options |= Dow
+		optionals++
+	}
+	if optionals > 1 {
+		return nil, fmt.Errorf("multiple optionals may not be configured")
+	}
+
+	// Figure out how many fields we need
+	max := 0
+	for _, place := range places {
+		if options&place > 0 {
+			max++
+		}
+	}
+	min := max - optionals
+
+	// 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, fields)
+		}
+		return nil, fmt.Errorf("expected %d to %d fields, found %d: %s", min, max, count, fields)
+	}
+
+	// Populate the optional field if not provided
+	if min < max && len(fields) == min {
+		switch {
+		case options&DowOptional > 0:
+			fields = append(fields, defaults[5]) // TODO: improve access to default
+		case options&SecondOptional > 0:
+			fields = append([]string{defaults[0]}, fields...)
+		default:
+			return nil, fmt.Errorf("unknown optional field")
+		}
+	}
+
+	// Populate all fields not part of options with their defaults
 	n := 0
 	n := 0
-	count := len(fields)
-	expFields := make([]string, len(places))
-	copy(expFields, defaults)
+	expandedFields := make([]string, len(places))
+	copy(expandedFields, defaults)
 	for i, place := range places {
 	for i, place := range places {
 		if options&place > 0 {
 		if options&place > 0 {
-			expFields[i] = fields[n]
+			expandedFields[i] = fields[n]
 			n++
 			n++
 		}
 		}
-		if n == count {
-			break
+	}
+	return expandedFields, nil
+}
+
+// expandOptionalFields returns fields with any optional fields added in at
+// their default value, if not provided.
+//
+// It panics if the input does not fulfill the following precondition:
+//   1. (# options fields) - (1 optional field) <= len(fields) <= (# options fields)
+//   2. Any optional fields have had their field added.
+//      For example, options&SecondOptional implies options&Second)
+func expandOptionalFields(fields []string, options ParseOption) []string {
+	expectedFields := 0
+	for _, place := range places {
+		if options&place > 0 {
+			expectedFields++
+		}
+	}
+	switch {
+	case len(fields) == expectedFields:
+		return fields
+	case len(fields) == expectedFields-1:
+		switch {
+		case options&DowOptional > 0:
+			return append(fields, defaults[5]) // TODO: improve access to default
+		case options&SecondOptional > 0:
+			return append([]string{defaults[0]}, fields...)
 		}
 		}
 	}
 	}
-	return expFields
+	panic(fmt.Errorf("expected %d fields, got %d", expectedFields, len(fields)))
 }
 }
 
 
 var standardParser = NewParser(
 var standardParser = NewParser(
@@ -185,20 +254,6 @@ func ParseStandard(standardSpec string) (Schedule, error) {
 	return standardParser.Parse(standardSpec)
 	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
 // 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
 // the field represents or error parsing field value.  A "field" is a comma-separated
 // list of "ranges".
 // list of "ranges".
@@ -246,7 +301,7 @@ func getRange(expr string, r bounds) (uint64, error) {
 				return 0, err
 				return 0, err
 			}
 			}
 		default:
 		default:
-			return 0, fmt.Errorf("Too many hyphens: %s", expr)
+			return 0, fmt.Errorf("too many hyphens: %s", expr)
 		}
 		}
 	}
 	}
 
 
@@ -264,20 +319,20 @@ func getRange(expr string, r bounds) (uint64, error) {
 			end = r.max
 			end = r.max
 		}
 		}
 	default:
 	default:
-		return 0, fmt.Errorf("Too many slashes: %s", expr)
+		return 0, fmt.Errorf("too many slashes: %s", expr)
 	}
 	}
 
 
 	if start < r.min {
 	if start < r.min {
-		return 0, 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 {
 	if end > r.max {
-		return 0, 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 {
 	if start > end {
-		return 0, 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 {
 	if step == 0 {
-		return 0, 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, nil
 	return getBits(start, end, step) | extra, nil
@@ -297,10 +352,10 @@ func parseIntOrName(expr string, names map[string]uint) (uint, error) {
 func mustParseInt(expr string) (uint, error) {
 func mustParseInt(expr string) (uint, error) {
 	num, err := strconv.Atoi(expr)
 	num, err := strconv.Atoi(expr)
 	if err != nil {
 	if err != nil {
-		return 0, fmt.Errorf("Failed to parse int from %s: %s", expr, err)
+		return 0, fmt.Errorf("failed to parse int from %s: %s", expr, err)
 	}
 	}
 	if num < 0 {
 	if num < 0 {
-		return 0, fmt.Errorf("Negative number (%d) not allowed: %s", num, expr)
+		return 0, fmt.Errorf("negative number (%d) not allowed: %s", num, expr)
 	}
 	}
 
 
 	return uint(num), nil
 	return uint(num), nil
@@ -391,10 +446,10 @@ func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) {
 	if strings.HasPrefix(descriptor, every) {
 	if strings.HasPrefix(descriptor, every) {
 		duration, err := time.ParseDuration(descriptor[len(every):])
 		duration, err := time.ParseDuration(descriptor[len(every):])
 		if err != nil {
 		if err != nil {
-			return nil, fmt.Errorf("Failed to parse duration %s: %s", descriptor, err)
+			return nil, fmt.Errorf("failed to parse duration %s: %s", descriptor, err)
 		}
 		}
 		return Every(duration), nil
 		return Every(duration), nil
 	}
 	}
 
 
-	return nil, fmt.Errorf("Unrecognized descriptor: %s", descriptor)
+	return nil, fmt.Errorf("unrecognized descriptor: %s", descriptor)
 }
 }

+ 166 - 28
parser_test.go

@@ -7,6 +7,8 @@ import (
 	"time"
 	"time"
 )
 )
 
 
+var secondParser = NewParser(Second | Minute | Hour | Dom | Month | DowOptional | Descriptor)
+
 func TestRange(t *testing.T) {
 func TestRange(t *testing.T) {
 	zero := uint64(0)
 	zero := uint64(0)
 	ranges := []struct {
 	ranges := []struct {
@@ -30,11 +32,11 @@ func TestRange(t *testing.T) {
 		{"*", 1, 3, 1<<1 | 1<<2 | 1<<3 | starBit, ""},
 		{"*", 1, 3, 1<<1 | 1<<2 | 1<<3 | starBit, ""},
 		{"*/2", 1, 3, 1<<1 | 1<<3 | starBit, ""},
 		{"*/2", 1, 3, 1<<1 | 1<<3 | starBit, ""},
 
 
-		{"5--5", 0, 0, zero, "Too many hyphens"},
-		{"jan-x", 0, 0, zero, "Failed to parse int from"},
-		{"2-x", 1, 5, zero, "Failed to parse int from"},
-		{"*/-12", 0, 0, zero, "Negative number"},
-		{"*//2", 0, 0, zero, "Too many slashes"},
+		{"5--5", 0, 0, zero, "too many hyphens"},
+		{"jan-x", 0, 0, zero, "failed to parse int from"},
+		{"2-x", 1, 5, zero, "failed to parse int from"},
+		{"*/-12", 0, 0, zero, "negative number"},
+		{"*//2", 0, 0, zero, "too many slashes"},
 		{"1", 3, 5, zero, "below minimum"},
 		{"1", 3, 5, zero, "below minimum"},
 		{"6", 3, 5, zero, "above maximum"},
 		{"6", 3, 5, zero, "above maximum"},
 		{"5-3", 3, 5, zero, "beyond end of range"},
 		{"5-3", 3, 5, zero, "beyond end of range"},
@@ -118,14 +120,14 @@ func TestBits(t *testing.T) {
 
 
 func TestParseScheduleErrors(t *testing.T) {
 func TestParseScheduleErrors(t *testing.T) {
 	var tests = []struct{ expr, err string }{
 	var tests = []struct{ expr, err string }{
-		{"* 5 j * * *", "Failed to parse int from"},
-		{"@every Xm", "Failed to parse duration"},
-		{"@unrecognized", "Unrecognized descriptor"},
-		{"* * * *", "Expected 5 to 6 fields"},
-		{"", "Empty spec string"},
+		{"* 5 j * * *", "failed to parse int from"},
+		{"@every Xm", "failed to parse duration"},
+		{"@unrecognized", "unrecognized descriptor"},
+		{"* * * *", "expected 5 to 6 fields"},
+		{"", "empty spec string"},
 	}
 	}
 	for _, c := range tests {
 	for _, c := range tests {
-		actual, err := Parse(c.expr)
+		actual, err := secondParser.Parse(c.expr)
 		if err == nil || !strings.Contains(err.Error(), c.err) {
 		if err == nil || !strings.Contains(err.Error(), c.err) {
 			t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
 			t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
 		}
 		}
@@ -138,23 +140,24 @@ func TestParseScheduleErrors(t *testing.T) {
 func TestParseSchedule(t *testing.T) {
 func TestParseSchedule(t *testing.T) {
 	tokyo, _ := time.LoadLocation("Asia/Tokyo")
 	tokyo, _ := time.LoadLocation("Asia/Tokyo")
 	entries := []struct {
 	entries := []struct {
+		parser   Parser
 		expr     string
 		expr     string
 		expected Schedule
 		expected Schedule
 	}{
 	}{
-		{"0 5 * * * *", every5min(time.Local)},
-                // Relied on the "optional seconds" behavior
-		// {"5 * * * *", every5min(time.Local)},
-		{"TZ=UTC  0 5 * * * *", every5min(time.UTC)},
-		// {"TZ=UTC  5 * * * *", every5min(time.UTC)},
-		{"TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo)},
-		{"@every 5m", ConstantDelaySchedule{5 * time.Minute}},
-		{"@midnight", midnight(time.Local)},
-		{"TZ=UTC  @midnight", midnight(time.UTC)},
-		{"TZ=Asia/Tokyo @midnight", midnight(tokyo)},
-		{"@yearly", annual(time.Local)},
-		{"@annually", annual(time.Local)},
-		{
-			expr: "* 5 * * * *",
+		{secondParser, "0 5 * * * *", every5min(time.Local)},
+		{standardParser, "5 * * * *", every5min(time.Local)},
+		{secondParser, "TZ=UTC  0 5 * * * *", every5min(time.UTC)},
+		{standardParser, "TZ=UTC  5 * * * *", every5min(time.UTC)},
+		{secondParser, "TZ=Asia/Tokyo 0 5 * * * *", every5min(tokyo)},
+		{secondParser, "@every 5m", ConstantDelaySchedule{5 * time.Minute}},
+		{secondParser, "@midnight", midnight(time.Local)},
+		{secondParser, "TZ=UTC  @midnight", midnight(time.UTC)},
+		{secondParser, "TZ=Asia/Tokyo @midnight", midnight(tokyo)},
+		{secondParser, "@yearly", annual(time.Local)},
+		{secondParser, "@annually", annual(time.Local)},
+		{
+			parser: secondParser,
+			expr:   "* 5 * * * *",
 			expected: &SpecSchedule{
 			expected: &SpecSchedule{
 				Second:   all(seconds),
 				Second:   all(seconds),
 				Minute:   1 << 5,
 				Minute:   1 << 5,
@@ -168,7 +171,29 @@ func TestParseSchedule(t *testing.T) {
 	}
 	}
 
 
 	for _, c := range entries {
 	for _, c := range entries {
-		actual, err := Parse(c.expr)
+		actual, err := c.parser.Parse(c.expr)
+		if err != nil {
+			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)
+		}
+	}
+}
+
+func TestOptionalSecondSchedule(t *testing.T) {
+	parser := NewParser(SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor)
+	entries := []struct {
+		expr     string
+		expected Schedule
+	}{
+		{"0 5 * * * *", every5min(time.Local)},
+		{"5 5 * * * *", every5min5s(time.Local)},
+		{"5 * * * *", every5min(time.Local)},
+	}
+
+	for _, c := range entries {
+		actual, err := parser.Parse(c.expr)
 		if err != nil {
 		if err != nil {
 			t.Errorf("%s => unexpected error %v", c.expr, err)
 			t.Errorf("%s => unexpected error %v", c.expr, err)
 		}
 		}
@@ -178,6 +203,115 @@ func TestParseSchedule(t *testing.T) {
 	}
 	}
 }
 }
 
 
+func TestNormalizeFields(t *testing.T) {
+	tests := []struct {
+		name     string
+		input    []string
+		options  ParseOption
+		expected []string
+	}{
+		{
+			"AllFields_NoOptional",
+			[]string{"0", "5", "*", "*", "*", "*"},
+			Second | Minute | Hour | Dom | Month | Dow | Descriptor,
+			[]string{"0", "5", "*", "*", "*", "*"},
+		},
+		{
+			"AllFields_SecondOptional_Provided",
+			[]string{"0", "5", "*", "*", "*", "*"},
+			SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor,
+			[]string{"0", "5", "*", "*", "*", "*"},
+		},
+		{
+			"AllFields_SecondOptional_NotProvided",
+			[]string{"5", "*", "*", "*", "*"},
+			SecondOptional | Minute | Hour | Dom | Month | Dow | Descriptor,
+			[]string{"0", "5", "*", "*", "*", "*"},
+		},
+		{
+			"SubsetFields_NoOptional",
+			[]string{"5", "15", "*"},
+			Hour | Dom | Month,
+			[]string{"0", "0", "5", "15", "*", "*"},
+		},
+		{
+			"SubsetFields_DowOptional_Provided",
+			[]string{"5", "15", "*", "4"},
+			Hour | Dom | Month | DowOptional,
+			[]string{"0", "0", "5", "15", "*", "4"},
+		},
+		{
+			"SubsetFields_DowOptional_NotProvided",
+			[]string{"5", "15", "*"},
+			Hour | Dom | Month | DowOptional,
+			[]string{"0", "0", "5", "15", "*", "*"},
+		},
+		{
+			"SubsetFields_SecondOptional_NotProvided",
+			[]string{"5", "15", "*"},
+			SecondOptional | Hour | Dom | Month,
+			[]string{"0", "0", "5", "15", "*", "*"},
+		},
+	}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			actual, err := normalizeFields(test.input, test.options)
+			if err != nil {
+				t.Errorf("unexpected error: %v", err)
+			}
+			if !reflect.DeepEqual(actual, test.expected) {
+				t.Errorf("expected %v, got %v", test.expected, actual)
+			}
+		})
+	}
+}
+
+func TestNormalizeFields_Errors(t *testing.T) {
+	tests := []struct {
+		name    string
+		input   []string
+		options ParseOption
+		err     string
+	}{
+		{
+			"TwoOptionals",
+			[]string{"0", "5", "*", "*", "*", "*"},
+			SecondOptional | Minute | Hour | Dom | Month | DowOptional,
+			"",
+		},
+		{
+			"TooManyFields",
+			[]string{"0", "5", "*", "*"},
+			SecondOptional | Minute | Hour,
+			"",
+		},
+		{
+			"NoFields",
+			[]string{},
+			SecondOptional | Minute | Hour,
+			"",
+		},
+		{
+			"TooFewFields",
+			[]string{"*"},
+			SecondOptional | Minute | Hour,
+			"",
+		},
+	}
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			actual, err := normalizeFields(test.input, test.options)
+			if err == nil {
+				t.Errorf("expected an error, got none. results: %v", actual)
+			}
+			if !strings.Contains(err.Error(), test.err) {
+				t.Errorf("expected error %q, got %q", test.err, err.Error())
+			}
+		})
+	}
+}
+
 func TestStandardSpecSchedule(t *testing.T) {
 func TestStandardSpecSchedule(t *testing.T) {
 	entries := []struct {
 	entries := []struct {
 		expr     string
 		expr     string
@@ -194,11 +328,11 @@ func TestStandardSpecSchedule(t *testing.T) {
 		},
 		},
 		{
 		{
 			expr: "5 j * * *",
 			expr: "5 j * * *",
-			err:  "Failed to parse int from",
+			err:  "failed to parse int from",
 		},
 		},
 		{
 		{
 			expr: "* * * *",
 			expr: "* * * *",
-			err:  "Expected exactly 5 fields",
+			err:  "expected exactly 5 fields",
 		},
 		},
 	}
 	}
 
 
@@ -220,6 +354,10 @@ func every5min(loc *time.Location) *SpecSchedule {
 	return &SpecSchedule{1 << 0, 1 << 5, all(hours), all(dom), all(months), all(dow), loc}
 	return &SpecSchedule{1 << 0, 1 << 5, all(hours), all(dom), all(months), all(dow), loc}
 }
 }
 
 
+func every5min5s(loc *time.Location) *SpecSchedule {
+	return &SpecSchedule{1 << 5, 1 << 5, all(hours), all(dom), all(months), all(dow), loc}
+}
+
 func midnight(loc *time.Location) *SpecSchedule {
 func midnight(loc *time.Location) *SpecSchedule {
 	return &SpecSchedule{1, 1, 1, all(dom), all(months), all(dow), loc}
 	return &SpecSchedule{1, 1, 1, all(dom), all(months), all(dow), loc}
 }
 }

+ 1 - 1
spec_test.go

@@ -171,7 +171,7 @@ func TestNext(t *testing.T) {
 	}
 	}
 
 
 	for _, c := range runs {
 	for _, c := range runs {
-		sched, err := Parse(c.spec)
+		sched, err := secondParser.Parse(c.spec)
 		if err != nil {
 		if err != nil {
 			t.Error(err)
 			t.Error(err)
 			continue
 			continue