Browse Source

v3: Merge 'master' and 'v2' to create a v3 branch, and add a go.mod file

This begins work on v3 to address all outstanding issues raised on Github over
the past couple years, in addition to adding support for Go modules.

Once complete, I'll tag it as "3.0"
Rob Figueiredo 7 years ago
parent
commit
1e507e218b
9 changed files with 421 additions and 213 deletions
  1. 33 1
      README.md
  2. 84 38
      cron.go
  3. 42 2
      cron_test.go
  4. 43 9
      doc.go
  5. 1 0
      go.mod
  6. 60 40
      parser.go
  7. 62 59
      parser_test.go
  8. 20 4
      spec.go
  9. 76 60
      spec_test.go

+ 33 - 1
README.md

@@ -1,6 +1,38 @@
-[![GoDoc](http://godoc.org/github.com/robfig/cron?status.png)](http://godoc.org/github.com/robfig/cron) 
+[![GoDoc](http://godoc.org/github.com/robfig/cron?status.png)](http://godoc.org/github.com/robfig/cron)
 [![Build Status](https://travis-ci.org/robfig/cron.svg?branch=master)](https://travis-ci.org/robfig/cron)
 
 # cron
 
 Documentation here: https://godoc.org/github.com/robfig/cron
+
+## DRAFT - Upgrading to v3
+
+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
+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.
+
+These are the updates required:
+
+- 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
+  parser conforms to the standard as described by
+  [the Cron wikipedia page]. This behavior is not currently supported in v3.
+
+### Cron spec format
+
+There are two cron spec formats in common usage:
+
+- The "standard" cron format, described on [the Cron wikipedia page] and used by
+  the cron Linux system utility.
+
+- The cron format used by [the Quartz Scheduler], commonly used for scheduled
+  jobs in Java software
+
+[the Cron wikipedia page]: https://en.wikipedia.org/wiki/Cron
+[the Quartz Scheduler]: http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html
+
+The original version of this package included an optional "seconds" field, which
+made it incompatible with both of these formats. Instead, the schedule parser
+has been extended to support both types.

+ 84 - 38
cron.go

@@ -14,10 +14,12 @@ type Cron struct {
 	entries  []*Entry
 	stop     chan struct{}
 	add      chan *Entry
-	snapshot chan []*Entry
+	remove   chan EntryID
+	snapshot chan []Entry
 	running  bool
 	ErrorLog *log.Logger
 	location *time.Location
+	nextID   EntryID
 }
 
 // Job is an interface for submitted cron jobs.
@@ -25,30 +27,39 @@ type Job interface {
 	Run()
 }
 
-// The Schedule describes a job's duty cycle.
+// Schedule describes a job's duty cycle.
 type Schedule interface {
-	// Return the next activation time, later than the given time.
+	// Next returns the next activation time, later than the given time.
 	// Next is invoked initially, and then each time the job is run.
 	Next(time.Time) time.Time
 }
 
+// EntryID identifies an entry within a Cron instance
+type EntryID int
+
 // Entry consists of a schedule and the func to execute on that schedule.
 type Entry struct {
-	// The schedule on which this job should be run.
+	// ID is the cron-assigned ID of this entry, which may be used to look up a
+	// snapshot or remove it.
+	ID EntryID
+
+	// Schedule on which this job should be run.
 	Schedule Schedule
 
-	// The next time the job will run. This is the zero time if Cron has not been
+	// Next time the job will run, or the zero time if Cron has not been
 	// started or this entry's schedule is unsatisfiable
 	Next time.Time
 
-	// The last time this job was run. This is the zero time if the job has never
-	// been run.
+	// Prev is the last time this job was run, or the zero time if never.
 	Prev time.Time
 
-	// The Job to run.
+	// Job is the thing to run when the Schedule is activated.
 	Job Job
 }
 
+// Valid returns true if this is not the zero entry.
+func (e Entry) Valid() bool { return e.ID != 0 }
+
 // byTime is a wrapper for sorting the entry array by time
 // (with zero time at the end).
 type byTime []*Entry
@@ -68,64 +79,71 @@ func (s byTime) Less(i, j int) bool {
 	return s[i].Next.Before(s[j].Next)
 }
 
-// New returns a new Cron job runner, in the Local time zone.
+// 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.Now().Location())
+	return NewWithLocation(time.Local)
 }
 
-// NewWithLocation returns a new Cron job runner.
+// 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{
 		entries:  nil,
 		add:      make(chan *Entry),
 		stop:     make(chan struct{}),
-		snapshot: make(chan []*Entry),
+		snapshot: make(chan []Entry),
+		remove:   make(chan EntryID),
 		running:  false,
 		ErrorLog: nil,
 		location: location,
 	}
 }
 
-// A wrapper that turns a func() into a cron.Job
+// FuncJob is a wrapper that turns a func() into a cron.Job
 type FuncJob func()
 
 func (f FuncJob) Run() { f() }
 
 // AddFunc adds a func to the Cron to be run on the given schedule.
-func (c *Cron) AddFunc(spec string, cmd func()) error {
+// 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.
+func (c *Cron) AddFunc(spec string, cmd func()) (EntryID, error) {
 	return c.AddJob(spec, FuncJob(cmd))
 }
 
 // AddJob adds a Job to the Cron to be run on the given schedule.
-func (c *Cron) AddJob(spec string, cmd Job) error {
+// 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.
+func (c *Cron) AddJob(spec string, cmd Job) (EntryID, error) {
 	schedule, err := Parse(spec)
 	if err != nil {
-		return err
+		return 0, err
 	}
-	c.Schedule(schedule, cmd)
-	return nil
+	return c.Schedule(schedule, cmd), nil
 }
 
 // Schedule adds a Job to the Cron to be run on the given schedule.
-func (c *Cron) Schedule(schedule Schedule, cmd Job) {
+func (c *Cron) Schedule(schedule Schedule, cmd Job) EntryID {
+	c.nextID++
 	entry := &Entry{
+		ID:       c.nextID,
 		Schedule: schedule,
 		Job:      cmd,
 	}
 	if !c.running {
 		c.entries = append(c.entries, entry)
-		return
+	} else {
+		c.add <- entry
 	}
-
-	c.add <- entry
+	return entry.ID
 }
 
 // Entries returns a snapshot of the cron entries.
-func (c *Cron) Entries() []*Entry {
+func (c *Cron) Entries() []Entry {
 	if c.running {
 		c.snapshot <- nil
-		x := <-c.snapshot
-		return x
+		return <-c.snapshot
 	}
 	return c.entrySnapshot()
 }
@@ -135,6 +153,25 @@ func (c *Cron) Location() *time.Location {
 	return c.location
 }
 
+// Entry returns a snapshot of the given entry, or nil if it couldn't be found.
+func (c *Cron) Entry(id EntryID) Entry {
+	for _, entry := range c.Entries() {
+		if id == entry.ID {
+			return entry
+		}
+	}
+	return Entry{}
+}
+
+// Remove an entry from being run in the future.
+func (c *Cron) Remove(id EntryID) {
+	if c.running {
+		c.remove <- id
+	} else {
+		c.removeEntry(id)
+	}
+}
+
 // Start the cron scheduler in its own go-routine, or no-op if already started.
 func (c *Cron) Start() {
 	if c.running {
@@ -165,7 +202,7 @@ func (c *Cron) runWithRecovery(j Job) {
 	j.Run()
 }
 
-// Run the scheduler. this is private just due to the need to synchronize
+// run the scheduler.. this is private just due to the need to synchronize
 // access to the 'running' state variable.
 func (c *Cron) run() {
 	// Figure out the next activation times for each entry.
@@ -214,6 +251,10 @@ func (c *Cron) run() {
 			case <-c.stop:
 				timer.Stop()
 				return
+
+			case id := <-c.remove:
+				timer.Stop()
+				c.removeEntry(id)
 			}
 
 			break
@@ -221,6 +262,11 @@ func (c *Cron) run() {
 	}
 }
 
+// now returns current time in c location
+func (c *Cron) now() time.Time {
+	return time.Now().In(c.location)
+}
+
 // Logs an error to stderr or to the configured error log
 func (c *Cron) logf(format string, args ...interface{}) {
 	if c.ErrorLog != nil {
@@ -240,20 +286,20 @@ func (c *Cron) Stop() {
 }
 
 // entrySnapshot returns a copy of the current cron entry list.
-func (c *Cron) entrySnapshot() []*Entry {
-	entries := []*Entry{}
-	for _, e := range c.entries {
-		entries = append(entries, &Entry{
-			Schedule: e.Schedule,
-			Next:     e.Next,
-			Prev:     e.Prev,
-			Job:      e.Job,
-		})
+func (c *Cron) entrySnapshot() []Entry {
+	var entries = make([]Entry, len(c.entries))
+	for i, e := range c.entries {
+		entries[i] = *e
 	}
 	return entries
 }
 
-// now returns current time in c location
-func (c *Cron) now() time.Time {
-	return time.Now().In(c.location)
+func (c *Cron) removeEntry(id EntryID) {
+	var entries []*Entry
+	for _, e := range c.entries {
+		if e.ID != id {
+			entries = append(entries, e)
+		}
+	}
+	c.entries = entries
 }

+ 42 - 2
cron_test.go

@@ -124,6 +124,43 @@ func TestAddWhileRunningWithDelay(t *testing.T) {
 	}
 }
 
+// Add a job, remove a job, start cron, expect nothing runs.
+func TestRemoveBeforeRunning(t *testing.T) {
+	wg := &sync.WaitGroup{}
+	wg.Add(1)
+
+	cron := New()
+	id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() })
+	cron.Remove(id)
+	cron.Start()
+	defer cron.Stop()
+
+	select {
+	case <-time.After(OneSecond):
+		// Success, shouldn't run
+	case <-wait(wg):
+		t.FailNow()
+	}
+}
+
+// Start cron, add a job, remove it, expect it doesn't run.
+func TestRemoveWhileRunning(t *testing.T) {
+	wg := &sync.WaitGroup{}
+	wg.Add(1)
+
+	cron := New()
+	cron.Start()
+	defer cron.Stop()
+	id, _ := cron.AddFunc("* * * * * ?", func() { wg.Done() })
+	cron.Remove(id)
+
+	select {
+	case <-time.After(OneSecond):
+	case <-wait(wg):
+		t.FailNow()
+	}
+}
+
 // Test timing with Entries.
 func TestSnapshotEntries(t *testing.T) {
 	wg := &sync.WaitGroup{}
@@ -146,7 +183,6 @@ func TestSnapshotEntries(t *testing.T) {
 		t.Error("expected job runs at 2 second mark")
 	case <-wait(wg):
 	}
-
 }
 
 // Test that the entries are correctly sorted.
@@ -160,10 +196,14 @@ func TestMultipleEntries(t *testing.T) {
 	cron := New()
 	cron.AddFunc("0 0 0 1 1 ?", func() {})
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
+	id1, _ := cron.AddFunc("* * * * * ?", func() { t.Fatal() })
+	id2, _ := cron.AddFunc("* * * * * ?", func() { t.Fatal() })
 	cron.AddFunc("0 0 0 31 12 ?", func() {})
 	cron.AddFunc("* * * * * ?", func() { wg.Done() })
 
+	cron.Remove(id1)
 	cron.Start()
+	cron.Remove(id2)
 	defer cron.Stop()
 
 	select {
@@ -282,7 +322,7 @@ func (t testJob) Run() {
 // Test that adding an invalid job spec returns an error
 func TestInvalidJobSpec(t *testing.T) {
 	cron := New()
-	err := cron.AddJob("this will not parse", nil)
+	_, err := cron.AddJob("this will not parse", nil)
 	if err == nil {
 		t.Errorf("expected an error with invalid spec, got nil")
 	}

+ 43 - 9
doc.go

@@ -8,6 +8,7 @@ them in their own goroutines.
 
 	c := cron.New()
 	c.AddFunc("0 30 * * * *", func() { fmt.Println("Every hour on the half hour") })
+	c.AddFunc("TZ=Asia/Tokyo 30 04 * * * *", func() { fmt.Println("Runs at 04:30 Tokyo time every day") })
 	c.AddFunc("@hourly",      func() { fmt.Println("Every hour") })
 	c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") })
 	c.Start()
@@ -24,19 +25,29 @@ them in their own goroutines.
 
 CRON Expression Format
 
-A cron expression represents a set of times, using 6 space-separated fields.
+A cron expression represents a set of times, using 5 space-separated fields.
 
 	Field name   | Mandatory? | Allowed values  | Allowed special characters
 	----------   | ---------- | --------------  | --------------------------
-	Seconds      | Yes        | 0-59            | * / , -
 	Minutes      | Yes        | 0-59            | * / , -
 	Hours        | Yes        | 0-23            | * / , -
 	Day of month | Yes        | 1-31            | * / , - ?
 	Month        | Yes        | 1-12 or JAN-DEC | * / , -
 	Day of week  | Yes        | 0-6 or SUN-SAT  | * / , - ?
 
-Note: Month and Day-of-week field values are case insensitive.  "SUN", "Sun",
-and "sun" are equally accepted.
+Month and Day-of-week field values are case insensitive.  "SUN", "Sun", and
+"sun" are equally accepted.
+
+The specific interpretation of the format is based on [the Cron Wikipedia page].
+
+[the Cron Wikipedia page]: https://en.wikipedia.org/wiki/Cron
+
+Alternative Formats
+
+Alternative Cron expression formats (like [Quartz]) support other fields (like
+seconds). You can implement that by [creating a custom Parser](#NewParser).
+
+[Quartz]: http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html
 
 Special Characters
 
@@ -84,13 +95,13 @@ You may use one of several pre-defined schedules in place of a cron expression.
 
 Intervals
 
-You may also schedule a job to execute at fixed intervals, starting at the time it's added 
+You may also schedule a job to execute at fixed intervals, starting at the time it's added
 or cron is run. This is supported by formatting the cron spec like this:
 
     @every <duration>
 
-where "duration" is a string accepted by time.ParseDuration
-(http://golang.org/pkg/time/#ParseDuration).
+where "duration" is a string accepted by
+[time.ParseDuration](http://golang.org/pkg/time/#ParseDuration).
 
 For example, "@every 1h30m10s" would indicate a schedule that activates after
 1 hour, 30 minutes, 10 seconds, and then every interval after that.
@@ -101,8 +112,31 @@ it will have only 2 minutes of idle time between each run.
 
 Time zones
 
-All interpretation and scheduling is done in the machine's local time zone (as
-provided by the Go time package (http://www.golang.org/pkg/time).
+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
+that default using [Cron.SetLocation](#Cron.SetLocation).
+
+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
+of the cron spec, of the form "TZ=Asia/Tokyo".
+
+For example:
+
+	# Runs at 6am in time.Local
+	cron.New().AddFunc("0 6 * * ?", ...)
+
+	# Runs at 6am in America/New_York
+	c := cron.New()
+	c.SetLocation("America/New_York")
+	c.AddFunc("0 6 * * ?", ...)
+
+	# Runs at 6am in Asia/Tokyo
+	cron.New().AddFunc("TZ=Asia/Tokyo 0 6 * * ?", ...)
+
+	# Runs at 6am in Asia/Tokyo
+	c := cron.New()
+	c.SetLocation("America/New_York")
+	c.AddFunc("TZ=Asia/Tokyo 0 6 * * ?", ...)
 
 Be aware that jobs scheduled during daylight-savings leap-ahead transitions will
 not be run!

+ 1 - 0
go.mod

@@ -0,0 +1 @@
+module github.com/robfig/cron/v3

+ 60 - 40
parser.go

@@ -79,8 +79,21 @@ func (p Parser) Parse(spec string) (Schedule, error) {
 	if len(spec) == 0 {
 		return nil, fmt.Errorf("Empty spec string")
 	}
-	if spec[0] == '@' && p.options&Descriptor > 0 {
-		return parseDescriptor(spec)
+
+	// Extract timezone if present
+	var loc = time.Local
+	if strings.HasPrefix(spec, "TZ=") {
+		var err error
+		i := strings.Index(spec, " ")
+		if loc, err = time.LoadLocation(spec[3:i]); err != nil {
+			return nil, fmt.Errorf("Provided bad location %s: %v", spec[3:i], err)
+		}
+		spec = strings.TrimSpace(spec[i:])
+	}
+
+	// Handle named schedules (descriptors)
+	if strings.HasPrefix(spec, "@") {
+		return parseDescriptor(spec, loc)
 	}
 
 	// Figure out how many fields we need
@@ -92,7 +105,7 @@ func (p Parser) Parse(spec string) (Schedule, error) {
 	}
 	min := max - p.optionals
 
-	// Split fields on whitespace
+	// Split on whitespace.
 	fields := strings.Fields(spec)
 
 	// Validate number of fields
@@ -129,12 +142,13 @@ func (p Parser) Parse(spec string) (Schedule, error) {
 	}
 
 	return &SpecSchedule{
-		Second: second,
-		Minute: minute,
-		Hour:   hour,
-		Dom:    dayofmonth,
-		Month:  month,
-		Dow:    dayofweek,
+		Second:   second,
+		Minute:   minute,
+		Hour:     hour,
+		Dom:      dayofmonth,
+		Month:    month,
+		Dow:      dayofweek,
+                Location: loc,
 	}, nil
 }
 
@@ -314,57 +328,63 @@ func all(r bounds) uint64 {
 }
 
 // parseDescriptor returns a predefined schedule for the expression, or error if none matches.
-func parseDescriptor(descriptor string) (Schedule, error) {
+func parseDescriptor(descriptor string, loc *time.Location) (Schedule, error) {
 	switch descriptor {
 	case "@yearly", "@annually":
 		return &SpecSchedule{
-			Second: 1 << seconds.min,
-			Minute: 1 << minutes.min,
-			Hour:   1 << hours.min,
-			Dom:    1 << dom.min,
-			Month:  1 << months.min,
-			Dow:    all(dow),
+			Second:   1 << seconds.min,
+			Minute:   1 << minutes.min,
+			Hour:     1 << hours.min,
+			Dom:      1 << dom.min,
+			Month:    1 << months.min,
+			Dow:      all(dow),
+			Location: loc,
 		}, nil
 
 	case "@monthly":
 		return &SpecSchedule{
-			Second: 1 << seconds.min,
-			Minute: 1 << minutes.min,
-			Hour:   1 << hours.min,
-			Dom:    1 << dom.min,
-			Month:  all(months),
-			Dow:    all(dow),
+			Second:   1 << seconds.min,
+			Minute:   1 << minutes.min,
+			Hour:     1 << hours.min,
+			Dom:      1 << dom.min,
+			Month:    all(months),
+			Dow:      all(dow),
+			Location: loc,
 		}, nil
 
 	case "@weekly":
 		return &SpecSchedule{
-			Second: 1 << seconds.min,
-			Minute: 1 << minutes.min,
-			Hour:   1 << hours.min,
-			Dom:    all(dom),
-			Month:  all(months),
-			Dow:    1 << dow.min,
+			Second:   1 << seconds.min,
+			Minute:   1 << minutes.min,
+			Hour:     1 << hours.min,
+			Dom:      all(dom),
+			Month:    all(months),
+			Dow:      1 << dow.min,
+			Location: loc,
 		}, nil
 
 	case "@daily", "@midnight":
 		return &SpecSchedule{
-			Second: 1 << seconds.min,
-			Minute: 1 << minutes.min,
-			Hour:   1 << hours.min,
-			Dom:    all(dom),
-			Month:  all(months),
-			Dow:    all(dow),
+			Second:   1 << seconds.min,
+			Minute:   1 << minutes.min,
+			Hour:     1 << hours.min,
+			Dom:      all(dom),
+			Month:    all(months),
+			Dow:      all(dow),
+			Location: loc,
 		}, nil
 
 	case "@hourly":
 		return &SpecSchedule{
-			Second: 1 << seconds.min,
-			Minute: 1 << minutes.min,
-			Hour:   all(hours),
-			Dom:    all(dom),
-			Month:  all(months),
-			Dow:    all(dow),
+			Second:   1 << seconds.min,
+			Minute:   1 << minutes.min,
+			Hour:     all(hours),
+			Dom:      all(dom),
+			Month:    all(months),
+			Dow:      all(dow),
+			Location: loc,
 		}, nil
+
 	}
 
 	const every = "@every "

+ 62 - 59
parser_test.go

@@ -116,77 +116,60 @@ func TestBits(t *testing.T) {
 	}
 }
 
-func TestParse(t *testing.T) {
+func TestParseScheduleErrors(t *testing.T) {
+	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"},
+	}
+	for _, c := range tests {
+		actual, err := Parse(c.expr)
+		if err == nil || !strings.Contains(err.Error(), c.err) {
+			t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
+		}
+		if actual != nil {
+			t.Errorf("expected nil schedule on error, got %v", actual)
+		}
+	}
+}
+
+func TestParseSchedule(t *testing.T) {
+	tokyo, _ := time.LoadLocation("Asia/Tokyo")
 	entries := []struct {
 		expr     string
 		expected Schedule
-		err      string
 	}{
+		{"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 * * * *",
 			expected: &SpecSchedule{
-				Second: all(seconds),
-				Minute: 1 << 5,
-				Hour:   all(hours),
-				Dom:    all(dom),
-				Month:  all(months),
-				Dow:    all(dow),
-			},
-		},
-		{
-			expr: "* 5 j * * *",
-			err:  "Failed to parse int from",
-		},
-		{
-			expr:     "@every 5m",
-			expected: ConstantDelaySchedule{Delay: time.Duration(5) * time.Minute},
-		},
-		{
-			expr: "@every Xm",
-			err:  "Failed to parse duration",
-		},
-		{
-			expr: "@yearly",
-			expected: &SpecSchedule{
-				Second: 1 << seconds.min,
-				Minute: 1 << minutes.min,
-				Hour:   1 << hours.min,
-				Dom:    1 << dom.min,
-				Month:  1 << months.min,
-				Dow:    all(dow),
+				Second:   all(seconds),
+				Minute:   1 << 5,
+				Hour:     all(hours),
+				Dom:      all(dom),
+				Month:    all(months),
+				Dow:      all(dow),
+				Location: time.Local,
 			},
 		},
-		{
-			expr: "@annually",
-			expected: &SpecSchedule{
-				Second: 1 << seconds.min,
-				Minute: 1 << minutes.min,
-				Hour:   1 << hours.min,
-				Dom:    1 << dom.min,
-				Month:  1 << months.min,
-				Dow:    all(dow),
-			},
-		},
-		{
-			expr: "@unrecognized",
-			err:  "Unrecognized descriptor",
-		},
-		{
-			expr: "* * * *",
-			err:  "Expected 5 to 6 fields",
-		},
-		{
-			expr: "",
-			err:  "Empty spec string",
-		},
 	}
 
 	for _, c := range entries {
 		actual, err := Parse(c.expr)
-		if len(c.err) != 0 && (err == nil || !strings.Contains(err.Error(), c.err)) {
-			t.Errorf("%s => expected %v, got %v", c.expr, c.err, err)
-		}
-		if len(c.err) == 0 && err != nil {
+		if err != nil {
 			t.Errorf("%s => unexpected error %v", c.expr, err)
 		}
 		if !reflect.DeepEqual(actual, c.expected) {
@@ -203,7 +186,7 @@ func TestStandardSpecSchedule(t *testing.T) {
 	}{
 		{
 			expr:     "5 * * * *",
-			expected: &SpecSchedule{1 << seconds.min, 1 << 5, all(hours), all(dom), all(months), all(dow)},
+			expected: &SpecSchedule{1 << seconds.min, 1 << 5, all(hours), all(dom), all(months), all(dow), time.Local},
 		},
 		{
 			expr:     "@every 5m",
@@ -232,3 +215,23 @@ func TestStandardSpecSchedule(t *testing.T) {
 		}
 	}
 }
+
+func every5min(loc *time.Location) *SpecSchedule {
+	return &SpecSchedule{1 << 0, 1 << 5, all(hours), all(dom), all(months), all(dow), loc}
+}
+
+func midnight(loc *time.Location) *SpecSchedule {
+	return &SpecSchedule{1, 1, 1, all(dom), all(months), all(dow), loc}
+}
+
+func annual(loc *time.Location) *SpecSchedule {
+	return &SpecSchedule{
+		Second:   1 << seconds.min,
+		Minute:   1 << minutes.min,
+		Hour:     1 << hours.min,
+		Dom:      1 << dom.min,
+		Month:    1 << months.min,
+		Dow:      all(dow),
+		Location: loc,
+	}
+}

+ 20 - 4
spec.go

@@ -6,6 +6,9 @@ import "time"
 // traditional crontab specification. It is computed initially and stored as bit sets.
 type SpecSchedule struct {
 	Second, Minute, Hour, Dom, Month, Dow uint64
+
+	// Override location for this schedule.
+	Location *time.Location
 }
 
 // bounds provides a range of acceptable values (plus a map of name to value).
@@ -61,6 +64,19 @@ func (s *SpecSchedule) Next(t time.Time) time.Time {
 	// of the field list (since it is necessary to re-verify previous field
 	// values)
 
+	// Convert the given time into the schedule's timezone, if one is specified.
+	// Save the original timezone so we can convert back after we find a time.
+	// Note that schedules without a time zone specified (time.Local) are treated
+	// as local to the time provided.
+	origLocation := t.Location()
+	loc := s.Location
+	if loc == time.Local {
+		loc = t.Location()
+	}
+	if s.Location != time.Local {
+		t = t.In(s.Location)
+	}
+
 	// Start at the earliest possible time (the upcoming second).
 	t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond)
 
@@ -82,7 +98,7 @@ WRAP:
 		if !added {
 			added = true
 			// Otherwise, set the date at the beginning (since the current time is irrelevant).
-			t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, t.Location())
+			t = time.Date(t.Year(), t.Month(), 1, 0, 0, 0, 0, loc)
 		}
 		t = t.AddDate(0, 1, 0)
 
@@ -96,7 +112,7 @@ WRAP:
 	for !dayMatches(s, t) {
 		if !added {
 			added = true
-			t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
+			t = time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, loc)
 		}
 		t = t.AddDate(0, 0, 1)
 
@@ -108,7 +124,7 @@ WRAP:
 	for 1<<uint(t.Hour())&s.Hour == 0 {
 		if !added {
 			added = true
-			t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, t.Location())
+			t = time.Date(t.Year(), t.Month(), t.Day(), t.Hour(), 0, 0, 0, loc)
 		}
 		t = t.Add(1 * time.Hour)
 
@@ -141,7 +157,7 @@ WRAP:
 		}
 	}
 
-	return t
+	return t.In(origLocation)
 }
 
 // dayMatches returns true if the schedule's day-of-week and day-of-month

+ 76 - 60
spec_test.go

@@ -11,24 +11,24 @@ func TestActivation(t *testing.T) {
 		expected   bool
 	}{
 		// Every fifteen minutes.
-		{"Mon Jul 9 15:00 2012", "0 0/15 * * *", true},
-		{"Mon Jul 9 15:45 2012", "0 0/15 * * *", true},
-		{"Mon Jul 9 15:40 2012", "0 0/15 * * *", false},
+		{"Mon Jul 9 15:00 2012", "0/15 * * * *", true},
+		{"Mon Jul 9 15:45 2012", "0/15 * * * *", true},
+		{"Mon Jul 9 15:40 2012", "0/15 * * * *", false},
 
 		// Every fifteen minutes, starting at 5 minutes.
-		{"Mon Jul 9 15:05 2012", "0 5/15 * * *", true},
-		{"Mon Jul 9 15:20 2012", "0 5/15 * * *", true},
-		{"Mon Jul 9 15:50 2012", "0 5/15 * * *", true},
+		{"Mon Jul 9 15:05 2012", "5/15 * * * *", true},
+		{"Mon Jul 9 15:20 2012", "5/15 * * * *", true},
+		{"Mon Jul 9 15:50 2012", "5/15 * * * *", true},
 
 		// Named months
-		{"Sun Jul 15 15:00 2012", "0 0/15 * * Jul", true},
-		{"Sun Jul 15 15:00 2012", "0 0/15 * * Jun", false},
+		{"Sun Jul 15 15:00 2012", "0/15 * * Jul *", true},
+		{"Sun Jul 15 15:00 2012", "0/15 * * Jun *", false},
 
 		// Everything set.
-		{"Sun Jul 15 08:30 2012", "0 30 08 ? Jul Sun", true},
-		{"Sun Jul 15 08:30 2012", "0 30 08 15 Jul ?", true},
-		{"Mon Jul 16 08:30 2012", "0 30 08 ? Jul Sun", false},
-		{"Mon Jul 16 08:30 2012", "0 30 08 15 Jul ?", false},
+		{"Sun Jul 15 08:30 2012", "30 08 ? Jul Sun", true},
+		{"Sun Jul 15 08:30 2012", "30 08 15 Jul ?", true},
+		{"Mon Jul 16 08:30 2012", "30 08 ? Jul Sun", false},
+		{"Mon Jul 16 08:30 2012", "30 08 15 Jul ?", false},
 
 		// Predefined schedules
 		{"Mon Jul 9 15:00 2012", "@hourly", true},
@@ -43,20 +43,20 @@ func TestActivation(t *testing.T) {
 
 		// Test interaction of DOW and DOM.
 		// If both are specified, then only one needs to match.
-		{"Sun Jul 15 00:00 2012", "0 * * 1,15 * Sun", true},
-		{"Fri Jun 15 00:00 2012", "0 * * 1,15 * Sun", true},
-		{"Wed Aug 1 00:00 2012", "0 * * 1,15 * Sun", true},
+		{"Sun Jul 15 00:00 2012", "* * 1,15 * Sun", true},
+		{"Fri Jun 15 00:00 2012", "* * 1,15 * Sun", true},
+		{"Wed Aug 1 00:00 2012", "* * 1,15 * Sun", true},
 
 		// However, if one has a star, then both need to match.
-		{"Sun Jul 15 00:00 2012", "0 * * * * Mon", false},
-		{"Sun Jul 15 00:00 2012", "0 * * */10 * Sun", false},
-		{"Mon Jul 9 00:00 2012", "0 * * 1,15 * *", false},
-		{"Sun Jul 15 00:00 2012", "0 * * 1,15 * *", true},
-		{"Sun Jul 15 00:00 2012", "0 * * */2 * Sun", true},
+		{"Sun Jul 15 00:00 2012", "* * * * Mon", false},
+		{"Sun Jul 15 00:00 2012", "* * */10 * Sun", false},
+		{"Mon Jul 9 00:00 2012", "* * 1,15 * *", false},
+		{"Sun Jul 15 00:00 2012", "* * 1,15 * *", true},
+		{"Sun Jul 15 00:00 2012", "* * */2 * Sun", true},
 	}
 
 	for _, test := range tests {
-		sched, err := Parse(test.spec)
+		sched, err := ParseStandard(test.spec)
 		if err != nil {
 			t.Error(err)
 			continue
@@ -76,19 +76,19 @@ func TestNext(t *testing.T) {
 		expected   string
 	}{
 		// Simple cases
-		{"Mon Jul 9 14:45 2012", "0 0/15 * * *", "Mon Jul 9 15:00 2012"},
-		{"Mon Jul 9 14:59 2012", "0 0/15 * * *", "Mon Jul 9 15:00 2012"},
-		{"Mon Jul 9 14:59:59 2012", "0 0/15 * * *", "Mon Jul 9 15:00 2012"},
+		{"Mon Jul 9 14:45 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},
+		{"Mon Jul 9 14:59 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},
+		{"Mon Jul 9 14:59:59 2012", "0 0/15 * * * *", "Mon Jul 9 15:00 2012"},
 
 		// Wrap around hours
-		{"Mon Jul 9 15:45 2012", "0 20-35/15 * * *", "Mon Jul 9 16:20 2012"},
+		{"Mon Jul 9 15:45 2012", "0 20-35/15 * * * *", "Mon Jul 9 16:20 2012"},
 
 		// Wrap around days
-		{"Mon Jul 9 23:46 2012", "0 */15 * * *", "Tue Jul 10 00:00 2012"},
-		{"Mon Jul 9 23:45 2012", "0 20-35/15 * * *", "Tue Jul 10 00:20 2012"},
-		{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * * *", "Tue Jul 10 00:20:15 2012"},
-		{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 1/2 * *", "Tue Jul 10 01:20:15 2012"},
-		{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 10-12 * *", "Tue Jul 10 10:20:15 2012"},
+		{"Mon Jul 9 23:46 2012", "0 */15 * * * *", "Tue Jul 10 00:00 2012"},
+		{"Mon Jul 9 23:45 2012", "0 20-35/15 * * * *", "Tue Jul 10 00:20 2012"},
+		{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * * * *", "Tue Jul 10 00:20:15 2012"},
+		{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 1/2 * * *", "Tue Jul 10 01:20:15 2012"},
+		{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 10-12 * * *", "Tue Jul 10 10:20:15 2012"},
 
 		{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 1/2 */2 * *", "Thu Jul 11 01:20:15 2012"},
 		{"Mon Jul 9 23:35:51 2012", "15/35 20-35/15 * 9-20 * *", "Wed Jul 10 00:20:15 2012"},
@@ -110,24 +110,42 @@ func TestNext(t *testing.T) {
 		{"Mon Jul 9 23:35 2012", "0 0 0 29 Feb ?", "Mon Feb 29 00:00 2016"},
 
 		// Daylight savings time 2am EST (-5) -> 3am EDT (-4)
-		{"2012-03-11T00:00:00-0500", "0 30 2 11 Mar ?", "2013-03-11T02:30:00-0400"},
+		{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 30 2 11 Mar ?", "2013-03-11T02:30:00-0400"},
 
 		// hourly job
-		{"2012-03-11T00:00:00-0500", "0 0 * * * ?", "2012-03-11T01:00:00-0500"},
-		{"2012-03-11T01:00:00-0500", "0 0 * * * ?", "2012-03-11T03:00:00-0400"},
-		{"2012-03-11T03:00:00-0400", "0 0 * * * ?", "2012-03-11T04:00:00-0400"},
-		{"2012-03-11T04:00:00-0400", "0 0 * * * ?", "2012-03-11T05:00:00-0400"},
+		{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T01:00:00-0500"},
+		{"2012-03-11T01:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T03:00:00-0400"},
+		{"2012-03-11T03:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T04:00:00-0400"},
+		{"2012-03-11T04:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-03-11T05:00:00-0400"},
 
 		// 1am nightly job
-		{"2012-03-11T00:00:00-0500", "0 0 1 * * ?", "2012-03-11T01:00:00-0500"},
-		{"2012-03-11T01:00:00-0500", "0 0 1 * * ?", "2012-03-12T01:00:00-0400"},
+		{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-03-11T01:00:00-0500"},
+		{"2012-03-11T01:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-03-12T01:00:00-0400"},
 
 		// 2am nightly job (skipped)
-		{"2012-03-11T00:00:00-0500", "0 0 2 * * ?", "2012-03-12T02:00:00-0400"},
+		{"2012-03-11T00:00:00-0500", "TZ=America/New_York 0 0 2 * * ?", "2012-03-12T02:00:00-0400"},
 
 		// Daylight savings time 2am EDT (-4) => 1am EST (-5)
-		{"2012-11-04T00:00:00-0400", "0 30 2 04 Nov ?", "2012-11-04T02:30:00-0500"},
-		{"2012-11-04T01:45:00-0400", "0 30 1 04 Nov ?", "2012-11-04T01:30:00-0500"},
+		{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 30 2 04 Nov ?", "2012-11-04T02:30:00-0500"},
+		{"2012-11-04T01:45:00-0400", "TZ=America/New_York 0 30 1 04 Nov ?", "2012-11-04T01:30:00-0500"},
+
+		// hourly job
+		{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T01:00:00-0400"},
+		{"2012-11-04T01:00:00-0400", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T01:00:00-0500"},
+		{"2012-11-04T01:00:00-0500", "TZ=America/New_York 0 0 * * * ?", "2012-11-04T02:00:00-0500"},
+
+		// 1am nightly job (runs twice)
+		{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 1 * * ?", "2012-11-04T01:00:00-0400"},
+		{"2012-11-04T01:00:00-0400", "TZ=America/New_York 0 0 1 * * ?", "2012-11-04T01:00:00-0500"},
+		{"2012-11-04T01:00:00-0500", "TZ=America/New_York 0 0 1 * * ?", "2012-11-05T01:00:00-0500"},
+
+		// 2am nightly job
+		{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 2 * * ?", "2012-11-04T02:00:00-0500"},
+		{"2012-11-04T02:00:00-0500", "TZ=America/New_York 0 0 2 * * ?", "2012-11-05T02:00:00-0500"},
+
+		// 3am nightly job
+		{"2012-11-04T00:00:00-0400", "TZ=America/New_York 0 0 3 * * ?", "2012-11-04T03:00:00-0500"},
+		{"2012-11-04T03:00:00-0500", "TZ=America/New_York 0 0 3 * * ?", "2012-11-05T03:00:00-0500"},
 
 		// hourly job
 		{"2012-11-04T00:00:00-0400", "0 0 * * * ?", "2012-11-04T01:00:00-0400"},
@@ -174,7 +192,7 @@ func TestErrors(t *testing.T) {
 		"0 0 * * XYZ",
 	}
 	for _, spec := range invalidSpecs {
-		_, err := Parse(spec)
+		_, err := ParseStandard(spec)
 		if err == nil {
 			t.Error("expected an error parsing: ", spec)
 		}
@@ -185,22 +203,20 @@ func getTime(value string) time.Time {
 	if value == "" {
 		return time.Time{}
 	}
-	t, err := time.Parse("Mon Jan 2 15:04 2006", value)
-	if err != nil {
-		t, err = time.Parse("Mon Jan 2 15:04:05 2006", value)
-		if err != nil {
-			t, err = time.Parse("2006-01-02T15:04:05-0700", value)
-			if err != nil {
-				panic(err)
-			}
-			// Daylight savings time tests require location
-			if ny, err := time.LoadLocation("America/New_York"); err == nil {
-				t = t.In(ny)
-			}
+
+	var layouts = []string{
+		"Mon Jan 2 15:04 2006",
+		"Mon Jan 2 15:04:05 2006",
+	}
+	for _, layout := range layouts {
+		if t, err := time.ParseInLocation(layout, value, time.Local); err == nil {
+			return t
 		}
 	}
-
-	return t
+	if t, err := time.Parse("2006-01-02T15:04:05-0700", value); err == nil {
+		return t
+	}
+	panic("could not parse time value " + value)
 }
 
 func TestNextWithTz(t *testing.T) {
@@ -209,15 +225,15 @@ func TestNextWithTz(t *testing.T) {
 		expected   string
 	}{
 		// Failing tests
-		{"2016-01-03T13:09:03+0530", "0 14 14 * * *", "2016-01-03T14:14:00+0530"},
-		{"2016-01-03T04:09:03+0530", "0 14 14 * * ?", "2016-01-03T14:14:00+0530"},
+		{"2016-01-03T13:09:03+0530", "14 14 * * *", "2016-01-03T14:14:00+0530"},
+		{"2016-01-03T04:09:03+0530", "14 14 * * ?", "2016-01-03T14:14:00+0530"},
 
 		// Passing tests
-		{"2016-01-03T14:09:03+0530", "0 14 14 * * *", "2016-01-03T14:14:00+0530"},
-		{"2016-01-03T14:00:00+0530", "0 14 14 * * ?", "2016-01-03T14:14:00+0530"},
+		{"2016-01-03T14:09:03+0530", "14 14 * * *", "2016-01-03T14:14:00+0530"},
+		{"2016-01-03T14:00:00+0530", "14 14 * * ?", "2016-01-03T14:14:00+0530"},
 	}
 	for _, c := range runs {
-		sched, err := Parse(c.spec)
+		sched, err := ParseStandard(c.spec)
 		if err != nil {
 			t.Error(err)
 			continue