///|
/// A reusable set of recurrence rules and explicit dates.
///
/// Inclusion rules and dates are merged in UTC instant order. Duplicate
/// instants are emitted once, and exclusion rules or dates remove an instant
/// from every inclusion source. Adding a source reuses the existing `RRule`
/// query implementation; the set does not duplicate recurrence generation.
pub struct RRuleSet {
priv rules : Array[RRule]
priv dates : Array[DateTime]
priv exrules : Array[RRule]
priv exdates : Array[DateTime]
priv mut cache : RecurrenceCache?
}
///|
/// Construct an empty recurrence set. `cache=true` shares a lazy occurrence
/// prefix across readers and queries. Adding any source invalidates that prefix.
///
/// ```mbt check
/// test {
/// let values = @date.rruleset().between(
/// @date.datetime(2024, 1, 1),
/// @date.datetime(2024, 1, 2),
/// inc=true,
/// )
/// assert_eq(values.length(), 0)
/// }
/// ```
pub fn rruleset(cache~ : Bool = false) -> RRuleSet {
{
rules: [],
dates: [],
exrules: [],
exdates: [],
cache: recurrence_cache(cache),
}
}
///|
/// Add an inclusive recurrence rule to the set.
pub fn RRuleSet::rrule(self : RRuleSet, rule : RRule) -> Unit {
self.rules.push(rule)
self.invalidate_cache()
}
///|
/// Add an inclusive explicit date to the set.
pub fn RRuleSet::rdate(self : RRuleSet, date : DateTime) -> Unit {
self.dates.push(date)
self.invalidate_cache()
}
///|
/// Add a recurrence rule whose occurrences are excluded from the set.
pub fn RRuleSet::exrule(self : RRuleSet, rule : RRule) -> Unit {
self.exrules.push(rule)
self.invalidate_cache()
}
///|
/// Add an explicit date whose instant is excluded from the set.
pub fn RRuleSet::exdate(self : RRuleSet, date : DateTime) -> Unit {
self.exdates.push(date)
self.invalidate_cache()
}
///|
priv struct RRuleSetCandidate {
instant : Int64
value : DateTime
}
///|
// Convert a DateTime to an exact UTC microsecond key. Using the integer key
// avoids treating two fixed-offset representations of one instant as distinct
// because of floating-point ordinal rounding.
fn rruleset_instant(value : DateTime) -> Int64 {
let days = days_from_civil(value.year, value.month, value.day)
Int64::from_int(days) * 86_400_000_000L +
Int64::from_int(value.hour * 3600 + value.minute * 60 + value.second) *
1_000_000L +
Int64::from_int(value.microsecond) -
Int64::from_int(value.offset_seconds) * 1_000_000L
}
///|
fn rruleset_unique_sorted(
values : Array[RRuleSetCandidate],
) -> Array[RRuleSetCandidate] {
values.sort_by_key(candidate => candidate.instant)
let result : Array[RRuleSetCandidate] = []
for candidate in values {
match result.last() {
Some(previous) if previous.instant == candidate.instant => ()
_ => result.push(candidate)
}
}
result
}
///|
/// Return occurrences between two date-times.
///
/// The bounds are exclusive by default, matching `RRule::between`; set `inc`
/// to include occurrences exactly equal to either bound. Inclusion sources
/// are merged and deduplicated before exclusion sources are applied.
// Retain the checked-query API while the validated merge itself is non-throwing.
#warnings("-unused_error_type")
pub fn RRuleSet::between(
self : RRuleSet,
after : DateTime,
before : DateTime,
inc~ : Bool = false,
) -> Array[DateTime] raise DateError {
let upper = rruleset_instant(before)
if rruleset_instant(after) > upper {
return []
}
self
.iter_after(after, inc~)
.take_while(value => {
if inc {
rruleset_instant(value) <= upper
} else {
rruleset_instant(value) < upper
}
})
.take(100000)
.to_array()
}
///|
fn RRuleSet::is_excluded(
self : RRuleSet,
value : DateTime,
) -> Bool raise DateError {
let instant = rruleset_instant(value)
for date in self.exdates {
if rruleset_instant(date) == instant {
return true
}
}
for rule in self.exrules {
if !rule.between(value, value, inc=true).is_empty() {
return true
}
}
false
}
///|
/// Return the first occurrence after `value`, or the equal occurrence when
/// `inc` is true. Excluded candidates are skipped one instant at a time.
// Retain the checked-query API while the validated merge itself is non-throwing.
#warnings("-unused_error_type")
pub fn RRuleSet::after(
self : RRuleSet,
value : DateTime,
inc~ : Bool = false,
) -> DateTime? raise DateError {
self.iter_after(value, inc~).next()
}
///|
fn RRuleSet::previous_candidate(
self : RRuleSet,
value : DateTime,
inc : Bool,
) -> DateTime? raise DateError {
let mut result : DateTime? = None
let mut result_instant : Int64? = None
for rule in self.rules {
match rule.before(value, inc~) {
Some(candidate) => {
let instant = rruleset_instant(candidate)
match result_instant {
Some(current) if instant <= current => ()
_ => {
result = Some(candidate)
result_instant = Some(instant)
}
}
}
None => ()
}
}
let target = rruleset_instant(value)
for candidate in self.dates {
let instant = rruleset_instant(candidate)
if (inc && instant <= target) || (!inc && instant < target) {
match result_instant {
Some(current) if instant <= current => ()
_ => {
result = Some(candidate)
result_instant = Some(instant)
}
}
}
}
result
}
///|
/// Return the last occurrence before `value`, or the equal occurrence when
/// `inc` is true. Excluded candidates are skipped one instant at a time.
pub fn RRuleSet::before(
self : RRuleSet,
value : DateTime,
inc~ : Bool = false,
) -> DateTime? raise DateError {
if self.cache is Some(_) {
let target = rruleset_instant(value)
let mut result = None
for candidate in self.iter() {
let instant = rruleset_instant(candidate)
if instant > target || (!inc && instant == target) {
break
}
result = Some(candidate)
}
return result
}
let mut cursor = value
let mut inclusive = inc
for _ in 0..<100000 {
match self.previous_candidate(cursor, inclusive) {
Some(candidate) =>
if self.is_excluded(candidate) {
cursor = candidate
inclusive = false
continue
} else {
return Some(candidate)
}
None => return None
}
}
None
}
///|
/// Return successive occurrences after `value`, up to `count` results.
///
/// With no count, the result is capped at 100000 entries. The sequence keeps
/// the same inclusion and exclusion semantics as `RRuleSet::after`.
// Retain the checked-query API while the validated merge itself is non-throwing.
#warnings("-unused_error_type")
pub fn RRuleSet::xafter(
self : RRuleSet,
value : DateTime,
count? : Int32,
inc~ : Bool = false,
) -> Array[DateTime] raise DateError {
let limit = Int32::min(100000, count.unwrap_or(100000))
if limit <= 0 {
return []
}
self.iter_after(value, count=limit, inc~).to_array()
}
///|
/// Return all occurrences through year 9999, capped at 100000 results.
pub fn RRuleSet::all(self : RRuleSet) -> Array[DateTime] raise DateError {
// Checked eager entry points retain DateError for unsupported UTC starts.
for rule in self.rules {
validate_timezone_ordinal(rule.dtstart().to_ordinal())
}
self.iter().take(100000).to_array()
}