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)
 [![Build Status](https://travis-ci.org/robfig/cron.svg?branch=master)](https://travis-ci.org/robfig/cron)
 
 
 # cron
 # cron
 
 
 Documentation here: https://godoc.org/github.com/robfig/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
 	entries  []*Entry
 	stop     chan struct{}
 	stop     chan struct{}
 	add      chan *Entry
 	add      chan *Entry
-	snapshot chan []*Entry
+	remove   chan EntryID
+	snapshot chan []Entry
 	running  bool
 	running  bool
 	ErrorLog *log.Logger
 	ErrorLog *log.Logger
 	location *time.Location
 	location *time.Location
+	nextID   EntryID
 }
 }
 
 
 // Job is an interface for submitted cron jobs.
 // Job is an interface for submitted cron jobs.
@@ -25,30 +27,39 @@ type Job interface {
 	Run()
 	Run()
 }
 }
 
 
-// The Schedule describes a job's duty cycle.
+// Schedule describes a job's duty cycle.
 type Schedule interface {
 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 is invoked initially, and then each time the job is run.
 	Next(time.Time) time.Time
 	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.
 // Entry consists of a schedule and the func to execute on that schedule.
 type Entry struct {
 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
 	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
 	// started or this entry's schedule is unsatisfiable
 	Next time.Time
 	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
 	Prev time.Time
 
 
-	// The Job to run.
+	// Job is the thing to run when the Schedule is activated.
 	Job Job
 	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
 // byTime is a wrapper for sorting the entry array by time
 // (with zero time at the end).
 // (with zero time at the end).
 type byTime []*Entry
 type byTime []*Entry
@@ -68,64 +79,71 @@ 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, 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 {
 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 {
 func NewWithLocation(location *time.Location) *Cron {
 	return &Cron{
 	return &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),
 		running:  false,
 		running:  false,
 		ErrorLog: nil,
 		ErrorLog: nil,
 		location: location,
 		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()
 type FuncJob func()
 
 
 func (f FuncJob) Run() { f() }
 func (f FuncJob) Run() { f() }
 
 
 // AddFunc adds a func to the Cron to be run on the given schedule.
 // 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))
 	return c.AddJob(spec, FuncJob(cmd))
 }
 }
 
 
 // AddJob adds a Job to the Cron to be run on the given schedule.
 // 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)
 	schedule, err := Parse(spec)
 	if err != nil {
 	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.
 // 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{
 	entry := &Entry{
+		ID:       c.nextID,
 		Schedule: schedule,
 		Schedule: schedule,
 		Job:      cmd,
 		Job:      cmd,
 	}
 	}
 	if !c.running {
 	if !c.running {
 		c.entries = append(c.entries, entry)
 		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.
 // Entries returns a snapshot of the cron entries.
-func (c *Cron) Entries() []*Entry {
+func (c *Cron) Entries() []Entry {
 	if c.running {
 	if c.running {
 		c.snapshot <- nil
 		c.snapshot <- nil
-		x := <-c.snapshot
-		return x
+		return <-c.snapshot
 	}
 	}
 	return c.entrySnapshot()
 	return c.entrySnapshot()
 }
 }
@@ -135,6 +153,25 @@ func (c *Cron) Location() *time.Location {
 	return c.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.
 // Start the cron scheduler in its own go-routine, or no-op if already started.
 func (c *Cron) Start() {
 func (c *Cron) Start() {
 	if c.running {
 	if c.running {
@@ -165,7 +202,7 @@ func (c *Cron) runWithRecovery(j Job) {
 	j.Run()
 	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.
 // access to the 'running' state variable.
 func (c *Cron) run() {
 func (c *Cron) run() {
 	// Figure out the next activation times for each entry.
 	// Figure out the next activation times for each entry.
@@ -214,6 +251,10 @@ func (c *Cron) run() {
 			case <-c.stop:
 			case <-c.stop:
 				timer.Stop()
 				timer.Stop()
 				return
 				return
+
+			case id := <-c.remove:
+				timer.Stop()
+				c.removeEntry(id)
 			}
 			}
 
 
 			break
 			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
 // 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 {
 	if c.ErrorLog != nil {
@@ -240,20 +286,20 @@ func (c *Cron) Stop() {
 }
 }
 
 
 // entrySnapshot returns a copy of the current cron entry list.
 // 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
 	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.
 // Test timing with Entries.
 func TestSnapshotEntries(t *testing.T) {
 func TestSnapshotEntries(t *testing.T) {
 	wg := &sync.WaitGroup{}
 	wg := &sync.WaitGroup{}
@@ -146,7 +183,6 @@ func TestSnapshotEntries(t *testing.T) {
 		t.Error("expected job runs at 2 second mark")
 		t.Error("expected job runs at 2 second mark")
 	case <-wait(wg):
 	case <-wait(wg):
 	}
 	}
-
 }
 }
 
 
 // Test that the entries are correctly sorted.
 // Test that the entries are correctly sorted.
@@ -160,10 +196,14 @@ func TestMultipleEntries(t *testing.T) {
 	cron := New()
 	cron := New()
 	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() })
+	id2, _ := cron.AddFunc("* * * * * ?", func() { t.Fatal() })
 	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() })
 
 
+	cron.Remove(id1)
 	cron.Start()
 	cron.Start()
+	cron.Remove(id2)
 	defer cron.Stop()
 	defer cron.Stop()
 
 
 	select {
 	select {
@@ -282,7 +322,7 @@ func (t testJob) Run() {
 // Test that adding an invalid job spec returns an error
 // Test that adding an invalid job spec returns an error
 func TestInvalidJobSpec(t *testing.T) {
 func TestInvalidJobSpec(t *testing.T) {
 	cron := New()
 	cron := New()
-	err := cron.AddJob("this will not parse", nil)
+	_, err := cron.AddJob("this will not parse", nil)
 	if err == nil {
 	if err == nil {
 		t.Errorf("expected an error with invalid spec, got 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 := cron.New()
 	c.AddFunc("0 30 * * * *", func() { fmt.Println("Every hour on the half hour") })
 	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("@hourly",      func() { fmt.Println("Every hour") })
 	c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") })
 	c.AddFunc("@every 1h30m", func() { fmt.Println("Every hour thirty") })
 	c.Start()
 	c.Start()
@@ -24,19 +25,29 @@ them in their own goroutines.
 
 
 CRON Expression Format
 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
 	Field name   | Mandatory? | Allowed values  | Allowed special characters
 	----------   | ---------- | --------------  | --------------------------
 	----------   | ---------- | --------------  | --------------------------
-	Seconds      | Yes        | 0-59            | * / , -
 	Minutes      | Yes        | 0-59            | * / , -
 	Minutes      | Yes        | 0-59            | * / , -
 	Hours        | Yes        | 0-23            | * / , -
 	Hours        | Yes        | 0-23            | * / , -
 	Day of month | Yes        | 1-31            | * / , - ?
 	Day of month | Yes        | 1-31            | * / , - ?
 	Month        | Yes        | 1-12 or JAN-DEC | * / , -
 	Month        | Yes        | 1-12 or JAN-DEC | * / , -
 	Day of week  | Yes        | 0-6 or SUN-SAT  | * / , - ?
 	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
 Special Characters
 
 
@@ -84,13 +95,13 @@ You may use one of several pre-defined schedules in place of a cron expression.
 
 
 Intervals
 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:
 or cron is run. This is supported by formatting the cron spec like this:
 
 
     @every <duration>
     @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
 For example, "@every 1h30m10s" would indicate a schedule that activates after
 1 hour, 30 minutes, 10 seconds, and then every interval after that.
 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
 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
 Be aware that jobs scheduled during daylight-savings leap-ahead transitions will
 not be run!
 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 {
 	if len(spec) == 0 {
 		return nil, fmt.Errorf("Empty spec string")
 		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
 	// Figure out how many fields we need
@@ -92,7 +105,7 @@ func (p Parser) Parse(spec string) (Schedule, error) {
 	}
 	}
 	min := max - p.optionals
 	min := max - p.optionals
 
 
-	// Split fields on whitespace
+	// Split on whitespace.
 	fields := strings.Fields(spec)
 	fields := strings.Fields(spec)
 
 
 	// Validate number of fields
 	// Validate number of fields
@@ -129,12 +142,13 @@ func (p Parser) Parse(spec string) (Schedule, error) {
 	}
 	}
 
 
 	return &SpecSchedule{
 	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
 	}, nil
 }
 }
 
 
@@ -314,57 +328,63 @@ func all(r bounds) uint64 {
 }
 }
 
 
 // parseDescriptor returns a predefined schedule for the expression, or error if none matches.
 // 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 {
 	switch descriptor {
 	case "@yearly", "@annually":
 	case "@yearly", "@annually":
 		return &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),
+			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
 		}, nil
 
 
 	case "@monthly":
 	case "@monthly":
 		return &SpecSchedule{
 		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
 		}, nil
 
 
 	case "@weekly":
 	case "@weekly":
 		return &SpecSchedule{
 		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
 		}, nil
 
 
 	case "@daily", "@midnight":
 	case "@daily", "@midnight":
 		return &SpecSchedule{
 		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
 		}, nil
 
 
 	case "@hourly":
 	case "@hourly":
 		return &SpecSchedule{
 		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
 		}, nil
+
 	}
 	}
 
 
 	const every = "@every "
 	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 {
 	entries := []struct {
 		expr     string
 		expr     string
 		expected Schedule
 		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 * * * *",
 			expr: "* 5 * * * *",
 			expected: &SpecSchedule{
 			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 {
 	for _, c := range entries {
 		actual, err := Parse(c.expr)
 		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)
 			t.Errorf("%s => unexpected error %v", c.expr, err)
 		}
 		}
 		if !reflect.DeepEqual(actual, c.expected) {
 		if !reflect.DeepEqual(actual, c.expected) {
@@ -203,7 +186,7 @@ func TestStandardSpecSchedule(t *testing.T) {
 	}{
 	}{
 		{
 		{
 			expr:     "5 * * * *",
 			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",
 			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.
 // traditional crontab specification. It is computed initially and stored as bit sets.
 type SpecSchedule struct {
 type SpecSchedule struct {
 	Second, Minute, Hour, Dom, Month, Dow uint64
 	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).
 // 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
 	// of the field list (since it is necessary to re-verify previous field
 	// values)
 	// 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).
 	// Start at the earliest possible time (the upcoming second).
 	t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond)
 	t = t.Add(1*time.Second - time.Duration(t.Nanosecond())*time.Nanosecond)
 
 
@@ -82,7 +98,7 @@ WRAP:
 		if !added {
 		if !added {
 			added = true
 			added = true
 			// Otherwise, set the date at the beginning (since the current time is irrelevant).
 			// 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)
 		t = t.AddDate(0, 1, 0)
 
 
@@ -96,7 +112,7 @@ WRAP:
 	for !dayMatches(s, t) {
 	for !dayMatches(s, t) {
 		if !added {
 		if !added {
 			added = true
 			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)
 		t = t.AddDate(0, 0, 1)
 
 
@@ -108,7 +124,7 @@ WRAP:
 	for 1<<uint(t.Hour())&s.Hour == 0 {
 	for 1<<uint(t.Hour())&s.Hour == 0 {
 		if !added {
 		if !added {
 			added = true
 			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)
 		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
 // 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
 		expected   bool
 	}{
 	}{
 		// Every fifteen minutes.
 		// 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.
 		// 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
 		// 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.
 		// 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
 		// Predefined schedules
 		{"Mon Jul 9 15:00 2012", "@hourly", true},
 		{"Mon Jul 9 15:00 2012", "@hourly", true},
@@ -43,20 +43,20 @@ func TestActivation(t *testing.T) {
 
 
 		// Test interaction of DOW and DOM.
 		// Test interaction of DOW and DOM.
 		// If both are specified, then only one needs to match.
 		// 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.
 		// 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 {
 	for _, test := range tests {
-		sched, err := Parse(test.spec)
+		sched, err := ParseStandard(test.spec)
 		if err != nil {
 		if err != nil {
 			t.Error(err)
 			t.Error(err)
 			continue
 			continue
@@ -76,19 +76,19 @@ func TestNext(t *testing.T) {
 		expected   string
 		expected   string
 	}{
 	}{
 		// Simple cases
 		// 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
 		// 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
 		// 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 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"},
 		{"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"},
 		{"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)
 		// 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
 		// 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
 		// 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)
 		// 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)
 		// 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
 		// hourly job
 		{"2012-11-04T00:00:00-0400", "0 0 * * * ?", "2012-11-04T01:00:00-0400"},
 		{"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",
 		"0 0 * * XYZ",
 	}
 	}
 	for _, spec := range invalidSpecs {
 	for _, spec := range invalidSpecs {
-		_, err := Parse(spec)
+		_, err := ParseStandard(spec)
 		if err == nil {
 		if err == nil {
 			t.Error("expected an error parsing: ", spec)
 			t.Error("expected an error parsing: ", spec)
 		}
 		}
@@ -185,22 +203,20 @@ func getTime(value string) time.Time {
 	if value == "" {
 	if value == "" {
 		return time.Time{}
 		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) {
 func TestNextWithTz(t *testing.T) {
@@ -209,15 +225,15 @@ func TestNextWithTz(t *testing.T) {
 		expected   string
 		expected   string
 	}{
 	}{
 		// Failing tests
 		// 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
 		// 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 {
 	for _, c := range runs {
-		sched, err := Parse(c.spec)
+		sched, err := ParseStandard(c.spec)
 		if err != nil {
 		if err != nil {
 			t.Error(err)
 			t.Error(err)
 			continue
 			continue