diff --git a/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt b/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt
index 0030fa1a..f9cedd4f 100644
--- a/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt
+++ b/Ical.Net.Tests/Calendars/Recurrence/RecurrenceTestCases.txt
@@ -181,3 +181,78 @@ INSTANCES:20250101,20251231,20260101
DTSTART:20250601
RRULE:FREQ=YEARLY;BYMONTH=6;BYMONTHDAY=2,-2;UNTIL=20250630
INSTANCES:20250602,20250629
+
+############################## START ERRATA 1913 TESTS ##############################
+# RFC errata 1913 (https://www.rfc-editor.org/errata_search.php?rfc=1913&eid=1913):
+# 'The numeric value in a BYDAY rule part with the FREQ rule part set to YEARLY corresponds
+# to an offset within the month when the BYMONTH rule part is present, and corresponds to an
+# offset within the year when the BYWEEKNO or BYMONTH rule parts are NOT present.'
+#
+# These 12 tests verify the behavior with the interpretation that
+# * when only BYMONTH is present, the numeric BYDAY offset applies within the month,
+# * when BYWEEKNO is present, the numeric BYDAY offset applies within the week.
+# Note: RFC says that BYWEEKNO + numeric BYDAY is invalid. We're taking the lenient approach here.
+#
+# Disclaimer: Other iCalendar libraries may interpret this differently.
+
+# Yearly BYMONTH numeric BYDAY (2MO) - 2nd Monday in each BYMONTH
+RRULE:FREQ=YEARLY;BYMONTH=6,9;BYDAY=2MO;COUNT=4
+DTSTART:20260608T090000
+INSTANCES:20260608T090000,20260914T090000,20270614T090000,20270913T090000
+
+# Yearly without BYMONTH numeric BYDAY (20MO) - 20th Monday of the YEAR
+RRULE:FREQ=YEARLY;BYDAY=20MO;COUNT=3
+DTSTART:20260518T090000
+INSTANCES:20260518T090000,20270517T090000,20280515T090000
+
+# Yearly BYMONTH negative BYDAY (-1SU) - last Sunday of each BYMONTH
+RRULE:FREQ=YEARLY;BYMONTH=6,9;BYDAY=-1SU;COUNT=2
+DTSTART:20260628T090000
+INSTANCES:20260628T090000,20260927T090000
+
+# Yearly BYMONTH=3 BYDAY=1MO - valid: 1st Monday of March each year
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=1MO;UNTIL=20270101T000000Z
+DTSTART:20240304T090000
+START-AT:20240101
+INSTANCES:20240304T090000,20250303T090000,20260302T090000
+
+# Yearly BYMONTH=3 BYDAY=6MO - invalid: 6th Monday in March doesn't exist
+RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=6MO
+DTSTART:20240101T090000
+START-AT:20240101
+EXCEPTION:Ical.Net.Evaluation.EvaluationOutOfRangeException
+EXCEPTION-STEP:Enumeration
+
+# Yearly BYWEEKNO=10 BYDAY=1MO - valid: Monday of ISO week 10 each year
+RRULE:FREQ=YEARLY;BYWEEKNO=10;BYDAY=1MO;COUNT=3
+DTSTART:20240304T090000
+INSTANCES:20240304T090000,20250303T090000,20260302T090000
+
+# Yearly BYWEEKNO=10 BYDAY=2MO - invalid: 2nd Monday in a week doesn't exist -> out of range
+RRULE:FREQ=YEARLY;BYWEEKNO=10;BYDAY=2MO
+DTSTART:20240101T090000
+EXCEPTION:Ical.Net.Evaluation.EvaluationOutOfRangeException
+EXCEPTION-STEP:Enumeration
+
+# Monthly BYMONTHDAY=13 with BYDAY=MO
+RRULE:FREQ=MONTHLY;BYMONTHDAY=13;BYDAY=MO;UNTIL=20271231T235959Z
+DTSTART:20250913T090000
+START-AT:20251013
+INSTANCES:20251013T090000,20260413T090000,20260713T090000,20270913T090000,20271213T090000
+
+# Yearly BYMONTHDAY=1,8 with BYDAY offsets (expand matrix - note2)
+RRULE:FREQ=YEARLY;BYMONTHDAY=1,8;BYDAY=22MO,23TU,25MO,36TU;UNTIL=20350101T000000
+DTSTART:20260601
+INSTANCES:20260601,20270608,20320608
+
+# Monthly BYMONTHDAY=1,8; BYDAY=1MO,2TU - limiting behavior: Monthly by month day with day offsets
+RRULE:FREQ=MONTHLY;BYMONTHDAY=1,8;BYDAY=1MO,2TU;UNTIL=20270101
+DTSTART:20260601
+INSTANCES:20260601,20260908,20261208
+
+# Yearly BYMONTH=6-12; BYMONTHDAY=1,8; BYDAY=1MO,2TU - Yearly by month and by month day, with day offsets
+RRULE:FREQ=YEARLY;BYMONTH=6,7,8,9,10,11,12;BYMONTHDAY=1,8;BYDAY=1MO,2TU;UNTIL=20270101
+DTSTART:20260601
+INSTANCES:20260601,20260908,20261208
+
+############################## END ERRATA 1913 TESTS ##############################
diff --git a/Ical.Net.Tests/RecurrenceTests.cs b/Ical.Net.Tests/RecurrenceTests.cs
index 437abec4..154eecd9 100644
--- a/Ical.Net.Tests/RecurrenceTests.cs
+++ b/Ical.Net.Tests/RecurrenceTests.cs
@@ -15,6 +15,7 @@
using Ical.Net.Evaluation;
using Ical.Net.Serialization;
using Ical.Net.Serialization.DataTypes;
+using Ical.Net.Tests.TestHelpers;
using NUnit.Framework;
using NUnit.Framework.Constraints;
@@ -32,38 +33,7 @@ private void EventOccurrenceTest(
Period[] expectedPeriods,
string[]? timeZones,
int eventIndex
- )
- {
- var evt = cal.Events.Skip(eventIndex).First();
- var rule = evt.RecurrenceRules.FirstOrDefault();
-
- var occurrences = toDate == null
- ? evt.GetOccurrences(fromDate).ToList()
- : evt.GetOccurrences(fromDate).TakeWhileBefore(toDate).ToList();
-
- Assert.Multiple(() =>
- {
- Assert.That(
- occurrences,
- Has.Count.EqualTo(expectedPeriods.Length),
- "There should have been " + expectedPeriods.Length + " occurrences; there were " + occurrences.Count);
-
- if (evt.RecurrenceRules.Count > 0)
- {
- Assert.That(evt.RecurrenceRules, Has.Count.EqualTo(1));
- }
-
- for (var i = 0; i < expectedPeriods.Length; i++)
- {
- var period = new Period(expectedPeriods[i].StartTime, expectedPeriods[i].EffectiveDuration!.Value);
-
- Assert.That(occurrences[i].Period, Is.EqualTo(period), "Event should occur on " + period);
- if (timeZones != null)
- Assert.That(period.StartTime.TimeZoneName, Is.EqualTo(timeZones[i]),
- "Event " + period + " should occur in the " + timeZones[i] + " timezone");
- }
- });
- }
+ ) => OccurrenceTester.AssertOccurrences(cal, fromDate, toDate, expectedPeriods, timeZones, eventIndex);
private void EventOccurrenceTest(
Calendar cal,
@@ -71,22 +41,22 @@ private void EventOccurrenceTest(
CalDateTime? toDate,
Period[] expectedPeriods,
string[]? timeZones
- ) => EventOccurrenceTest(cal, fromDate, toDate, expectedPeriods, timeZones, 0);
+ ) => OccurrenceTester.AssertOccurrences(cal, fromDate, toDate, expectedPeriods, timeZones, 0);
- private static readonly TestCaseData[] EventOccurrenceTestCases = new TestCaseData[]
- {
+ private static readonly TestCaseData[] EventOccurrenceTestCases =
+ [
new("""
DTSTART;TZID=Europe/Amsterdam:20201024T023000
DURATION:PT5M
RRULE:FREQ=DAILY;UNTIL=20201025T010000Z
""",
- new[]
- {
+ (string[])
+ [
"20201024T023000/PT5M",
"20201025T023000/PT5M"
- }
- ),
- };
+ ]
+ )
+ ];
[Test, Category("Recurrence")]
[TestCaseSource(nameof(EventOccurrenceTestCases))]
diff --git a/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs b/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs
new file mode 100644
index 00000000..94316e96
--- /dev/null
+++ b/Ical.Net.Tests/TestHelpers/OccurrenceTester.cs
@@ -0,0 +1,54 @@
+//
+// Copyright ical.net project maintainers and contributors.
+// Licensed under the MIT license.
+//
+#nullable enable
+using System.Linq;
+using Ical.Net.DataTypes;
+using NUnit.Framework;
+
+namespace Ical.Net.Tests.TestHelpers;
+
+internal static class OccurrenceTester
+{
+ public static void AssertOccurrences(
+ Calendar cal,
+ CalDateTime? fromDate,
+ CalDateTime? toDate,
+ Period[] expectedPeriods,
+ string[]? timeZones,
+ int eventIndex
+ )
+ {
+ var evt = cal.Events.Skip(eventIndex).First();
+
+ var occurrences = toDate == null
+ ? evt.GetOccurrences(fromDate).ToList()
+ : evt.GetOccurrences(fromDate).TakeWhileBefore(toDate).ToList();
+
+ Assert.Multiple(() =>
+ {
+ Assert.That(
+ occurrences,
+ Has.Count.EqualTo(expectedPeriods.Length),
+ $"There should have been {expectedPeriods.Length} occurrences; there were {occurrences.Count}");
+
+ if (evt.RecurrenceRules.Count > 0)
+ {
+ Assert.That(evt.RecurrenceRules, Has.Count.EqualTo(1));
+ }
+
+ for (var i = 0; i < expectedPeriods.Length; i++)
+ {
+ var period = new Period(expectedPeriods[i].StartTime, expectedPeriods[i].EffectiveDuration!.Value);
+
+ Assert.That(occurrences[i].Period, Is.EqualTo(period), "Event should occur on " + period);
+ if (timeZones != null)
+ {
+ Assert.That(period.StartTime.TimeZoneName, Is.EqualTo(timeZones[i]),
+ $"Event {period} should occur in the {timeZones[i]} timezone");
+ }
+ }
+ });
+ }
+}
diff --git a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs
index c5e5b4ee..5552449d 100644
--- a/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs
+++ b/Ical.Net/Evaluation/RecurrencePatternEvaluator.cs
@@ -310,27 +310,29 @@ private static CalDateTime GetIntervalLowerLimit(CalDateTime intervalRefTime, Re
private struct ExpandContext
{
///
- /// Indicates whether the dates have been fully expanded. If true, subsequent parts should only limit, not expand.
+ /// True when the candidate set has already been expanded by an earlier BY-* part.
+ /// When true, subsequent BY-* parts must only limit/filter, not expand.
///
- ///
- /// This makes a difference in case of BYWEEKNO, which might span months and years. After it was applied (BYWEEKNO would
- /// always expand), the subsequent parts mustn't expand.
- ///
- public bool DatesFullyExpanded { get; set; }
+ ///
+ /// BYWEEKNO can expand the candidate set across month and year boundaries. When BYWEEKNO
+ /// performs expansion (i.e. the expand behavior is enabled for BYWEEKNO), the evaluator
+ /// marks the candidate set as expanded and subsequent BY‑parts must not expand again.
+ ///
+ public bool IsCandidateSetFullyExpanded { get; set; }
}
///
/// Returns a list of possible dates generated from the applicable BY* rules, using the specified date as a seed.
///
- /// The seed date.
+ /// The seed date. It is always returned in the list of possible dates.
///
///
/// A list of possible dates.
- private IEnumerable GetCandidates(CalDateTime date, RecurrencePattern pattern, bool?[] expandBehaviors)
+ private IEnumerable GetCandidates(CalDateTime seedDate, RecurrencePattern pattern, bool?[] expandBehaviors)
{
- var expandContext = new ExpandContext() { DatesFullyExpanded = false };
+ var expandContext = new ExpandContext { IsCandidateSetFullyExpanded = false };
- IEnumerable dates = [date];
+ IEnumerable dates = [seedDate];
dates = GetMonthVariants(dates, pattern, expandBehaviors[0]);
dates = GetWeekNoVariants(dates, pattern, expandBehaviors[1], ref expandContext);
dates = GetYearDayVariants(dates, pattern, expandBehaviors[2], ref expandContext);
@@ -422,7 +424,7 @@ private static IEnumerable GetWeekNoVariants(IEnumerable GetByWeekNoForYearNormalized(RecurrencePattern pattern,
///
///
/// Context that indicates whether earlier parts have already fully expanded the candidate set.
- /// If is true then expansion must not be
+ /// If is true then expansion must not be
/// performed again and the method should behave in limit mode.
- /// When this method performs an expansion it will set
+ /// When this method performs an expansion it will set
/// to true to prevent later parts from expanding again.
///
///
@@ -506,9 +508,9 @@ private static IEnumerable GetYearDayVariants(IEnumerable GetMonthDayVariants(IEnumerable GetDayVariants(IEnumerable
if (expand == null || pattern.ByDay.Count == 0)
return dates;
- if (expand.Value && !expandContext.DatesFullyExpanded)
+ if (expand.Value && !expandContext.IsCandidateSetFullyExpanded)
{
// Expand behavior
- expandContext.DatesFullyExpanded = true;
+ expandContext.IsCandidateSetFullyExpanded = true;
return GetDayVariantsExpanded(dates, pattern);
}
@@ -636,15 +638,25 @@ private static IEnumerable GetDayVariants(IEnumerable
private static IEnumerable GetDayVariantsLimited(IEnumerable dates, RecurrencePattern pattern)
=>
- // If no offset is specified, simply test the day of week!
- // FIXME: test with offset...
- dates.Where(date => pattern.ByDay.Any(weekDay => weekDay.DayOfWeek.Equals(date.DayOfWeek)));
-
+ // If no offset is specified, simply test the day of week.
+ // When an offset is present, use GetAbsWeekDays to compute the concrete
+ // weekday dates according to the frequency and check containment.
+ dates.Where(date => pattern.ByDay.Any(weekDay =>
+ {
+ if (weekDay.Offset is null)
+ return weekDay.DayOfWeek.Equals(date.DayOfWeek);
+
+ // When limiting with an offset (e.g. "22MO" or "1MO"), compute the
+ // absolute dates for that WeekDay in the appropriate scope and
+ // check if the candidate matches one of them.
+ return GetAbsWeekDays(date, weekDay, pattern).Any(d => d.Equals(date));
+ }));
+
private static IEnumerable GetDayVariantsExpanded(IEnumerable dates, RecurrencePattern pattern)
{
foreach (var date in dates)
{
- var weekDayDates = new SortedSet();
+ var weekDayDates = new SortedSet(); // SortedSet uses CalDateTime.CompareTo
foreach (var day in pattern.ByDay)
foreach (var d in GetAbsWeekDays(date, day, pattern))
weekDayDates.Add(d);
@@ -681,15 +693,16 @@ private static IEnumerable GetAbsWeekDaysDaily(CalDateTime date, We
private static IEnumerable GetAbsWeekDaysYearly(CalDateTime date, WeekDay weekDay)
{
var year = date.Year;
+ var daysInYear = DateTime.IsLeapYear(year) ? 366 : 365;
- // construct a list of possible year days..
+ // Go to Jan 1 and find first occurrence of target weekday
date = date.AddDays(-date.DayOfYear + 1);
- while (date.DayOfWeek != weekDay.DayOfWeek)
- {
- date = date.AddDays(1);
- }
+ var offset = ((int) weekDay.DayOfWeek - (int) date.DayOfWeek + 7) % 7;
+ date = date.AddDays(offset);
- while (date.Year == year)
+ // Yield all occurrences (52 or 53 per year)
+ var occurrenceCount = (daysInYear - offset + 6) / 7;
+ for (var i = 0; i < occurrenceCount; i++)
{
yield return date;
date = date.AddDays(7);
@@ -699,24 +712,31 @@ private static IEnumerable GetAbsWeekDaysYearly(CalDateTime date, W
private static IEnumerable GetAbsWeekDaysMonthly(CalDateTime date, RecurrencePattern pattern, WeekDay weekDay)
{
var month = date.Month;
+ var year = date.Year;
+ var daysInMonth = Calendar.GetDaysInMonth(year, month);
- // construct a list of possible month days..
+ // Go to first day of month and find first occurrence of target weekday
date = date.AddDays(-date.Day + 1);
- while (date.DayOfWeek != weekDay.DayOfWeek)
- {
- date = date.AddDays(1);
- }
+ var offset = ((int) weekDay.DayOfWeek - (int) date.DayOfWeek + 7) % 7;
+ date = date.AddDays(offset);
- var byWeekNoNormalized = GetByWeekNoForYearNormalized(pattern, Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek));
- while (date.Month == month)
- {
- var currentWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek);
+ // Pre-calculate occurrence count (4 or 5 occurrences per month)
+ var occurrenceCount = (daysInMonth - offset + 6) / 7;
+
+ var byWeekNoNormalized = pattern.ByWeekNo.Count > 0
+ ? GetByWeekNoForYearNormalized(pattern, Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek))
+ : null;
- if ((byWeekNoNormalized.Count == 0 || byWeekNoNormalized.Contains(currentWeekNo))
- && (pattern.ByMonth.Count == 0 || pattern.ByMonth.Contains(date.Month)))
+ for (var i = 0; i < occurrenceCount; i++)
+ {
+ if (byWeekNoNormalized == null || byWeekNoNormalized.Contains(Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek)))
{
- yield return date;
+ if (pattern.ByMonth.Count == 0 || pattern.ByMonth.Contains(date.Month))
+ {
+ yield return date;
+ }
}
+
date = date.AddDays(7);
}
}
@@ -726,26 +746,31 @@ private static IEnumerable GetAbsWeekDaysWeekly(CalDateTime date, R
var weekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek);
// Go to the first day of the week
- date = date.AddDays(-GetWeekDayOffset(date, pattern.FirstDayOfWeek));
+ var weekDayOffset = GetWeekDayOffset(date, pattern.FirstDayOfWeek);
+ date = date.AddDays(-weekDayOffset);
- // construct a list of possible week days..
- while (date.DayOfWeek != weekDay.DayOfWeek)
- {
- date = date.AddDays(1);
- }
+ // Find first occurrence of target weekday
+ var offset = ((int) weekDay.DayOfWeek - (int) date.DayOfWeek + 7) % 7;
+ date = date.AddDays(offset);
- var nextWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek);
var currentWeekNo = Calendar.GetIso8601WeekOfYear(date, pattern.FirstDayOfWeek);
- var byWeekNoNormalized = GetByWeekNoForYearNormalized(pattern, Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek));
+ var nextWeekNo = currentWeekNo;
- //When we manage weekly recurring pattern and we have boundary case:
- //Weekdays: Dec 31, Jan 1, Feb 1, Mar 1, Apr 1, May 1, June 1, Dec 31 - It's the 53th week of the year, but all another are 1st week number.
+ var byWeekNoNormalized = pattern.ByWeekNo.Count > 0
+ ? GetByWeekNoForYearNormalized(pattern, Calendar.GetIso8601YearOfWeek(date, pattern.FirstDayOfWeek))
+ : null;
+
+ // When we manage weekly recurring pattern, and we have boundary case:
+ // Weekdays: Dec 31, Jan 1, Feb 1, Mar 1, Apr 1, May 1, June 1, Dec 31 -
+ // It's the 53rd week of the year, but all others are 1st week number.
while (currentWeekNo == weekNo || (nextWeekNo < weekNo && currentWeekNo == nextWeekNo && pattern.Frequency == FrequencyType.Weekly))
{
- if ((byWeekNoNormalized.Count == 0 || byWeekNoNormalized.Contains(currentWeekNo))
- && (pattern.ByMonth.Count == 0 || pattern.ByMonth.Contains(date.Month)))
+ if (byWeekNoNormalized == null || byWeekNoNormalized.Contains(currentWeekNo))
{
- yield return date;
+ if (pattern.ByMonth.Count == 0 || pattern.ByMonth.Contains(date.Month))
+ {
+ yield return date;
+ }
}
date = date.AddDays(7);
@@ -768,18 +793,23 @@ private static int GetWeekDayOffset(CalDateTime date, DayOfWeek startOfWeek)
/// The position of the element to extract.
private static IEnumerable GetOffsetDates(IEnumerable dates, int? offset)
{
- if (offset is null)
- return dates;
-
- if (offset == 0)
- throw new EvaluationException("Encountered a day offset of 0 which is not allowed.");
-
- if (offset < 0) {
- offset = -offset;
- dates = dates.Reverse();
+ switch (offset)
+ {
+ case null:
+ return dates;
+ case 0:
+ throw new EvaluationException("Encountered a day offset of 0 which is not allowed.");
+ case < 0:
+ {
+ var list = dates as IList ?? dates.ToList();
+ var index = list.Count + offset.Value;
+ return index >= 0 && index < list.Count
+ ? [list[index]]
+ : [];
+ }
+ default:
+ return dates.Skip(offset.Value - 1).Take(1);
}
-
- return dates.Skip(offset.Value - 1).Take(1);
}
///
diff --git a/Ical.Net/Evaluation/RecurrenceUtil.cs b/Ical.Net/Evaluation/RecurrenceUtil.cs
index e7e9150b..1e1068f9 100644
--- a/Ical.Net/Evaluation/RecurrenceUtil.cs
+++ b/Ical.Net/Evaluation/RecurrenceUtil.cs
@@ -42,6 +42,9 @@ from p in periods
public static bool?[] GetExpandBehaviorList(RecurrencePattern p)
{
// See the table in RFC 5545 Section 3.3.10 (Page 43).
+ // Index mapping (must match RecurrencePatternEvaluator.GetCandidates order!):
+ // 0 = BYMONTH, 1 = BYWEEKNO, 2 = BYYEARDAY, 3 = BYMONTHDAY, 4 = BYDAY,
+ // 5 = BYHOUR, 6 = BYMINUTE, 7 = BYSECOND, 8 = BYSETPOS (sentinel)
switch (p.Frequency)
{
case FrequencyType.Minutely:
@@ -53,11 +56,12 @@ from p in periods
case FrequencyType.Weekly:
return [false, null, null, null, true, true, true, true, false];
case FrequencyType.Monthly:
- {
- bool?[] row = [false, null, null, true, true, true, true, true, false];
+ {
+ bool?[] row = [false, null, null, true, true, true, true, true, false];
- // Limit if BYMONTHDAY is present; otherwise, special expand for MONTHLY.
- if (p.ByMonthDay.Count > 0)
+ // RFC 5545 Notes 1 & 2:
+ // BYDAY should act as a limiter when BYMONTHDAY or BYYEARDAY are present.
+ if (p.ByMonthDay.Count > 0 || p.ByYearDay.Count > 0)
{
row[4] = false;
}
@@ -68,10 +72,8 @@ from p in periods
{
bool?[] row = [true, true, true, true, true, true, true, true, false];
- // Limit if BYYEARDAY or BYMONTHDAY is present; otherwise,
- // special expand for WEEKLY if BYWEEKNO present; otherwise,
- // special expand for MONTHLY if BYMONTH present; otherwise,
- // special expand for YEARLY.
+ // RFC 5545 Notes 1 & 2:
+ // BYDAY should act as a limiter when BYMONTHDAY or BYYEARDAY are present.
if (p.ByYearDay.Count > 0 || p.ByMonthDay.Count > 0)
{
row[4] = false;