From be83a97b39c86421abef55987b559a46414ed2c0 Mon Sep 17 00:00:00 2001 From: AdamMagued Date: Wed, 14 Oct 2026 03:08:54 +0300 Subject: [PATCH] feat(rules/en): bias ExactMonthDate towards future dates when year is omitted --- rules/en/exact_month_date.go | 33 ++++++++++++++++- rules/en/exact_month_date_test.go | 60 ++++++++++++++++++++++++++++--- 2 files changed, 87 insertions(+), 6 deletions(-) diff --git a/rules/en/exact_month_date.go b/rules/en/exact_month_date.go index b7a88ab..62e6666 100644 --- a/rules/en/exact_month_date.go +++ b/rules/en/exact_month_date.go @@ -47,7 +47,9 @@ func ExactMonthDate(s rules.Strategy) rules.Rule { ), Applier: func(m *rules.Match, c *rules.Context, o *rules.Options, ref time.Time) (bool, error) { - _ = overwrite + if (c.Month != nil || c.Day != nil) && !overwrite { + return false, nil + } ord1 := strings.ToLower(strings.TrimSpace(m.Captures[0])) num1 := strings.ToLower(strings.TrimSpace(m.Captures[1])) @@ -102,7 +104,36 @@ func ExactMonthDate(s rules.Strategy) rules.Rule { c.Day = &num } + if c.Year == nil { + year := ref.Year() + day := ref.Day() + if c.Day != nil { + day = *c.Day + } + + if last := daysIn(time.Month(monInt), year); day > last { + day = last + } + + dateInRefYear := time.Date(year, time.Month(monInt), day, 0, 0, 0, 0, ref.Location()) + refDate := time.Date(ref.Year(), ref.Month(), ref.Day(), 0, 0, 0, 0, ref.Location()) + if dateInRefYear.Before(refDate) { + year++ + c.Year = &year + if c.Day != nil { + if last := daysIn(time.Month(monInt), year); *c.Day > last { + c.Day = &last + } + } + } + } + return true, nil }, } } + +func daysIn(m time.Month, year int) int { + return time.Date(year, m+1, 0, 0, 0, 0, 0, time.UTC).Day() +} + diff --git a/rules/en/exact_month_date_test.go b/rules/en/exact_month_date_test.go index d7f9d91..05c0a5f 100644 --- a/rules/en/exact_month_date_test.go +++ b/rules/en/exact_month_date_test.go @@ -29,7 +29,7 @@ func TestExactMonthDate(t *testing.T) { {"october 21st", 0, "october 21st", 6936 * time.Hour}, {"twentieth of december", 0, "twentieth of december", 8376 * time.Hour}, {"march 10th", 0, "march 10th", 1536 * time.Hour}, - {"jan. 4", 0, "jan. 4", -48 * time.Hour}, + {"jan. 4", 0, "jan. 4", 8736 * time.Hour}, {"february", 0, "february", 744 * time.Hour}, {"october", 0, "october", 6576 * time.Hour}, {"jul.", 0, "jul.", 4368 * time.Hour}, @@ -51,11 +51,11 @@ func TestExactMonthDateFromLongMonth(t *testing.T) { Text string Expected time.Time }{ - {"september 5th", time.Date(2016, time.September, 5, 0, 0, 0, 0, time.UTC)}, - {"february 11", time.Date(2016, time.February, 11, 0, 0, 0, 0, time.UTC)}, - {"1st of june", time.Date(2016, time.June, 1, 0, 0, 0, 0, time.UTC)}, + {"september 5th", time.Date(2017, time.September, 5, 0, 0, 0, 0, time.UTC)}, + {"february 11", time.Date(2017, time.February, 11, 0, 0, 0, 0, time.UTC)}, + {"1st of june", time.Date(2017, time.June, 1, 0, 0, 0, 0, time.UTC)}, // No day in the text: the reference day clamps to the month's last. - {"september", time.Date(2016, time.September, 30, 0, 0, 0, 0, time.UTC)}, + {"september", time.Date(2017, time.September, 30, 0, 0, 0, 0, time.UTC)}, // Months that do have a 31st keep the reference day untouched. {"december", time.Date(2016, time.December, 31, 0, 0, 0, 0, time.UTC)}, } @@ -67,3 +67,53 @@ func TestExactMonthDateFromLongMonth(t *testing.T) { require.Equal(t, f.Expected, res.Time, "[en.ExactMonthDate] time #%d", i) } } + +func TestExactMonthDateFutureBias(t *testing.T) { + w := when.New(nil) + w.Add(en.ExactMonthDate(rules.Override)) + + // End of year case from issue #29: + ref := time.Date(2021, time.December, 28, 12, 0, 0, 0, time.UTC) + + tests := []struct { + text string + expected time.Time + }{ + {"january 4th", time.Date(2022, time.January, 4, 12, 0, 0, 0, time.UTC)}, + {"january 4", time.Date(2022, time.January, 4, 12, 0, 0, 0, time.UTC)}, + {"jan. 4", time.Date(2022, time.January, 4, 12, 0, 0, 0, time.UTC)}, + {"4th of january", time.Date(2022, time.January, 4, 12, 0, 0, 0, time.UTC)}, + {"january", time.Date(2022, time.January, 28, 12, 0, 0, 0, time.UTC)}, + {"december 28th", time.Date(2021, time.December, 28, 12, 0, 0, 0, time.UTC)}, + {"december 29th", time.Date(2021, time.December, 29, 12, 0, 0, 0, time.UTC)}, + {"december 27th", time.Date(2022, time.December, 27, 12, 0, 0, 0, time.UTC)}, + } + + for _, tt := range tests { + res, err := w.Parse(tt.text, ref) + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, tt.expected, res.Time) + } + + // Leap year edge case: 2020 is a leap year, next year 2021 is not + leapRef := time.Date(2020, time.March, 1, 10, 0, 0, 0, time.UTC) + res, err := w.Parse("february 29th", leapRef) + require.NoError(t, err) + require.NotNil(t, res) + require.Equal(t, time.Date(2021, time.February, 28, 10, 0, 0, 0, time.UTC), res.Time) + + // Strategy check: skip should not override already set month + wSkip := when.New(nil) + wSkip.Add(en.ExactMonthDate(rules.Skip)) + ctx := &rules.Context{ + Month: func(i int) *int { return &i }(11), + } + match := en.ExactMonthDate(rules.Skip).Find("january 4th") + require.NotNil(t, match) + applied, err := match.Apply(ctx, nil, ref) + require.NoError(t, err) + require.False(t, applied) + require.Equal(t, 11, *ctx.Month) +} +