///|
/// Calendar frequencies used by `rrule_locator` (from years to seconds).
pub(all) enum RRuleFrequency {
Yearly
Monthly
Weekly
Daily
Hourly
Minutely
Secondly
}
///|
// Normalized selector arrays are private and never exposed to callers.
priv struct DateRule {
frequency : RRuleFrequency
interval : Int32
wkst : Int32
dtstart : DateTime?
count : Int32?
until : DateTime?
bymonth : Array[Int32]?
bymonthday : Array[Int32]?
byyearday : Array[Int32]?
byweekno : Array[Int32]?
byeaster : Array[Int32]?
byweekday : Array[Int32]?
bynweekday : Array[RelativeWeekday]?
byhour : Array[Int32]?
byminute : Array[Int32]?
bysecond : Array[Int32]?
bysetpos : Array[Int32]?
timezone : DateTimeZone?
}
///|
/// A reusable recurrence rule with a concrete start date.
///
/// `RRule` mirrors the query-oriented part of `dateutil.rrule`: construct a
/// rule once, then ask for occurrences in a bounded interval. The locator
/// adapter remains available when the rule is attached to an axis.
pub struct RRule {
priv rule : DateRule
priv cache : RecurrenceCache?
}
///|
/// Return a copy whose recurrence fields are interpreted in a transition-aware
/// local timezone. Calendar candidates are still generated in local time;
/// emitted ticks remain UTC Matplotlib ordinals. The cache setting is preserved
/// with a new empty prefix.
///
/// ```mbt check
/// test {
/// let zone = try! @date.timezone(-300, transitions=[
/// try! @date.timezone_transition(@date.datetime(2024, 3, 10, hour=7), -240),
/// ])
/// let rule = try! @date.rrule(
/// @date.RRuleFrequency::Daily,
/// @date.datetime(2024, 3, 9, hour=2, offset_minutes=-300),
/// byhour=[2],
/// ).with_timezone(zone)
/// assert_eq(rule.locator().offset_minutes(), -300)
/// }
/// ```
pub fn RRule::with_timezone(self : RRule, timezone : DateTimeZone) -> RRule {
{
rule: self.rule.with_timezone(timezone),
cache: recurrence_cache(self.cache_enabled()),
}
}
///|
/// Construct a reusable recurrence rule with an explicit `dtstart`.
///
/// Keeping the start date required makes object queries deterministic. The
/// locator adapter remains available through `RRule::locator` when a rule is
/// used to generate axis ticks. Selector arrays are copied, sorted, and
/// deduplicated during construction, so callers can safely reuse or mutate
/// their input arrays afterwards. `cache=true` shares an incrementally generated
/// occurrence prefix between queries and readers; the default is uncached.
pub fn rrule(
frequency : RRuleFrequency,
dtstart : DateTime,
interval~ : Int32 = 1,
wkst~ : Int32 = 0,
bymonth? : Array[Int32],
bymonthday? : Array[Int32],
byyearday? : Array[Int32],
byweekno? : Array[Int32],
byeaster? : Array[Int32],
byweekday? : Array[Int32],
byhour? : Array[Int32],
byminute? : Array[Int32],
bysecond? : Array[Int32],
bysetpos? : Array[Int32],
count? : Int32,
until? : DateTime,
cache~ : Bool = false,
) -> RRule raise DateError {
{
rule: make_date_rule(
frequency,
interval,
wkst,
bymonth,
bymonthday,
byyearday,
byweekno,
byeaster,
byweekday,
byhour,
byminute,
bysecond,
bysetpos,
Some(dtstart),
count,
until,
),
cache: recurrence_cache(cache),
}
}
///|
/// Return the axis locator represented by this rule.
pub fn RRule::locator(self : RRule) -> DateLocator {
date_locator_from_rule(self.rule)
}
///|
/// Return the recurrence frequency.
pub fn RRule::frequency(self : RRule) -> RRuleFrequency {
self.rule.frequency
}
///|
/// Return the positive calendar or clock interval.
pub fn RRule::interval(self : RRule) -> Int32 {
self.rule.interval
}
///|
/// Return the week-start selector (Monday=0 through Sunday=6).
pub fn RRule::wkst(self : RRule) -> Int32 {
self.rule.wkst
}
///|
/// Return the recurrence start date.
pub fn RRule::dtstart(self : RRule) -> DateTime {
self.rule.dtstart.unwrap()
}
///|
/// Return the optional occurrence limit.
pub fn RRule::count(self : RRule) -> Int32? {
self.rule.count
}
///|
/// Return the inclusive upper date-time bound, when configured.
pub fn RRule::until(self : RRule) -> DateTime? {
self.rule.until
}
///|
/// Return a copy whose recurrence dates use a fixed UTC offset.
/// The cache setting is preserved, with independent empty cache state.
pub fn RRule::with_offset(
self : RRule,
offset_minutes : Int32,
) -> RRule raise DateError {
{
rule: self.rule.with_offset(offset_minutes),
cache: recurrence_cache(self.cache_enabled()),
}
}
///|
/// Return a rule with selected fields replaced.
///
/// Omitted fields keep their current values. Selector arrays are copied and
/// validated again, so the returned rule is independent of both the original
/// rule and the caller's arrays. Pass selector names through `clear` to remove
/// an existing selector before applying replacements. This typed form covers
/// the common immutable `dateutil.rrule.replace` workflow while keeping the
/// clear operation explicit in MoonBit's optional-argument type system.
/// The cache setting is retained unless replaced; a fresh cache is always used.
pub fn RRule::replace(
self : RRule,
frequency? : RRuleFrequency,
dtstart? : DateTime,
interval? : Int32,
wkst? : Int32,
bymonth? : Array[Int32],
bymonthday? : Array[Int32],
byyearday? : Array[Int32],
byweekno? : Array[Int32],
byeaster? : Array[Int32],
byweekday? : Array[Int32],
byhour? : Array[Int32],
byminute? : Array[Int32],
bysecond? : Array[Int32],
bysetpos? : Array[Int32],
count? : Int32,
until? : DateTime,
clear~ : Array[String] = [],
cache? : Bool,
) -> RRule raise DateError {
for selector in clear {
if selector != "bymonth" &&
selector != "bymonthday" &&
selector != "byyearday" &&
selector != "byweekno" &&
selector != "byeaster" &&
selector != "byweekday" &&
selector != "byhour" &&
selector != "byminute" &&
selector != "bysecond" &&
selector != "bysetpos" {
raise DateError::InvalidDate(
message="unknown rrule selector to clear: {selector}",
)
}
}
let clear_bymonth = clear.contains("bymonth")
let clear_bymonthday = clear.contains("bymonthday")
let clear_byyearday = clear.contains("byyearday")
let clear_byweekno = clear.contains("byweekno")
let clear_byeaster = clear.contains("byeaster")
let clear_byweekday = clear.contains("byweekday")
let clear_byhour = clear.contains("byhour")
let clear_byminute = clear.contains("byminute")
let clear_bysecond = clear.contains("bysecond")
let clear_bysetpos = clear.contains("bysetpos")
let next_frequency = match frequency {
Some(value) => value
None => self.rule.frequency
}
let next_dtstart = match dtstart {
Some(value) => value
None => self.dtstart()
}
let next_interval = match interval {
Some(value) => value
None => self.rule.interval
}
let next_wkst = match wkst {
Some(value) => value
None => self.rule.wkst
}
let next_bymonth = if clear_bymonth {
match bymonth {
Some(value) => Some(value)
None => None
}
} else {
match bymonth {
Some(value) => Some(value)
None => self.rule.bymonth
}
}
let next_bymonthday = if clear_bymonthday {
match bymonthday {
Some(value) => Some(value)
None => None
}
} else {
match bymonthday {
Some(value) => Some(value)
None => self.rule.bymonthday
}
}
let next_byyearday = if clear_byyearday {
match byyearday {
Some(value) => Some(value)
None => None
}
} else {
match byyearday {
Some(value) => Some(value)
None => self.rule.byyearday
}
}
let next_byweekno = if clear_byweekno {
match byweekno {
Some(value) => Some(value)
None => None
}
} else {
match byweekno {
Some(value) => Some(value)
None => self.rule.byweekno
}
}
let next_byeaster = if clear_byeaster {
match byeaster {
Some(value) => Some(value)
None => None
}
} else {
match byeaster {
Some(value) => Some(value)
None => self.rule.byeaster
}
}
let next_byweekday = if clear_byweekday {
match byweekday {
Some(value) => Some(value)
None => None
}
} else {
match byweekday {
Some(value) => Some(value)
None => self.rule.byweekday
}
}
let next_byhour = if clear_byhour {
match byhour {
Some(value) => Some(value)
None => None
}
} else {
match byhour {
Some(value) => Some(value)
None => self.rule.byhour
}
}
let next_byminute = if clear_byminute {
match byminute {
Some(value) => Some(value)
None => None
}
} else {
match byminute {
Some(value) => Some(value)
None => self.rule.byminute
}
}
let next_bysecond = if clear_bysecond {
match bysecond {
Some(value) => Some(value)
None => None
}
} else {
match bysecond {
Some(value) => Some(value)
None => self.rule.bysecond
}
}
let next_bysetpos = if clear_bysetpos {
match bysetpos {
Some(value) => Some(value)
None => None
}
} else {
match bysetpos {
Some(value) => Some(value)
None => self.rule.bysetpos
}
}
let next_count = match count {
Some(value) => Some(value)
None => self.rule.count
}
let next_until = match until {
Some(value) => Some(value)
None => self.rule.until
}
let validated_count = if count is None &&
next_count is Some(value) &&
value <= 0 {
None
} else {
next_count
}
let next_rule = make_date_rule(
next_frequency,
next_interval,
next_wkst,
next_bymonth,
next_bymonthday,
next_byyearday,
next_byweekno,
next_byeaster,
next_byweekday,
next_byhour,
next_byminute,
next_bysecond,
next_bysetpos,
Some(next_dtstart),
validated_count,
next_until,
)
let retained_ordinal = if clear_byweekday || byweekday is Some(_) {
None
} else {
self.rule.bynweekday
}
let mut next_rule = {
..next_rule,
count: next_count,
bynweekday: retained_ordinal,
}
if !(next_frequency is Yearly || next_frequency is Monthly) &&
retained_ordinal is Some(values) {
let plain = next_rule.byweekday.unwrap_or([]).copy()
for value in values {
if !plain.contains(value.weekday) {
plain.push(value.weekday)
}
}
plain.sort()
next_rule = { ..next_rule, byweekday: Some(plain), bynweekday: None, }
}
let next_rule = match self.rule.timezone {
Some(timezone) => next_rule.with_timezone(timezone)
None => next_rule
}
{
rule: next_rule,
cache: recurrence_cache(cache.unwrap_or(self.cache_enabled())),
}
}
///|
fn RRule::query(
self : RRule,
first : DateTime,
last : DateTime,
) -> Array[Double] {
if self.cache is Some(_) {
let first_ordinal = first.to_ordinal()
let last_ordinal = last.to_ordinal()
if first_ordinal > last_ordinal {
return []
}
let result : Array[Double] = []
for value in self.iter() {
let ordinal = value.to_ordinal()
if ordinal > last_ordinal {
break
}
if ordinal >= first_ordinal {
result.push(ordinal)
if result.length() >= 100000 {
break
}
}
}
return result
}
let locator = date_locator_from_rule(self.rule)
// DateTime already represents an instant. A transition-aware timezone is
// used only to choose the local calendar fields for generation; converting
// the query value back through `timezone.to_ordinal` would reinterpret its
// fields as local time and shift the query window.
let first_ordinal = first.to_ordinal()
let last_ordinal = last.to_ordinal()
if first_ordinal > last_ordinal {
return []
}
let first_date = self.rule.local_date(first)
let last_date = self.rule.local_date(last)
recurrence_ticks(locator, first_ordinal, last_ordinal, first_date, last_date)
}
///|
fn RRule::value_to_datetime(
self : RRule,
value : Double,
) -> DateTime raise DateError {
match self.rule.timezone {
Some(timezone) => timezone.generated_datetime(value)
None => decoded_ordinal(value, self.dtstart().offset_seconds(), true)
}
}
///|
// A DateTime query already carries an absolute instant. Keep it exact while
// choosing generation fields instead of round-tripping through public ordinals.
fn DateRule::local_date(self : DateRule, value : DateTime) -> DateTime {
match self.timezone {
Some(timezone) => value.with_timezone(timezone) catch { _ => value }
None =>
value.with_offset_seconds(self.dtstart.unwrap_or(value).offset_seconds) catch {
_ => value
}
}
}
///|
// Keep reverse queries below the recurrence safety cap for dense clock rules,
// then widen the window when selectors make the rule sparse.
fn RRule::before_probe_days(self : RRule) -> Int32 {
match self.rule.frequency {
Secondly => 1
Minutely => 7
Hourly => 31
Daily => 366
Weekly => 366 * 4
Monthly => 366 * 8
Yearly => 366 * 64
}
}
///|
fn RRule::find_before(
self : RRule,
value : DateTime,
inc : Bool,
) -> DateTime? raise DateError {
let start = self.dtstart()
let target = value.to_ordinal()
if target < start.to_ordinal() {
return None
}
let mut span = self.before_probe_days().to_double()
let mut attempts = 0
while attempts < 32 {
let lower_ordinal = Double::max(start.to_ordinal(), target - span)
let lower = decoded_ordinal(lower_ordinal, start.offset_seconds(), true)
let values = self.query(lower, value)
let mut index = values.length() - 1
while index >= 0 {
let occurrence = values[index]
if (inc && occurrence <= target) || (!inc && occurrence < target) {
return Some(self.value_to_datetime(occurrence))
}
index -= 1
}
if lower_ordinal <= start.to_ordinal() {
return None
}
span = span * 2.0
attempts += 1
}
None
}
///|
/// Return occurrences between two date-times.
///
/// The bounds are exclusive by default, matching `dateutil.rrule`. Set
/// `inc` to include occurrences exactly equal to either bound. An inverted
/// interval returns an empty array.
pub fn RRule::between(
self : RRule,
after : DateTime,
before : DateTime,
inc~ : Bool = false,
) -> Array[DateTime] raise DateError {
let lower = after.to_ordinal()
let upper = before.to_ordinal()
if lower > upper {
return []
}
let values = self.query(after, before)
let result : Array[DateTime] = []
for value in values {
if (inc || value > lower) && (inc || value < upper) {
result.push(self.value_to_datetime(value))
}
}
result
}
///|
/// Return the first occurrence after `value`, or the equal occurrence when
/// `inc` is true. The search is bounded by the supported Gregorian range.
// Preserve the existing checked-query signature while using a validated lazy source.
#warnings("-unused_error_type")
pub fn RRule::after(
self : RRule,
value : DateTime,
inc~ : Bool = false,
) -> DateTime? raise DateError {
self.iter_after(value, inc~).next()
}
///|
/// Return successive occurrences after `value`.
///
/// This eager array form covers the useful query semantics of
/// `dateutil.rrule.xafter`; an omitted `count` is capped at 100000 results.
// Preserve the existing checked-query signature while using a validated lazy source.
#warnings("-unused_error_type")
pub fn RRule::xafter(
self : RRule,
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 the last occurrence before `value`, or the equal occurrence when
/// `inc` is true. The search is bounded by the supported Gregorian range.
pub fn RRule::before(
self : RRule,
value : DateTime,
inc~ : Bool = false,
) -> DateTime? raise DateError {
if value.to_ordinal() < self.dtstart().to_ordinal() {
return None
}
if self.rule.count is None && self.cache is None {
return self.find_before(value, inc)
}
let target = value.to_ordinal()
let mut result = None
for candidate in self.iter() {
let ordinal = candidate.to_ordinal()
if ordinal > target || (!inc && ordinal == target) {
break
}
result = Some(candidate)
}
result
}
///|
/// Return all occurrences from `dtstart` through year 9999.
///
/// Results are capped at 100000 occurrences, the same safety limit used by
/// `DateLocator::tick_values`. Rules without `count` or `until` can therefore
/// represent an unbounded recurrence while keeping this eager query finite.
pub fn RRule::all(self : RRule) -> Array[DateTime] raise DateError {
validate_timezone_ordinal(self.dtstart().to_ordinal())
self.iter().take(100000).to_array()
}
///|
fn make_date_rule(
frequency : RRuleFrequency,
interval : Int32,
wkst : Int32,
bymonth : Array[Int32]?,
bymonthday : Array[Int32]?,
byyearday : Array[Int32]?,
byweekno : Array[Int32]?,
byeaster : Array[Int32]?,
byweekday : Array[Int32]?,
byhour : Array[Int32]?,
byminute : Array[Int32]?,
bysecond : Array[Int32]?,
bysetpos : Array[Int32]?,
dtstart : DateTime?,
count : Int32?,
until : DateTime?,
) -> DateRule raise DateError {
if count is Some(value) && value <= 0 {
raise DateError::InvalidDate(message="rrule count must be positive")
}
{
frequency,
interval: positive_interval(interval),
wkst: weekday_selector(wkst),
dtstart,
count,
until,
bymonth: locator_selection(None, bymonth, None, None, month_selector),
bymonthday: locator_selection(
None,
bymonthday,
None,
None,
monthday_selector,
),
byyearday: locator_selection(None, byyearday, None, None, yearday_selector),
byweekno: locator_selection(None, byweekno, None, None, weekno_selector),
byeaster: locator_selection(
None,
byeaster,
None,
None,
easter_offset_selector,
),
byweekday: locator_selection(None, byweekday, None, None, weekday_selector),
bynweekday: None,
byhour: locator_selection(None, byhour, None, None, hour_selector),
byminute: locator_selection(
None,
byminute,
None,
None,
minute_or_second_selector,
),
bysecond: locator_selection(
None,
bysecond,
None,
None,
minute_or_second_selector,
),
bysetpos: locator_selection(None, bysetpos, None, None, recurrence_position),
timezone: None,
}
}
///|
fn date_locator_from_rule(rule : DateRule) -> DateLocator {
{
kind: Recurrence,
interval: rule.interval,
selector: 0,
secondary: 0,
selector_enabled: false,
offset_minutes: match rule.dtstart {
Some(value) => value.offset_minutes()
None => 0
},
timezone: match rule.timezone {
Some(zone) => Some(zone)
None =>
match rule.dtstart {
Some(start) if start.offset_seconds % 60 != 0 =>
Some(try! fixed_timezone_seconds(start.offset_seconds))
_ => None
}
},
selection: None,
secondary_selection: None,
holidays: [],
rule: Some(rule),
automatic: None,
wrapper_locator: None,
microsecond_object: None,
}
}
///|
/// Locate dates with a calendar recurrence rule.
///
/// Arrays are copied, sorted and deduplicated; empty or invalid selectors raise
/// `DateError`. `bymonthday` counts from either end of the month (-1 is the last
/// day). `byweekday` and the week start `wkst` use Monday=0 through Sunday=6.
/// `byyearday` counts from January 1 (`-1` is December 31), `byweekno`
/// follows the configurable week start, and `byeaster` selects days relative
/// to Western Easter (zero is Easter Sunday).
/// `bysetpos` selects positions within each recurrence period after all other
/// filters and expansions, before clipping to the visible range.
/// `dtstart` supplies the recurrence start used for filtering and counting;
/// when omitted, the locator keeps Matplotlib's visible-window anchor. `count`
/// limits the number of occurrences from that start, and `until` is an
/// inclusive date-time bound. Supplying either bound with no explicit
/// `dtstart` uses the Matplotlib anchor as the start.
///
/// Like Matplotlib's `RRuleLocator`, the anchor is the lower bound minus the
/// calendar difference between the bounds. Omitted clock fields below the
/// frequency inherit this anchor's fields; recurrence ticks have zero
/// microseconds. With no day selector, yearly/monthly rules inherit its day,
/// yearly rules also inherit its month if omitted, and weekly rules inherit
/// its weekday. Other omitted selectors allow all values.
///
/// The interval advances calendar units before filtering. Use `with_offset`
/// for a fixed UTC offset; emitted numeric ticks remain UTC day ordinals.
///
/// ```mbt check
/// test {
/// let locator = @date.rrule_locator(
/// @date.Monthly,
/// byweekday=[0, 1, 2, 3, 4],
/// bysetpos=[-1],
/// byhour=[0],
/// byminute=[0],
/// bysecond=[0],
/// )
/// let ticks = locator.tick_values(
/// @date.datetime(2024, 1, 1).to_ordinal(),
/// @date.datetime(2024, 3, 31).to_ordinal(),
/// )
/// assert_eq(@date.format_date_ticks(ticks, format="%Y-%m-%d"), [
/// "2024-01-31", "2024-02-29", "2024-03-29",
/// ])
/// }
/// ```
pub fn rrule_locator(
frequency : RRuleFrequency,
tz? : String,
timezone? : DateTimeZone,
interval~ : Int32 = 1,
wkst~ : Int32 = 0,
bymonth? : Array[Int32],
bymonthday? : Array[Int32],
byyearday? : Array[Int32],
byweekno? : Array[Int32],
byeaster? : Array[Int32],
byweekday? : Array[Int32],
byhour? : Array[Int32],
byminute? : Array[Int32],
bysecond? : Array[Int32],
bysetpos? : Array[Int32],
dtstart? : DateTime,
count? : Int32,
until? : DateTime,
) -> DateLocator raise DateError {
let locator = date_locator_from_rule(
make_date_rule(
frequency, interval, wkst, bymonth, bymonthday, byyearday, byweekno, byeaster,
byweekday, byhour, byminute, bysecond, bysetpos, dtstart, count, until,
),
)
if tz is Some(_) || timezone is Some(_) || dtstart is None {
locator.with_timezone(selected_timezone(tz, timezone))
} else {
locator
}
}
///|
fn recurrence_position(value : Int32) -> Int32 raise DateError {
if value == 0 || value < -366 || value > 366 {
raise DateError::InvalidDate(
message="recurrence position must be in -366..-1 or 1..366",
)
}
value
}
///|
// Keep explicit recurrence boundaries in the same local calendar as a copied
// locator. The numeric instants remain unchanged when the fixed offset moves.
fn DateRule::with_offset(
self : DateRule,
offset_minutes : Int32,
) -> DateRule raise DateError {
{
frequency: self.frequency,
interval: self.interval,
wkst: self.wkst,
dtstart: match self.dtstart {
Some(value) => Some(value.with_offset(offset_minutes))
None => None
},
count: self.count,
until: match self.until {
Some(value) => Some(value.with_offset(offset_minutes))
None => None
},
bymonth: self.bymonth,
bymonthday: self.bymonthday,
byyearday: self.byyearday,
byweekno: self.byweekno,
byeaster: self.byeaster,
byweekday: self.byweekday,
bynweekday: self.bynweekday,
byhour: self.byhour,
byminute: self.byminute,
bysecond: self.bysecond,
bysetpos: self.bysetpos,
timezone: None,
}
}
///|
fn DateRule::with_timezone(
self : DateRule,
timezone : DateTimeZone,
) -> DateRule {
{
frequency: self.frequency,
interval: self.interval,
wkst: self.wkst,
dtstart: self.dtstart,
count: self.count,
until: self.until,
bymonth: self.bymonth,
bymonthday: self.bymonthday,
byyearday: self.byyearday,
byweekno: self.byweekno,
byeaster: self.byeaster,
byweekday: self.byweekday,
bynweekday: self.bynweekday,
byhour: self.byhour,
byminute: self.byminute,
bysecond: self.bysecond,
bysetpos: self.bysetpos,
timezone: Some(timezone),
}
}