justinwongcn/moon-ical/ical/rrule does not have a README file

    ExpandError

    pub(all) suberror ExpandError {
    Unsupported(feature~ : String)
    Invalid(message~ : String)
    } derive(Eq,
    Debug
    )

    Why an expansion can fail even though the rule parsed.
    impl Show for ExpandError

    ExpandError::equal

    fn ExpandError::equal(ExpandError, ExpandError) -> Bool

    ExpandError::not_equal

    fn ExpandError::not_equal(x : ExpandError, y : ExpandError) -> Bool

    ExpandError::output

    fn ExpandError::output(self : ExpandError, logger : &Logger) -> Unit

    ExpandError::to_string

    fn ExpandError::to_string(self : ExpandError) -> String

    Byday

    pub struct Byday {
    ordinal : Int
    weekday :
    Weekday

    } derive(Eq,
    Debug
    )

    One BYDAY entry: a weekday, optionally qualified by an ordinal.

    TU is any Tuesday (ordinal = 0), 1FR the first Friday of the interval, -2MO the second-to-last Monday. RFC 5545 bounds the ordinal to ±53; expansion of qualified weekdays arrives with S8.

    Byday::equal

    fn Byday::equal(Byday, Byday) -> Bool

    Byday::not_equal

    fn Byday::not_equal(x : Byday, y : Byday) -> Bool

    Byday::to_repr

    Freq

    pub(all) enum Freq {
    Yearly
    Monthly
    Weekly
    Daily
    } derive(Eq,
    Debug
    )

    How often a recurrence repeats: the FREQ clause of RFC 5545 §3.3.10.

    Only the four calendar grades are representable, on purpose. The finer HOURLY, MINUTELY, and SECONDLY grades fall outside this project's boundary (docs/development.html §08), so [@rrule.parse_rule] rejects them with an explicit error instead of silently narrowing or dropping them.

    Freq::equal

    fn Freq::equal(Freq, Freq) -> Bool

    Freq::not_equal

    fn Freq::not_equal(x : Freq, y : Freq) -> Bool

    Freq::to_repr

    Occurrence

    One effective occurrence after RRULE expansion, EXDATE removal, and RECURRENCE-ID override merging.

    Occurrence::equal

    fn Occurrence::equal(Occurrence, Occurrence) -> Bool

    Occurrence::not_equal

    fn Occurrence::not_equal(x : Occurrence, y : Occurrence) -> Bool

    Rule

    pub struct Rule {
    freq : Freq
    interval : Int
    count : Int?
    until :
    IcalDateTime
    ?
    byday : Array[Byday]
    bymonthday : Array[Int]
    bymonth : Array[Int]
    bysetpos : Array[Int]
    wkst :
    Weekday

    } derive(Eq,
    Debug
    )

    A parsed RRULE value (RFC 5545 §3.3.10): the syntax of a recurrence rule, not its expansion.

    Every clause within the project's support boundary is kept in a typed field, whichever milestone will expand it — the first-tier clauses (INTERVAL / COUNT / UNTIL / plain BYDAY / positive BYMONTHDAY / BYMONTH) with S3, the second-tier ones (ordinal BYDAY, negative BYMONTHDAY, BYSETPOS, WKST) with S8. Clauses outside the boundary never reach a Rule: [@rrule.parse_rule] rejects them loudly.

    Defaults follow the RFC: interval = 1 and wkst = Monday when their clauses are absent, and every BY* list is empty when absent.

    Rule::equal

    fn Rule::equal(Rule, Rule) -> Bool

    Rule::not_equal

    fn Rule::not_equal(x : Rule, y : Rule) -> Bool

    Rule::to_repr

    expand

    Expand a parsed [Rule] against its DTSTART into the occurrence date-times of the series, in chronological order (RFC 5545 §3.3.10).

    Supports the calendar-grade RFC 5545 rules in this project: DAILY / WEEKLY / MONTHLY / YEARLY, ordinal BYDAY, positive and negative BYMONTHDAY, BYSETPOS, BYMONTH, and WKST. The semantic checks that need the DTSTART (COUNT + UNTIL exclusivity, UNTIL value-type agreement) happen here.

    Occurrences are wall-clock arithmetic: every occurrence carries the DTSTART's clock time, UTC offset, zone spelling, and all-day flag unchanged (see [@model.IcalDateTime::on_date]), so a series crossing a DST change keeps the DTSTART offset — the documented ZoneTable boundary, not a silent guess. A DTSTART that does not match the rule is not forced into the series: the first occurrence is the first matching date, exactly as the reference corpus behaves.

    COUNT counts occurrences from the first match (RFC 5545 counts the DTSTART only when it matches); UNTIL bounds the series inclusively, compared by instant. limit caps how many occurrences are returned — the safety valve for unbounded rules — and never truncates a COUNT/UNTIL rule that ends within it.

    Example

    fn test_example() raise {
    let rule = @rrule.parse_rule("FREQ=DAILY;COUNT=3")
    let dtstart = @model.parse_single_date_time(
    "20260901T090000Z",
    @model.ZoneTable::empty(),
    )
    let occurrences = @rrule.expand(rule, dtstart)
    assert_eq(occurrences.length(), 3)
    assert_eq(occurrences[2].to_string(), "2026-09-03T09:00:00Z")
    }

    expand_series

    fn expand_series(events : Array[
    Event
    ], limit? : Int) -> Array[Occurrence] raise

    Expand and merge one UID's VEVENT series. A cancelled override removes its instance; a moved override replaces that instance; RANGE=THISANDFUTURE shifts the selected instance and every later generated instance by the same wall-clock delta.

    parse_rule

    fn parse_rule(rule : String) -> Rule raise

    Parse an RRULE property value (RFC 5545 §3.3.10) into a [Rule].

    This is the syntax layer only. Every clause inside the project's support boundary (docs/development.html §08) is parsed into a typed field; the clauses outside it — the HOURLY / MINUTELY / SECONDLY frequencies and the BYWEEKNO / BYYEARDAY / BYHOUR / BYMINUTE / BYSECOND clauses — are rejected with an explicit [@text.ParseError::BadRule] rather than silently dropped.

    The RRULE: property-name prefix may be present or absent, so both a bare property value and a whole content line parse. Clause names and values match case-insensitively (real feeds mix cases). Each clause may appear at most once, and FREQ must appear exactly once.

    Two deliberate deviations from the RFC grammar, in the direction of tolerating real feeds: FREQ is not required to be the first clause, and no DTSTART-dependent semantic checks (COUNT + UNTIL exclusivity, UNTIL value-type agreement) happen here — those belong to expansion (S3), which is the only place that knows the DTSTART.

    Example

    fn test_example() raise {
    let rule = @rrule.parse_rule("FREQ=WEEKLY;BYDAY=TU,TH;COUNT=10")
    assert_true(rule.freq is @rrule.Freq::Weekly)
    assert_eq(rule.count, Some(10))
    assert_eq(rule.byday.length(), 2)
    }