diff --git a/rules/common/common.go b/rules/common/common.go index 37509f0..1c5abe2 100644 --- a/rules/common/common.go +++ b/rules/common/common.go @@ -2,6 +2,32 @@ package common import "github.com/olebedev/when/rules" +var MONTHS_DAYS = []int{ + 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, +} + +func GetDays(year, month int) int { + if month > 12 || month < 0 { + return 0 + } + + if month != 2 { + return MONTHS_DAYS[month] + } + + if year%4 == 0 { + if year%400 == 0 { + return 29 + } + if year%100 == 0 { + return 28 + } + return 29 + } + + return 28 +} + var All = []rules.Rule{ SlashDMY(rules.Override), } diff --git a/rules/common/common_test.go b/rules/common/common_test.go index 2bafd1f..9d57c1f 100644 --- a/rules/common/common_test.go +++ b/rules/common/common_test.go @@ -18,7 +18,7 @@ type Fixture struct { Text string Index int Phrase string - Diff time.Duration + Want time.Time } func ApplyFixtures(t *testing.T, name string, w *when.Parser, fixt []Fixture) { @@ -28,7 +28,7 @@ func ApplyFixtures(t *testing.T, name string, w *when.Parser, fixt []Fixture) { require.NotNil(t, res, "[%s] res #%d", name, i) require.Equal(t, f.Index, res.Index, "[%s] index #%d", name, i) require.Equal(t, f.Phrase, res.Text, "[%s] text #%d", name, i) - require.Equal(t, f.Diff, res.Time.Sub(null), "[%s] diff #%d", name, i) + require.Equal(t, f.Want, res.Time, "[%s] %s diff #%d", name, f.Phrase, i) } } @@ -56,3 +56,10 @@ func TestAll(t *testing.T) { fixt := []Fixture{} ApplyFixtures(t, "common.All...", w, fixt) } + +func TestLeapYear(t *testing.T) { + require.Equal(t, common.GetDays(1999, 2), 28, "Normal year") + require.Equal(t, common.GetDays(2004, 2), 29, "Leap year") + require.Equal(t, common.GetDays(3000, 2), 28, "Century") + require.Equal(t, common.GetDays(2000, 2), 29, "Century divisible by 400") +} diff --git a/rules/common/slash_dmy.go b/rules/common/slash_dmy.go index 31d0799..7c0d887 100644 --- a/rules/common/slash_dmy.go +++ b/rules/common/slash_dmy.go @@ -20,18 +20,6 @@ also with "\", gift for windows' users https://play.golang.org/p/29LkTfe1Xr */ -var MONTHS_DAYS = []int{ - 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31, -} - -func getDays(year, month int) int { - // naive leap year check - if (year-2000)%4 == 0 && month == 2 { - return 29 - } - return MONTHS_DAYS[month] -} - func SlashDMY(s rules.Strategy) rules.Rule { return &rules.F{ @@ -58,39 +46,51 @@ func SlashDMY(s rules.Strategy) rules.Rule { return false, nil } + if month > 12 { + return false, nil + } + + if day > GetDays(ref.Year(), month) { + // invalid date: day is after last day of the month + return false, nil + } + WithYear: if year != -1 { - if getDays(year, month) >= day { - c.Year = &year - c.Month = &month - c.Day = &day - } else { - return false, nil - } + c.Year = &year + c.Month = &month + c.Day = &day return true, nil } - if int(ref.Month()) > month { - year = ref.Year() + 1 - goto WithYear - } + if o.WantPast { + if month > int(ref.Month()) { + year = ref.Year() - 1 + } else if month == int(ref.Month()) { + if day <= ref.Day() { + year = ref.Year() + } else { + year = ref.Year() - 1 + } + } else { + year = ref.Year() + } - if int(ref.Month()) == month { - if getDays(ref.Year(), month) >= day { - if day > ref.Day() { + } else { + if month < int(ref.Month()) { + year = ref.Year() + 1 + } else if month == int(ref.Month()) { + if day >= ref.Day() { year = ref.Year() - } else if day < ref.Day() { - year = ref.Year() + 1 } else { - return false, nil + year = ref.Year() + 1 } - goto WithYear } else { - return false, nil + year = ref.Year() } } - return true, nil + goto WithYear }, } } diff --git a/rules/common/slash_dmy_test.go b/rules/common/slash_dmy_test.go index d2b09f9..c905a41 100644 --- a/rules/common/slash_dmy_test.go +++ b/rules/common/slash_dmy_test.go @@ -11,22 +11,26 @@ import ( func TestSlashDMY(t *testing.T) { fixt := []Fixture{ - {"The Deadline is 10/10/2016", 16, "10/10/2016", (284 - OFFSET) * 24 * time.Hour}, - {"The Deadline is 1/2/2016", 16, "1/2/2016", (32 - OFFSET) * 24 * time.Hour}, - {"The Deadline is 29/2/2016", 16, "29/2/2016", (60 - OFFSET) * 24 * time.Hour}, + {"The Deadline is 10/10/2016", 16, "10/10/2016", time.Date(2016, 10, 10, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 1/2/2016", 16, "1/2/2016", time.Date(2016, 2, 1, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 29/2/2016", 16, "29/2/2016", time.Date(2016, 2, 29, 0, 0, 0, 0, time.UTC)}, // next year - {"The Deadline is 28/2", 16, "28/2", (59 + 366 - OFFSET) * 24 * time.Hour}, - {"The Deadline is 28/02/2017", 16, "28/02/2017", (59 + 366 - OFFSET) * 24 * time.Hour}, + {"The Deadline is 28/2", 16, "28/2", time.Date(2017, 2, 28, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 28/02/2017", 16, "28/02/2017", time.Date(2017, 2, 28, 0, 0, 0, 0, time.UTC)}, // right after w/o a year - {"The Deadline is 28/07", 16, "28/07", (210 - OFFSET) * 24 * time.Hour}, + {"The Deadline is 28/07", 16, "28/07", time.Date(2016, 7, 28, 0, 0, 0, 0, time.UTC)}, // before w/o a year - {"The Deadline is 30/06", 16, "30/06", (181 + 366 - OFFSET) * 24 * time.Hour}, + {"The Deadline is 30/06", 16, "30/06", time.Date(2017, 6, 30, 0, 0, 0, 0, time.UTC)}, // prev day will be added to the future - {"The Deadline is 14/07", 16, "14/07", (195 + 366 - OFFSET) * 24 * time.Hour}, + {"The Deadline is 14/07", 16, "14/07", time.Date(2017, 7, 14, 0, 0, 0, 0, time.UTC)}, + + // Existing doesn't work for a month in the future + {"The Deadline is 14/08", 16, "14/08", time.Date(2016, 8, 14, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 15/07", 16, "15/07", time.Date(2016, 7, 15, 0, 0, 0, 0, time.UTC)}, } w := when.New(nil) @@ -35,3 +39,31 @@ func TestSlashDMY(t *testing.T) { ApplyFixtures(t, "common.SlashDMY", w, fixt) } + +func TestSlashDMYPast(t *testing.T) { + fixt := []Fixture{ + {"The Deadline is 10/10/2016", 16, "10/10/2016", time.Date(2016, 10, 10, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 1/2/2016", 16, "1/2/2016", time.Date(2016, 2, 1, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 29/2/2016", 16, "29/2/2016", time.Date(2016, 2, 29, 0, 0, 0, 0, time.UTC)}, + + // before w/o a year says same year + {"The Deadline is 30/06", 16, "30/06", time.Date(2016, 6, 30, 0, 0, 0, 0, time.UTC)}, + + // prev day will still be this year + {"The Deadline is 14/07", 16, "14/07", time.Date(2016, 7, 14, 0, 0, 0, 0, time.UTC)}, + + // after w/o a year is prior year + {"The Deadline is 28/07", 16, "28/07", time.Date(2015, 7, 28, 0, 0, 0, 0, time.UTC)}, + + // Regression tests: current date and furture month + {"The Deadline is 15/07", 16, "15/07", time.Date(2016, 7, 15, 0, 0, 0, 0, time.UTC)}, + } + + w := when.New(&rules.Options{ + Distance: 5, + MatchByOrder: true, + WantPast: true}) + w.Add(common.SlashDMY(rules.Skip)) + + ApplyFixtures(t, "common.SlashDMY", w, fixt) +} diff --git a/rules/common/slash_mdy.go b/rules/common/slash_mdy.go new file mode 100644 index 0000000..e7d9597 --- /dev/null +++ b/rules/common/slash_mdy.go @@ -0,0 +1,94 @@ +package common + +import ( + "regexp" + "strconv" + "time" + + "github.com/olebedev/when/rules" +) + +/* + +- MM/DD/YYYY +- 3/14/2015 +- 03/14/2015 +- 3/14 + +also with "\", gift for windows' users +*/ + +func SlashMDY(s rules.Strategy) rules.Rule { + + return &rules.F{ + RegExp: regexp.MustCompile("(?i)(?:\\W|^)" + + "([0-3]{0,1}[0-9]{1})" + + "[\\/\\\\]" + + "([0-3]{0,1}[0-9]{1})" + + "(?:[\\/\\\\]" + + "((?:1|2)[0-9]{3})\\s*)?" + + "(?:\\W|$)"), + Applier: func(m *rules.Match, c *rules.Context, o *rules.Options, ref time.Time) (bool, error) { + if (c.Day != nil || c.Month != nil || c.Year != nil) && s != rules.Override { + return false, nil + } + + month, _ := strconv.Atoi(m.Captures[0]) + day, _ := strconv.Atoi(m.Captures[1]) + year := -1 + if m.Captures[2] != "" { + year, _ = strconv.Atoi(m.Captures[2]) + } + + if day == 0 { + return false, nil + } + + if month > 12 { + return false, nil + } + + if day > GetDays(ref.Year(), month) { + // invalid date: day is after last day of the month + return false, nil + } + + WithYear: + if year != -1 { + c.Year = &year + c.Month = &month + c.Day = &day + return true, nil + } + + if o.WantPast { + if month > int(ref.Month()) { + year = ref.Year() - 1 + } else if month == int(ref.Month()) { + if day <= ref.Day() { + year = ref.Year() + } else { + year = ref.Year() - 1 + } + } else { + year = ref.Year() + } + + } else { + if month < int(ref.Month()) { + year = ref.Year() + 1 + } else if month == int(ref.Month()) { + if day >= ref.Day() { + year = ref.Year() + } else { + year = ref.Year() + 1 + } + } else { + year = ref.Year() + } + } + + goto WithYear + }, + } +} diff --git a/rules/common/slash_mdy_test.go b/rules/common/slash_mdy_test.go new file mode 100644 index 0000000..45d8661 --- /dev/null +++ b/rules/common/slash_mdy_test.go @@ -0,0 +1,69 @@ +package common_test + +import ( + "testing" + "time" + + "github.com/olebedev/when" + "github.com/olebedev/when/rules" + "github.com/olebedev/when/rules/common" +) + +func TestSlashMDY(t *testing.T) { + fixt := []Fixture{ + {"The Deadline is 10/10/2016", 16, "10/10/2016", time.Date(2016, 10, 10, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 2/1/2016", 16, "2/1/2016", time.Date(2016, 2, 1, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 2/29/2016", 16, "2/29/2016", time.Date(2016, 2, 29, 0, 0, 0, 0, time.UTC)}, + + // next year + {"The Deadline is 2/28", 16, "2/28", time.Date(2017, 2, 28, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 02/28/2017", 16, "02/28/2017", time.Date(2017, 2, 28, 0, 0, 0, 0, time.UTC)}, + + // right after w/o a year + {"The Deadline is 07/28", 16, "07/28", time.Date(2016, 7, 28, 0, 0, 0, 0, time.UTC)}, + + // before w/o a year + {"The Deadline is 06/30", 16, "06/30", time.Date(2017, 6, 30, 0, 0, 0, 0, time.UTC)}, + + // prev day will be added to the future + {"The Deadline is 07/14", 16, "07/14", time.Date(2017, time.July, 14, 0, 0, 0, 0, time.UTC)}, + + // Current day or future months + {"The Deadline is 8/14", 16, "8/14", time.Date(2016, 8, 14, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 7/15", 16, "7/15", time.Date(2016, 7, 15, 0, 0, 0, 0, time.UTC)}, + } + + w := when.New(nil) + w.Add(common.SlashMDY(rules.Override)) + + ApplyFixtures(t, "common.SlashMDY", w, fixt) + +} + +func TestSlashMDYPast(t *testing.T) { + fixt := []Fixture{ + {"The Deadline is 10/10/2016", 16, "10/10/2016", time.Date(2016, 10, 10, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 2/1/2016", 16, "2/1/2016", time.Date(2016, 2, 1, 0, 0, 0, 0, time.UTC)}, + {"The Deadline is 2/29/2016", 16, "2/29/2016", time.Date(2016, 2, 29, 0, 0, 0, 0, time.UTC)}, + + // before w/o a year says same year + {"The Deadline is 06/30", 16, "06/30", time.Date(2016, 6, 30, 0, 0, 0, 0, time.UTC)}, + + // prev day will still be this year + {"The Deadline is 07/14", 16, "07/14", time.Date(2016, 7, 14, 0, 0, 0, 0, time.UTC)}, + + // after w/o a year is prior year + {"The Deadline is 07/28", 16, "07/28", time.Date(2015, 7, 28, 0, 0, 0, 0, time.UTC)}, + + // Regression tests: current date and furture month + {"The Deadline is 07/15", 16, "07/15", time.Date(2016, 7, 15, 0, 0, 0, 0, time.UTC)}, + } + + w := when.New(&rules.Options{ + Distance: 5, + MatchByOrder: true, + WantPast: true}) + w.Add(common.SlashMDY(rules.Skip)) + + ApplyFixtures(t, "common.SlashMDY", w, fixt) +} diff --git a/rules/en/en_test.go b/rules/en/en_test.go index 3850dca..9ad0293 100644 --- a/rules/en/en_test.go +++ b/rules/en/en_test.go @@ -5,6 +5,7 @@ import ( "time" "github.com/olebedev/when" + "github.com/olebedev/when/rules" "github.com/olebedev/when/rules/en" "github.com/stretchr/testify/require" ) @@ -21,11 +22,11 @@ type Fixture struct { func ApplyFixtures(t *testing.T, name string, w *when.Parser, fixt []Fixture) { for i, f := range fixt { res, err := w.Parse(f.Text, null) - require.Nil(t, err, "[%s] err #%d", name, i) - require.NotNil(t, res, "[%s] res #%d", name, i) - require.Equal(t, f.Index, res.Index, "[%s] index #%d", name, i) - require.Equal(t, f.Phrase, res.Text, "[%s] text #%d", name, i) - require.Equal(t, f.Diff, res.Time.Sub(null), "[%s] diff #%d", name, i) + require.Nil(t, err, "[%s] %s err #%d", name, f.Text, i) + require.NotNil(t, res, "[%s] %s res #%d", name, f.Text, i) + require.Equal(t, f.Index, res.Index, "[%s] %s index #%d", name, f.Text, i) + require.Equal(t, f.Phrase, res.Text, "[%s] %s text #%d", name, f.Text, i) + require.Equal(t, null.Add(f.Diff), res.Time, "[%s] %s diff #%d", name, f.Text, i) } } @@ -61,3 +62,20 @@ func TestAll(t *testing.T) { ApplyFixtures(t, "en.All...", w, fixt) } + +func TestAllPast(t *testing.T) { + w := when.New(&rules.Options{ + Distance: 5, + MatchByOrder: true, + WantPast: true}) + w.Add(en.All...) + + // complex cases + fixt := []Fixture{ + {"at Friday afternoon", 3, "Friday afternoon", (((2 - 7) * 24) + 15) * time.Hour}, + {"tuesday at 14:00", 0, "tuesday at 14:00", ((-1 * 24) + 14) * time.Hour}, + {"tuesday at 2p", 0, "tuesday at 2p", ((-1 * 24) + 14) * time.Hour}, + } + + ApplyFixtures(t, "en.All... WantPast", w, fixt) +} diff --git a/rules/en/exact_month_date.go b/rules/en/exact_month_date.go index b7a88ab..9bd7a0a 100644 --- a/rules/en/exact_month_date.go +++ b/rules/en/exact_month_date.go @@ -102,6 +102,35 @@ func ExactMonthDate(s rules.Strategy) rules.Rule { c.Day = &num } + if o.WantPast { + year := ref.Year() + + if c.Month != nil { + if *c.Month > int(ref.Month()) { + // future month + year = year - 1 + } else if *c.Month == int(ref.Month()) { + if c.Day != nil && *c.Day > int(ref.Month()) { + // future day in this month + year = year - 1 + } + } else { + // today or past + } + } else { + if c.Day != nil { + if *c.Day > int(ref.Day()) { + // future day in this month + year = year - 1 + } else { + // today or past + } + } + } + + c.Year = &year // XXXX: is this a potential nil pointer reference? + } + return true, nil }, } diff --git a/rules/en/exact_month_date_test.go b/rules/en/exact_month_date_test.go index 38b3dff..e045fba 100644 --- a/rules/en/exact_month_date_test.go +++ b/rules/en/exact_month_date_test.go @@ -33,7 +33,50 @@ func TestExactMonthDate(t *testing.T) { {"october", 0, "october", 6576 * time.Hour}, {"jul.", 0, "jul.", 4368 * time.Hour}, {"june", 0, "june", 3648 * time.Hour}, + + // TODO: allow specifying the xth of the month + // {"the 1st", 4, "1st", date(2016, 1, 1)}, + // {"the 10th", 4, "10th", date(2015, 1, 10)}, } ApplyFixtures(t, "en.ExactMonthDate", w, fixtok) } + +func date(year int, month time.Month, day int) time.Duration { + return time.Date(year, month, day, 0, 0, 0, 0, time.UTC).Sub(null) +} + +func TestExactMonthDatePast(t *testing.T) { + w := when.New(&rules.Options{Distance: 5, MatchByOrder: true, WantPast: true}) + w.Add(en.ExactMonthDate(rules.Override)) + + fixtok := []Fixture{ + {"third of march", 0, "third of march", date(2015, 3, 3)}, + {"march third", 0, "march third", date(2015, 3, 3)}, + {"march 3rd", 0, "march 3rd", date(2015, 3, 3)}, + {"3rd march", 0, "3rd march", date(2015, 3, 3)}, + {"march 3", 0, "march 3", date(2015, 3, 3)}, + {"1 september", 0, "1 september", date(2015, 9, 1)}, + {"1 sept", 0, "1 sept", date(2015, 9, 1)}, + {"1 sept.", 0, "1 sept.", date(2015, 9, 1)}, + {"1st of september", 0, "1st of september", date(2015, 9, 1)}, + {"sept. 1st", 0, "sept. 1st", date(2015, 9, 1)}, + {"march 7th", 0, "march 7th", date(2015, 3, 7)}, + {"october 21st", 0, "october 21st", date(2015, 10, 21)}, + {"twentieth of december", 0, "twentieth of december", date(2015, 12, 20)}, + {"march 10th", 0, "march 10th", date(2015, 3, 10)}, + {"jan 1st", 0, "jan 1st", date(2016, 1, 1)}, + {"jan. 4", 0, "jan. 4", date(2015, 1, 4)}, + {"fourth of jan", 0, "fourth of jan", date(2015, 1, 4)}, + {"january", 0, "january", date(2016, 1, 6)}, + {"october", 0, "october", date(2015, 10, 6)}, + {"jul.", 0, "jul.", date(2015, 7, 6)}, + {"june", 0, "june", date(2015, 6, 6)}, + + // TODO: allow specifying the xth of the month + // {"1st", 0, "1st", date(2016, 1, 1)}, + // {"10th", 0, "10th", date(2015, 1, 10)}, + } + + ApplyFixtures(t, "en.ExactMonthDate WantPast", w, fixtok) +} diff --git a/rules/en/weekday.go b/rules/en/weekday.go index a280ff2..5c2da06 100644 --- a/rules/en/weekday.go +++ b/rules/en/weekday.go @@ -26,9 +26,15 @@ func Weekday(s rules.Strategy) rules.Rule { day := strings.ToLower(strings.TrimSpace(m.Captures[1])) norm := strings.ToLower(strings.TrimSpace(m.Captures[0] + m.Captures[2])) + if norm == "" { - norm = "next" + if o.WantPast { + norm = "last" + } else { + norm = "next" + } } + dayInt, ok := WEEKDAY_OFFSET[day] if !ok { return false, nil diff --git a/rules/en/weekday_test.go b/rules/en/weekday_test.go index 1599c3c..e5173ea 100644 --- a/rules/en/weekday_test.go +++ b/rules/en/weekday_test.go @@ -9,6 +9,8 @@ import ( "github.com/olebedev/when/rules/en" ) +// Reference date is Wed, Jan 6, 2016 + func TestWeekday(t *testing.T) { // current is Friday fixt := []Fixture{ @@ -26,6 +28,10 @@ func TestWeekday(t *testing.T) { {"this tuesday", 0, "this tuesday", -(24 * time.Hour)}, {"drop me a line at this wednesday", 18, "this wednesday", 0}, {"this saturday", 0, "this saturday", 3 * 24 * time.Hour}, + // not specified + {"tuesday", 0, "tuesday", (7 - 1) * 24 * time.Hour}, + {"wednesday", 0, "wednesday", (7 - 0) * 24 * time.Hour}, + {"saturday", 0, "saturday", 3 * 24 * time.Hour}, } w := when.New(nil) @@ -34,3 +40,22 @@ func TestWeekday(t *testing.T) { ApplyFixtures(t, "en.Weekday", w, fixt) } + +func TestWeekdayPast(t *testing.T) { + // current is Friday + fixt := []Fixture{ + // not specified + {"tuesday", 0, "tuesday", -1 * 24 * time.Hour}, + {"wednesday", 0, "wednesday", -7 * 24 * time.Hour}, + {"saturday", 0, "saturday", (3 - 7) * 24 * time.Hour}, + } + + w := when.New(&rules.Options{ + Distance: 5, + MatchByOrder: true, + WantPast: true}) + + w.Add(en.Weekday(rules.Override)) + + ApplyFixtures(t, "en.Weekday WantPast", w, fixt) +} diff --git a/rules/rules.go b/rules/rules.go index 2011d3f..f696d20 100644 --- a/rules/rules.go +++ b/rules/rules.go @@ -24,6 +24,8 @@ type Options struct { MatchByOrder bool + WantPast bool + // TODO // WeekStartsOn time.Weekday } diff --git a/when.go b/when.go index beb6626..85e74c4 100644 --- a/when.go +++ b/when.go @@ -5,10 +5,10 @@ import ( "time" "github.com/olebedev/when/rules" + "github.com/olebedev/when/rules/br" "github.com/olebedev/when/rules/common" "github.com/olebedev/when/rules/en" "github.com/olebedev/when/rules/ru" - "github.com/olebedev/when/rules/br" "github.com/pkg/errors" ) @@ -142,6 +142,7 @@ func New(o *rules.Options) *Parser { var defaultOptions = &rules.Options{ Distance: 5, MatchByOrder: true, + WantPast: false, } // EN is a parser for English language