From 544bd3808159af600e46456fe158ec949f49ac2e Mon Sep 17 00:00:00 2001 From: Craig Date: Tue, 17 Feb 2026 21:13:44 -0500 Subject: [PATCH 1/3] Implement RFC 5545 compliance: case-insensitivity, any part order, folding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Parse rule part names and enum values case-insensitively (FREQ, BYDAY, etc.). - Accept rule parts in any order; require exactly one FREQ (exact key match). - Unfold content lines on parse (CRLF/LF + SPACE/HTAB removed). - Add optional foldLongLines and emitWKST to format style; fold at 75 octets when enabled. - Parse and accept WKST (not persisted); optional WKST=MO on format. - BYSECOND 0–60 (leap second), UNTIL hour 24 normalized to next day 00:00. - Replace force unwraps in buffer resize and date components with guards/nil-coalescing. --- .../RecurrenceRuleRFC5545FormatStyle.swift | 356 +++++++++++------- 1 file changed, 219 insertions(+), 137 deletions(-) diff --git a/Sources/RRuleKit/RecurrenceRuleRFC5545FormatStyle.swift b/Sources/RRuleKit/RecurrenceRuleRFC5545FormatStyle.swift index f6a460b..7f12c6f 100644 --- a/Sources/RRuleKit/RecurrenceRuleRFC5545FormatStyle.swift +++ b/Sources/RRuleKit/RecurrenceRuleRFC5545FormatStyle.swift @@ -15,11 +15,23 @@ public struct RecurrenceRuleRFC5545FormatStyle: Sendable { /// The calendar used for parsing and validating recurrence rules. public let calendar: Calendar - /// Initializes the format style with a specific calendar. + /// When true, formatted output longer than 75 octets is folded (CRLF + SPACE) per RFC 5545 Section 3.1. + /// Default is `false` for backward compatibility. + public let foldLongLines: Bool + + /// When true, format emits `WKST=MO` when appropriate (RFC 5545 default week start). Default is `false`. + public let emitWKST: Bool + + /// Initializes the format style with a specific calendar and optional RFC 5545 content-line options. /// - /// - Parameter calendar: The calendar to use for parsing. Defaults to `.current`. - public init(calendar: Calendar = .current) { + /// - Parameters: + /// - calendar: The calendar to use for parsing. Defaults to `.current`. + /// - foldLongLines: If true, format folds lines longer than 75 octets. Defaults to `false`. + /// - emitWKST: If true, format emits WKST=MO for RECUR compliance. Defaults to `false`. + public init(calendar: Calendar = .current, foldLongLines: Bool = false, emitWKST: Bool = false) { self.calendar = calendar + self.foldLongLines = foldLongLines + self.emitWKST = emitWKST } } @@ -36,8 +48,8 @@ extension RecurrenceRuleRFC5545FormatStyle: FormatStyle { var buffer = UnsafeMutableBufferPointer.allocate(capacity: estimatedCapacity) defer { buffer.deallocate() } // Ensure the buffer is deallocated after use - let lenght = format(value, into: &buffer) - return String(decoding: buffer[.. 0 initially. func ensureCapacity(_ required: Int) { guard required > buffer.count else { return } let newCapacity = max(required, buffer.count * 2) let newBuffer = UnsafeMutableBufferPointer.allocate(capacity: newCapacity) - newBuffer.baseAddress?.initialize(from: buffer.baseAddress!, count: index) + if index > 0, let source = buffer.baseAddress { + newBuffer.baseAddress?.initialize(from: source, count: index) + } buffer.deallocate() buffer = newBuffer } @@ -152,12 +170,12 @@ extension RecurrenceRuleRFC5545FormatStyle: FormatStyle { append(month, zeroPad: 2) append(day, zeroPad: 2) - // Append time components if this is a DATE-TIME + // Append time components if this is a DATE-TIME (default to midnight when omitted per RFC 5545) if isDateTime { append("T".utf8) - append(components.hour!, zeroPad: 2) - append(components.minute!, zeroPad: 2) - append(components.second!, zeroPad: 2) + append(components.hour ?? 0, zeroPad: 2) + append(components.minute ?? 0, zeroPad: 2) + append(components.second ?? 0, zeroPad: 2) // Add UTC suffix if the time is in UTC if isUTC { @@ -294,8 +312,94 @@ extension RecurrenceRuleRFC5545FormatStyle: FormatStyle { append(rrule.setPositions) } - // Return the total number of bytes written - return index + // Emit WKST=MO per RFC 5545 default when requested (significant for WEEKLY with interval>1 or YEARLY with BYWEEKNO). + if emitWKST { + append(";WKST=MO".utf8) + } + + let length = index + if foldLongLines { + return foldContentLine(buffer: &buffer, length: length) + } + return length + } +} + +// MARK: - Content-Line Folding (RFC 5545 Section 3.1) + +extension RecurrenceRuleRFC5545FormatStyle { + + /// Unfolds a content line: removes CRLF followed by a single linear-white-space (SPACE or HTAB). + /// - Parameter value: The possibly folded content line (e.g. RRULE value). + /// - Returns: The unfolded string. + package static func unfoldContentLine(_ value: String) -> String { + var result: [Character] = [] + var i = value.startIndex + while i < value.endIndex { + let c = value[i] + // Swift treats "\r\n" as a single Character (extended grapheme cluster); handle both single-char and \r then \n. + if c == "\r\n" { + let afterCRLF = value.index(after: i) + if afterCRLF < value.endIndex { + let next = value[afterCRLF] + if next == " " || next == "\t" { + i = value.index(after: afterCRLF) + continue + } + } + i = value.index(after: i) + continue + } + if c == "\r", value.index(i, offsetBy: 1, limitedBy: value.endIndex) ?? value.endIndex < value.endIndex { + let next = value.index(i, offsetBy: 1) + if next < value.endIndex, value[next] == "\n" { + let afterCRLF = value.index(next, offsetBy: 1) + if afterCRLF < value.endIndex { + let n = value[afterCRLF] + if n == " " || n == "\t" { + i = value.index(after: afterCRLF) + continue + } + } + } + } else if c == "\n" { + let afterLF = value.index(i, offsetBy: 1) + if afterLF < value.endIndex { + let n = value[afterLF] + if n == " " || n == "\t" { + i = value.index(after: afterLF) + continue + } + } + } + result.append(c) + i = value.index(after: i) + } + return String(result) + } + + /// Folds buffer content so no line exceeds 75 octets per RFC 5545; returns new length. + /// Inserts CRLF + SPACE at octet boundaries. Reallocates buffer and replaces with folded content. + private func foldContentLine(buffer: inout UnsafeMutableBufferPointer, length: Int) -> Int { + let maxOctets = 75 + let estimatedOut = length + (length / maxOctets + 1) * 3 + let newBuffer = UnsafeMutableBufferPointer.allocate(capacity: estimatedOut) + var writeIndex = 0 + var lineStart = 0 + for i in 0..= maxOctets, lineStart < i { + newBuffer[writeIndex] = 13 + newBuffer[writeIndex + 1] = 10 + newBuffer[writeIndex + 2] = 32 + writeIndex += 3 + lineStart = i + } + newBuffer[writeIndex] = buffer[i] + writeIndex += 1 + } + buffer.deallocate() + buffer = newBuffer + return writeIndex } } @@ -309,26 +413,16 @@ extension RecurrenceRuleRFC5545FormatStyle: ParseStrategy { /// - Returns: A `RecurrenceRule` if parsing is successful. /// /// ## RFC 5545 Parsing Requirements: - /// The `FREQ` key is **mandatory** and defines the recurrence frequency. - /// **`FREQ` must appear as the first key** in the string. - /// - /// Optional keys include: - /// - `COUNT`: Specifies the number of occurrences. - /// - `UNTIL`: Defines the end date of the recurrence in either UTC or local time. - /// - `INTERVAL`: Specifies the interval between occurrences (default is 1). - /// - `BY*` keys (e.g., `BYSECOND`, `BYDAY`): Define specific occurrences within the recurrence pattern. - /// - Keys must be separated by semicolons (`;`) and formatted as `KEY=VALUE`. - /// - Only one of `COUNT` or `UNTIL` can be present in a single rule. - /// - /// - Note: The input string must follow strict RFC 5545 formatting. - /// Invalid or unsupported keys will cause parsing to fail. + /// The `FREQ` key is **mandatory** and must appear exactly once; rule parts may be in **any order**. + /// Property names and enumerated values are **case-insensitive**. Input is unfolded (CRLF+space removed) before parsing. + /// Optional keys: `COUNT`, `UNTIL`, `INTERVAL`, `BY*`, `WKST`. Only one of `COUNT` or `UNTIL` is allowed. /// /// - Warning: **The `FREQ=SECONDLY` frequency is currently not supported.** - /// If the input string specifies `FREQ=SECONDLY`, the function throw error. /// /// See also: [RFC 5545](https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10) public func parse(_ rfcString: String) throws -> RecurrenceRule { - guard let (_, rrule) = parse(rfcString, in: rfcString.startIndex..) -> (String.Index, RecurrenceRule)? { var v = value[range] guard !v.isEmpty else { return nil } - // Parse the UTF-8 representation of the input string. + typealias Part = (key: Slice>, value: Slice>) + let result = v.withUTF8 { buffer -> (Int, RecurrenceRule)? in var index = buffer.startIndex + var parts: [Part] = [] + while index < buffer.endIndex { + guard let (keySlice, valueSlice) = extractRulePart(&index, from: buffer) else { return nil } + parts.append((keySlice, valueSlice)) + } + guard !parts.isEmpty else { return nil } - // Parse the mandatory "FREQ" key. - guard let (keySlice, valueSlice) = extractRulePart(&index, from: buffer), - keySlice.contains("FREQ".utf8), - let frequency = parseAsFrequency(valueSlice) else { + // Require exactly one FREQ (exact key match, case-insensitive). Reject e.g. FREQUENCY. + let freqParts = parts.filter { keySliceEquals($0.key, "FREQ") } + guard freqParts.count == 1, + let frequency = parseAsFrequency(freqParts[0].value) else { return nil } - // Initialize the recurrence rule with the parsed frequency. var rrule = RecurrenceRule(calendar: calendar, frequency: frequency) + var seenKeys: Set = [] - // Parse remaining key-value pairs. - while index < buffer.endIndex { - guard let (keySlice, valueSlice) = extractRulePart(&index, from: buffer) else { - return nil - } + for (keySlice, valueSlice) in parts { + guard let canonical = keyCanonical(keySlice) else { return nil } + if seenKeys.contains(canonical) { return nil } + seenKeys.insert(canonical) - switch keySlice.count { - case 5 where keySlice.elementsEqual("UNTIL".utf8): + switch canonical { + case "freq": + break + case "until": guard let until = parseAsDate(valueSlice), rrule.end == .never else { return nil } rrule.end = .afterDate(until) - - case 5 where keySlice.elementsEqual("COUNT".utf8): + case "count": guard let occurrences = parseAsInt(valueSlice, min: 1), rrule.end == .never else { return nil } rrule.end = .afterOccurrences(occurrences) - - case 8 where keySlice.elementsEqual("INTERVAL".utf8): + case "interval": guard let interval = parseAsInt(valueSlice, min: 1) else { return nil } rrule.interval = interval - - case 8 where keySlice.elementsEqual("BYSECOND".utf8): + case "bysecond": guard let seconds = parseAsList(valueSlice, transform: { parseAsInt($0, min: 0, max: 60) }) else { return nil } rrule.seconds = seconds - - case 8 where keySlice.elementsEqual("BYMINUTE".utf8): + case "byminute": guard let minutes = parseAsList(valueSlice, transform: { parseAsInt($0, min: 0, max: 59) }) else { return nil } rrule.minutes = minutes - - case 6 where keySlice.elementsEqual("BYHOUR".utf8): + case "byhour": guard let hours = parseAsList(valueSlice, transform: { parseAsInt($0, min: 0, max: 23) }) else { return nil } rrule.hours = hours - - case 5 where keySlice.elementsEqual("BYDAY".utf8): - guard let weekdays = parseAsList(valueSlice, transform: { parseAsWeekday($0) })else { return nil } + case "byday": + guard let weekdays = parseAsList(valueSlice, transform: { parseAsWeekday($0) }) else { return nil } rrule.weekdays = weekdays - - case 10 where keySlice.elementsEqual("BYMONTHDAY".utf8): + case "bymonthday": let daysOfTheMonth = parseAsList(valueSlice) { parseAsInt($0, min: -31, max: 31) } guard let daysOfTheMonth, daysOfTheMonth.count > 0 else { return nil } rrule.daysOfTheMonth = daysOfTheMonth - - case 9 where keySlice.elementsEqual("BYYEARDAY".utf8): + case "byyearday": let daysOfTheYear = parseAsList(valueSlice) { parseAsInt($0, min: -366, max: 366) } guard let daysOfTheYear, daysOfTheYear.count > 0 else { return nil } rrule.daysOfTheYear = daysOfTheYear - - case 8 where keySlice.elementsEqual("BYWEEKNO".utf8): + case "byweekno": let weeks = parseAsList(valueSlice) { parseAsInt($0, min: -53, max: 53) } guard let weeks else { return nil } rrule.weeks = weeks - - case 7 where keySlice.elementsEqual("BYMONTH".utf8): + case "bymonth": let months: [RecurrenceRule.Month]? = parseAsList(valueSlice) { elementSlice in - guard let index = parseAsInt(elementSlice, min: 1, max: 12) else { return nil } - return RecurrenceRule.Month(index) + guard let idx = parseAsInt(elementSlice, min: 1, max: 12) else { return nil } + return RecurrenceRule.Month(idx) } - guard let months else { return nil } rrule.months = months - - case 8 where keySlice.elementsEqual("BYSETPOS".utf8): + case "bysetpos": let setPositions = parseAsList(valueSlice) { parseAsInt($0, min: -366, max: 366) } guard let setPositions else { return nil } rrule.setPositions = setPositions - + case "wkst": + break default: return nil } } - return (index, rrule) + return (buffer.endIndex, rrule) } - // Compute the final index after parsing. guard let result else { return nil } let endIndex = value.utf8.index(v.startIndex, offsetBy: result.0) return (endIndex, result.1) } + /// Case-insensitive ASCII comparison: slice equals string (exact key match). + private func keySliceEquals(_ slice: Slice>, _ string: String) -> Bool { + guard slice.count == string.utf8.count, + let s = String(bytes: slice, encoding: .utf8) else { return false } + return s.lowercased() == string.lowercased() + } + + /// Returns lowercase canonical key for known RECUR part names, or nil if unknown. + private func keyCanonical(_ slice: Slice>) -> String? { + let known = ["FREQ", "UNTIL", "COUNT", "INTERVAL", "BYSECOND", "BYMINUTE", "BYHOUR", "BYDAY", "BYMONTHDAY", "BYYEARDAY", "BYWEEKNO", "BYMONTH", "BYSETPOS", "WKST"] + guard let s = String(bytes: slice, encoding: .utf8) else { return nil } + let lower = s.lowercased() + return known.first { $0.lowercased() == lower }.map { $0.lowercased() } + } + + // MARK: - Parse Helpers + /// Extracts a key-value pair from the given buffer. /// This function reads a key-value pair from the buffer in the format `key=value`, /// advancing the `index` past the parsed pair. The `key` and `value` are returned @@ -594,24 +676,19 @@ extension RecurrenceRuleRFC5545FormatStyle: ParseStrategy { return results.isEmpty ? nil : results } - /// Parses a buffer slice as a `RecurrenceRule.Frequency`. - /// This function compares the slice of raw UTF-8 data with predefined frequency values - /// ("MINUTELY", "HOURLY", "DAILY", "WEEKLY", "MONTHLY", "YEARLY") and returns the corresponding - /// `RecurrenceRule.Frequency` enum value. - /// - /// - Parameter bufferSlice: A slice of an `UnsafeBufferPointer` containing the raw data to parse. - /// - Returns: A `RecurrenceRule.Frequency` if the slice matches a known frequency, otherwise `nil`. + /// Parses a buffer slice as a `RecurrenceRule.Frequency` (case-insensitive per RFC 5545). /// /// - Warning: The "SECONDLY" frequency is currently not supported in `RecurrenceRule.Frequency`. private func parseAsFrequency(_ bufferSlice: Slice>) -> RecurrenceRule.Frequency? { - switch bufferSlice.count { - case 8 where bufferSlice.elementsEqual("MINUTELY".utf8): .minutely - case 6 where bufferSlice.elementsEqual("HOURLY".utf8): .hourly - case 5 where bufferSlice.elementsEqual("DAILY".utf8): .daily - case 6 where bufferSlice.elementsEqual("WEEKLY".utf8): .weekly - case 7 where bufferSlice.elementsEqual("MONTHLY".utf8): .monthly - case 6 where bufferSlice.elementsEqual("YEARLY".utf8): .yearly - default: nil + guard let s = String(bytes: bufferSlice, encoding: .utf8) else { return nil } + switch s.lowercased() { + case "minutely": return .minutely + case "hourly": return .hourly + case "daily": return .daily + case "weekly": return .weekly + case "monthly": return .monthly + case "yearly": return .yearly + default: return nil } } @@ -634,7 +711,7 @@ extension RecurrenceRuleRFC5545FormatStyle: ParseStrategy { /// - bufferSlice: The slice containing the date or date-time data. /// - timeZone: The time zone to apply to the components. /// - Returns: A `DateComponents` object if the parsing is successful, otherwise `nil`. - func parseDateComponets(_ bufferSlice: Slice>, tz timeZone: TimeZone? = nil) -> DateComponents? { + func parseDateComponents(_ bufferSlice: Slice>, tz timeZone: TimeZone? = nil) -> DateComponents? { guard bufferSlice.count > 0 else { return nil } if bufferSlice.count == 8 { // DATE @@ -651,19 +728,24 @@ extension RecurrenceRuleRFC5545FormatStyle: ParseStrategy { return components } - if bufferSlice.count == 15 { // DATE-TIME + if bufferSlice.count == 15 { // DATE-TIME (hour 24 = midnight end of day per RFC 5545) let year = parseAsInt(bufferSlice[bufferSlice.startIndex.. CocoaError { @@ -790,8 +872,8 @@ public extension FormatStyle where Self == RecurrenceRuleRFC5545FormatStyle { .init(calendar: .current) } - static func rfc5545(calendar: Calendar) -> Self { - .init(calendar: calendar) + static func rfc5545(calendar: Calendar = .current, foldLongLines: Bool = false, emitWKST: Bool = false) -> Self { + .init(calendar: calendar, foldLongLines: foldLongLines, emitWKST: emitWKST) } } From c7d24b9f4bc3345057307f9e658ca9a839031bd2 Mon Sep 17 00:00:00 2001 From: Craig Date: Tue, 17 Feb 2026 21:13:51 -0500 Subject: [PATCH 2/3] Add parse, format, and round-trip tests for RFC 5545 compliance MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parse tests: - Case-insensitivity (freq=daily, mixed-case key/value). - Rule part order (COUNT=5;FREQ=DAILY parses). - FREQUENCY vs FREQ (exact key), SECONDLY throws, duplicate keys throw. - WKST accepted, content-line unfolding (CRLF and LF), BYSECOND=60. - Error contract: parse failure throws NSError with NSCocoaErrorDomain and formatting code. - Parse via RecurrenceRule(strategy:) (ParseStrategy API). Format tests: - foldLongLines (no line >75 octets), emitWKST and default no WKST. - Large-rule format with semantic substring checks (not just length). - Format via rule.formatted(style) (FormatStyle API). - Replace force unwraps with #require in UNTIL format tests. Round-trip tests: - Parse → format → parse (simple, UNTIL date, UNTIL UTC, UNTIL TZID, multiple BY*). - Format → parse → format (simple rule, rule with UNTIL and BYDAY). - TZID round-trip with same calendar (America/New_York). --- .../RecurrenceRuleRFC5545FormatTests.swift | 118 ++++++++++++--- .../RecurrenceRuleRFC5545ParseTests.swift | 141 ++++++++++++++++-- .../RecurrenceRuleRFC5545RoundTripTests.swift | 104 +++++++++++++ 3 files changed, 327 insertions(+), 36 deletions(-) create mode 100644 Tests/RRuleKitTests/RecurrenceRuleRFC5545RoundTripTests.swift diff --git a/Tests/RRuleKitTests/RecurrenceRuleRFC5545FormatTests.swift b/Tests/RRuleKitTests/RecurrenceRuleRFC5545FormatTests.swift index 147552b..2e475ce 100644 --- a/Tests/RRuleKitTests/RecurrenceRuleRFC5545FormatTests.swift +++ b/Tests/RRuleKitTests/RecurrenceRuleRFC5545FormatTests.swift @@ -45,8 +45,6 @@ struct RecurrenceRuleRFC5545FormatTests { let rrule = Calendar.RecurrenceRule(calendar: calendar, frequency: frequency) let result = formatStyle.format(rrule) - print(result) - #expect(result == expected) } @@ -69,14 +67,15 @@ struct RecurrenceRuleRFC5545FormatTests { } @Test("Format UNTIL DATE-TIME with TZID Rule Part") - func formatUntilDateTimeWithTzidRulePart() { + func formatUntilDateTimeWithTzidRulePart() throws { + let tz = try #require(TimeZone(identifier: "America/New_York")) var localCalendar = Calendar(identifier: .gregorian) - localCalendar.timeZone = TimeZone(identifier: "America/New_York")! + localCalendar.timeZone = tz let formatter = RecurrenceRuleRFC5545FormatStyle(calendar: localCalendar) let components = DateComponents(calendar: localCalendar, year: 2025, month: 1, day: 18, hour: 10, minute: 22) - let endDate = components.date! + let endDate = try #require(components.date) let rrule = Calendar.RecurrenceRule(calendar: localCalendar, frequency: .daily, end: .afterDate(endDate)) let expected = "FREQ=DAILY;UNTIL=TZID=America/New_York:20250118T102200" let result = formatter.format(rrule) @@ -85,11 +84,11 @@ struct RecurrenceRuleRFC5545FormatTests { } @Test("Format UNTIL DATE-TIME with UTC Rule Part") - func formatUntilDateTimeWithUTCRulePart() { + func formatUntilDateTimeWithUTCRulePart() throws { let formatter = RecurrenceRuleRFC5545FormatStyle(calendar: calendar) - let componets = DateComponents(calendar: calendar, year: 2025, month: 1, day: 18, hour: 10, minute: 26) + let components = DateComponents(calendar: calendar, year: 2025, month: 1, day: 18, hour: 10, minute: 26) - let endDate = componets.date! + let endDate = try #require(components.date) let rrule = Calendar.RecurrenceRule(calendar: calendar, frequency: .daily, end: .afterDate(endDate)) let expected = "FREQ=DAILY;UNTIL=20250118T102600Z" let result = formatter.format(rrule) @@ -98,11 +97,11 @@ struct RecurrenceRuleRFC5545FormatTests { } @Test("Format UNTIL DATE Rule Part") - func formatUntilDateRulePart() { + func formatUntilDateRulePart() throws { let formatter = RecurrenceRuleRFC5545FormatStyle(calendar: calendar) let components = DateComponents(calendar: calendar, year: 2025, month: 1, day: 18) - let endDate = components.date! + let endDate = try #require(components.date) let rrule = Calendar.RecurrenceRule(calendar: calendar, frequency: .daily, end: .afterDate(endDate)) let expected = "FREQ=DAILY;UNTIL=20250118" let result = formatter.format(rrule) @@ -179,7 +178,7 @@ struct RecurrenceRuleRFC5545FormatTests { } @Test("Format BYMONTHDAY Rule Part", arguments: zip( - [[], [1], [2, -3], []], + [[], [1], [2, -3]], ["", "BYMONTHDAY=1", "BYMONTHDAY=2,-3"] )) func formatByMonthDayRulePart(byMonthDay: [Int], byMonthDayExpected: String) { @@ -215,7 +214,7 @@ struct RecurrenceRuleRFC5545FormatTests { } @Test("Format BYMONTH Rule Part", arguments: zip( - [[], [Calendar.RecurrenceRule.Month(1)], [.init(2), .init(3)], []], + [[], [Calendar.RecurrenceRule.Month(1)], [.init(2), .init(3)]], ["", "BYMONTH=1", "BYMONTH=2,3"] )) func formatByMonthRulePart(byMonth: [Calendar.RecurrenceRule.Month], byMontExpected: String) { @@ -242,17 +241,17 @@ struct RecurrenceRuleRFC5545FormatTests { } @Test("Format large Recurrence Rule") - func formatLagerRRule() { + func formatLargeRRule() { let formatter = RecurrenceRuleRFC5545FormatStyle(calendar: calendar) let rrule = Calendar.RecurrenceRule( calendar: calendar, frequency: .daily, - interval: 2, // Nieco większy interwał - end: .afterOccurrences(20), // Zwiększenie liczby wystąpień - months: [1, 3, 6, 9, 12], // Więcej miesięcy - daysOfTheYear: [1, 50, 100, 150, 200, 250, 300], // Więcej dni w roku - daysOfTheMonth: [1, 10, 15, 20, 25, 31], // Więcej dni w miesiącu - weeks: [1, 10, 20, 30, 40, 50], // Więcej tygodni + interval: 2, + end: .afterOccurrences(20), + months: [1, 3, 6, 9, 12], + daysOfTheYear: [1, 50, 100, 150, 200, 250, 300], + daysOfTheMonth: [1, 10, 15, 20, 25, 31], + weeks: [1, 10, 20, 30, 40, 50], weekdays: [ .every(.monday), .every(.tuesday), @@ -263,13 +262,84 @@ struct RecurrenceRuleRFC5545FormatTests { .every(.sunday), .nth(1, .sunday) ], - hours: [0, 6, 12, 18, 23], // Więcej godzin - minutes: [0, 15, 30, 45, 59], // Więcej minut - seconds: [0, 10, 20, 30, 40, 50], // Więcej sekund - setPositions: [1, -1, 5, 2, 4] // Dodanie więcej pozycji + hours: [0, 6, 12, 18, 23], + minutes: [0, 15, 30, 45, 59], + seconds: [0, 10, 20, 30, 40, 50], + setPositions: [1, -1, 5, 2, 4] ) let result = formatter.format(rrule) - #expect(result.utf8.count == 258) + // Semantic checks: key parts must be present (order and formatting may vary slightly) + #expect(result.contains("FREQ=DAILY")) + #expect(result.contains("INTERVAL=2")) + #expect(result.contains("COUNT=20")) + #expect(result.contains("BYMONTH=1,3,6,9,12")) + #expect(result.contains("BYDAY=MO,TU,WE,TH,FR,SA,SU,1SU")) + #expect(result.contains("BYSETPOS=1,-1,5,2,4")) + #expect(result.utf8.count > 200) + } + + // MARK: - RFC 5545 format options (folding, WKST) + + @Test("Format with foldLongLines keeps no line over 75 octets") + func formatWithFolding() { + let formatter = RecurrenceRuleRFC5545FormatStyle(calendar: calendar, foldLongLines: true) + let rrule = Calendar.RecurrenceRule( + calendar: calendar, + frequency: .daily, + interval: 2, + end: .afterOccurrences(20), + months: [1, 3, 6, 9, 12], + daysOfTheMonth: [1, 10, 15, 20, 25, 31], + weekdays: [.every(.monday), .every(.wednesday), .every(.friday)], + hours: [8, 12, 18], + minutes: [0, 30] + ) + let result = formatter.format(rrule) + // RFC 5545: folded lines are separated by CRLF + SPACE; each logical line is at most 75 octets + let logicalLines = result.components(separatedBy: "\r\n ") + if logicalLines.count == 1 { + let alt = result.components(separatedBy: "\n ") + for segment in alt { + #expect(segment.utf8.count <= 75, "Segment exceeds 75 octets: \(segment.utf8.count)") + } + } else { + for segment in logicalLines { + #expect(segment.utf8.count <= 75, "Segment exceeds 75 octets: \(segment.utf8.count)") + } + } + let unfolded = result.replacingOccurrences(of: "\r\n ", with: "").replacingOccurrences(of: "\n ", with: "") + let oneLine = unfolded.replacingOccurrences(of: "\r\n", with: "").replacingOccurrences(of: "\n", with: "") + #expect(oneLine.contains("FREQ=DAILY")) + #expect(oneLine.contains("BYDAY=MO,WE,FR")) + } + + @Test("Format with emitWKST appends WKST=MO") + func formatWithEmitWKST() { + let formatter = RecurrenceRuleRFC5545FormatStyle(calendar: calendar, emitWKST: true) + let rrule = Calendar.RecurrenceRule(calendar: calendar, frequency: .weekly, weekdays: [.every(.monday)]) + let result = formatter.format(rrule) + #expect(result.hasSuffix(";WKST=MO")) + #expect(result.contains("FREQ=WEEKLY")) + } + + @Test("Format default does not emit WKST") + func formatDefaultNoWKST() { + let formatter = RecurrenceRuleRFC5545FormatStyle(calendar: calendar) + let rrule = Calendar.RecurrenceRule(calendar: calendar, frequency: .weekly) + let result = formatter.format(rrule) + #expect(!result.contains("WKST=")) + } + + // MARK: - FormatStyle API (RecurrenceRule+FormatStyle) + + @Test("Format via rule.formatted(style) (FormatStyle API)") + func formatViaFormattedStyle() { + let formatter = RecurrenceRuleRFC5545FormatStyle(calendar: calendar) + let rrule = Calendar.RecurrenceRule(calendar: calendar, frequency: .daily, interval: 2, end: .afterOccurrences(10)) + let result = rrule.formatted(formatter) + #expect(result.contains("FREQ=DAILY")) + #expect(result.contains("INTERVAL=2")) + #expect(result.contains("COUNT=10")) } } diff --git a/Tests/RRuleKitTests/RecurrenceRuleRFC5545ParseTests.swift b/Tests/RRuleKitTests/RecurrenceRuleRFC5545ParseTests.swift index b313792..9a5b5cf 100644 --- a/Tests/RRuleKitTests/RecurrenceRuleRFC5545ParseTests.swift +++ b/Tests/RRuleKitTests/RecurrenceRuleRFC5545ParseTests.swift @@ -47,12 +47,24 @@ struct RecurrenceRuleRFC5545ParseTests { } @Test("Throws an error for invalid FREQ Rule Part") - func throwsErrorForInvalidFrequencyRuleParrt() throws { + func throwsErrorForInvalidFrequencyRulePart() throws { #expect(throws: NSError.self) { try parser.parse("FREQ=FOOBAR") } } + /// Ensures the public parse error contract: CocoaError with formatting code and NSCocoaErrorDomain. + @Test("Parse failure throws CocoaError with formatting code and NSCocoaErrorDomain") + func parseFailureThrowsStableErrorContract() throws { + do { + _ = try parser.parse("FREQ=FOOBAR") + #expect(Bool(false), "Expected parse to throw") + } catch let error as NSError { + #expect(error.domain == NSCocoaErrorDomain) + #expect(error.code == CocoaError.Code.formatting.rawValue) + } + } + @Test("Parse INTERVAL Rule Part", arguments: zip(["INTERVAL=1", "INTERVAL=2", "INTERVAL=10"], [1, 2, 10])) func parseIntervalRulePart(rfcInterval: String, expected: Int) throws { let rfcString = "FREQ=DAILY;\(rfcInterval)" @@ -64,7 +76,7 @@ struct RecurrenceRuleRFC5545ParseTests { @Test("Throws an error for invalid INTERVAL Rule Part", arguments: [ "INTERVAL=", "INTERVAL=-1", "INTERVAL=0", "INTERVAL=foo" ]) - func throwsErrorForInvalidIntervalRuleParrt(invalidInterval: String) throws { + func throwsErrorForInvalidIntervalRulePart(invalidInterval: String) throws { let rfcString = "FREQ=DAILY;\(invalidInterval)" #expect(throws: NSError.self) { try parser.parse(rfcString) @@ -114,7 +126,7 @@ struct RecurrenceRuleRFC5545ParseTests { } @Test("Parse UNTIL local DATE-TIME Rule Part") - func parseUntilLocalDateTimeRuleParty() throws { + func parseUntilLocalDateTimeRulePart() throws { let rfcString = "FREQ=DAILY;UNTIL=20250111T235959" let expected = Calendar.RecurrenceRule.End.afterDate(Date(timeIntervalSince1970: 1736639999)) let result = try parser.parse(rfcString) @@ -131,8 +143,17 @@ struct RecurrenceRuleRFC5545ParseTests { #expect(result.end == expected) } + @Test("Parse UNTIL DATE-TIME with hour 00 (midnight) per RFC 5545") + func parseUntilDateTimeWithHourZeroRulePart() throws { + let rfcString = "FREQ=DAILY;UNTIL=20250111T003000Z" + let result = try parser.parse(rfcString) + // 2025-01-11 00:30:00 UTC = midnight + 30 minutes + let expected = Calendar.RecurrenceRule.End.afterDate(Date(timeIntervalSince1970: 1736555400)) + #expect(result.end == expected) + } + @Test("Throws an error for invalid UNTIL Rule Part", arguments: ["UNTIL=20251350", "UNTIL=foobar", "UNTIL=1"]) - func throwsErrorForInvalidCountRulePart(invalidString: String) throws { + func throwsErrorForInvalidUntilRulePart(invalidString: String) throws { let rfcString = "FREQ=DAILY;\(invalidString)" #expect(throws: NSError.self, performing: { @@ -175,7 +196,7 @@ struct RecurrenceRuleRFC5545ParseTests { } @Test("Throws an error for invalid BYMINUTE Rule Part", arguments: ["BYMINUTE=-1","BYMINUTE=60","BYMINUTE=foobar"]) - func invalidByMinute(rfcByMinute: String) throws { + func throwsErrorForInvalidByMinuteRulePart(rfcByMinute: String) throws { let rfcString = "FREQ=DAILY;\(rfcByMinute)" #expect(throws: NSError.self) { @@ -191,9 +212,9 @@ struct RecurrenceRuleRFC5545ParseTests { #expect(result.hours == expected) } - @Test("Throws an error for invalid BYMINUTE Rule Part", arguments: ["BYHOUR=-1","BYHOUR=24","BYMINUTE=foobar"]) - func invalidByHour(rfcByMinute: String) throws { - let rfcString = "FREQ=DAILY;\(rfcByMinute)" + @Test("Throws an error for invalid BYHOUR Rule Part", arguments: ["BYHOUR=-1", "BYHOUR=24", "BYHOUR=foobar"]) + func throwsErrorForInvalidByHourRulePart(rfcByHour: String) throws { + let rfcString = "FREQ=DAILY;\(rfcByHour)" #expect(throws: NSError.self) { try parser.parse(rfcString) @@ -213,7 +234,7 @@ struct RecurrenceRuleRFC5545ParseTests { ] ) ) - func parseByDayEveryWeekdarRulePart(rfcByDay: String, expected: Calendar.RecurrenceRule.Weekday) throws { + func parseByDayEveryWeekdayRulePart(rfcByDay: String, expected: Calendar.RecurrenceRule.Weekday) throws { let rfcString = "FREQ=DAILY;\(rfcByDay)" let result = try parser.parse(rfcString) @@ -248,7 +269,7 @@ struct RecurrenceRuleRFC5545ParseTests { ["BYMONTHDAY=1", "BYMONTHDAY=-31", "BYMONTHDAY=1,15,31"], [[1], [-31], [1, 15, 31]] )) - func parseByMonthDatRulePart(rfcByMonthDay: String, expected: [Int]) throws { + func parseByMonthDayRulePart(rfcByMonthDay: String, expected: [Int]) throws { let rfcString = "FREQ=MONTHLY;\(rfcByMonthDay)" let result = try parser.parse(rfcString) @@ -280,7 +301,7 @@ struct RecurrenceRuleRFC5545ParseTests { @Test("Throws an error for invalid BYYEARDAY Rule Part", arguments: [ "BYYEARDAY=", "BYYEARDAY=-367", "BYYEARDAY=367", "BYYEARDAY=foobar" ]) - func throwsErroForInvalidByYearDayRulePart(invalidString: String) throws { + func throwsErrorForInvalidByYearDayRulePart(invalidString: String) throws { let rfcString = "FREQ=YEARLY;\(invalidString)" #expect(throws: NSError.self) { @@ -312,7 +333,7 @@ struct RecurrenceRuleRFC5545ParseTests { @Test("Parses BYMONTH Rule Part", arguments: zip( ["BYMONTH=1", "BYMONTH=1,6,12"], - [[Calendar.RecurrenceRule.Month(1)], [1, 6, 12]] + [[Calendar.RecurrenceRule.Month(1)], [Calendar.RecurrenceRule.Month(1), .init(6), .init(12)]] )) func parseByMonthRulePart(rfcByMonth: String, expected: [Calendar.RecurrenceRule.Month]) throws { let rfcString = "FREQ=YEARLY;\(rfcByMonth)" @@ -354,6 +375,29 @@ struct RecurrenceRuleRFC5545ParseTests { } } + /// RFC 5545: compliant applications MUST accept rule parts in any order. + @Test("Parses rule when FREQ is not the first key (any order)") + func parsesWhenFreqIsNotFirst() throws { + let result = try parser.parse("COUNT=5;FREQ=DAILY") + #expect(result.frequency == .daily) + #expect(result.end == .afterOccurrences(5)) + } + + /// Exact key "FREQ" is required; "FREQUENCY" is not accepted. + @Test("Throws an error when first key is FREQUENCY instead of FREQ") + func throwsErrorWhenKeyIsFrequencyNotFreq() throws { + #expect(throws: NSError.self) { + try parser.parse("FREQUENCY=DAILY") + } + } + + @Test("Throws an error for FREQ=SECONDLY (unsupported)") + func throwsErrorForSecondlyFrequency() throws { + #expect(throws: NSError.self) { + try parser.parse("FREQ=SECONDLY;INTERVAL=1") + } + } + @Test("Throws an error for invalid RFC 5545 string format", arguments: [ "", "FOO=BAR", "COUNT=1", "FREQ=MONTHLY:COUNT=2", "FREQ=MONTHLY;BYDAY=MO;WE" ]) @@ -388,4 +432,77 @@ struct RecurrenceRuleRFC5545ParseTests { try parser.parse(rfcString) } } + + /// RFC 5545 errata: BYDAY with numeric modifiers + BYWEEKNO in YEARLY can be invalid. + /// The library does not reject this combination; parsing succeeds and semantics are left to Foundation. + @Test("Parses YEARLY with BYWEEKNO and BYDAY ordinal (errata combination; library does not reject)") + func parsesYearlyByWeekNoAndByDayOrdinal() throws { + let rfcString = "FREQ=YEARLY;BYWEEKNO=1;BYDAY=1MO" + let result = try parser.parse(rfcString) + #expect(result.frequency == .yearly) + #expect(result.weeks == [1]) + #expect(result.weekdays == [.nth(1, .monday)]) + } + + // MARK: - RFC 5545 case-insensitivity (Section 2, 3.1) + + @Test("Parse FREQ with lowercase value (case-insensitive)") + func parseFreqLowerCase() throws { + let result = try parser.parse("freq=daily") + #expect(result.frequency == .daily) + } + + @Test("Parse mixed-case key and value") + func parseMixedCase() throws { + let result = try parser.parse("FREQ=Weekly;ByDay=mo,we,fr") + #expect(result.frequency == .weekly) + #expect(result.weekdays == [.every(.monday), .every(.wednesday), .every(.friday)]) + } + + // MARK: - WKST (RFC 5545 Section 3.3.10) + + @Test("Parses rule with WKST (accepted and ignored; Foundation has no week-start API)") + func parsesWithWkst() throws { + let result = try parser.parse("FREQ=WEEKLY;WKST=SU;BYDAY=MO") + #expect(result.frequency == .weekly) + #expect(result.weekdays == [.every(.monday)]) + } + + // MARK: - Content-line unfolding (RFC 5545 Section 3.1) + + @Test("Parses folded content line (CRLF + SPACE removed)") + func parsesFoldedContentLine() throws { + let folded = "FREQ=DAILY;BYDAY=MO,\r\n TU,WE" + let result = try parser.parse(folded) + #expect(result.frequency == .daily) + #expect(result.weekdays == [.every(.monday), .every(.tuesday), .every(.wednesday)]) + } + + @Test("Parses folded content line (LF + SPACE)") + func parsesFoldedContentLineLF() throws { + let folded = "FREQ=WEEKLY;COUNT=3\n ;BYDAY=MO" + let result = try parser.parse(folded) + #expect(result.frequency == .weekly) + #expect(result.end == .afterOccurrences(3)) + #expect(result.weekdays == [.every(.monday)]) + } + + // MARK: - BYSECOND=60 (leap second, RFC 5545) + + @Test("Parses BYSECOND=60 (leap second per RFC 5545)") + func parsesBySecond60() throws { + let result = try parser.parse("FREQ=DAILY;BYSECOND=0,60") + #expect(result.seconds == [0, 60]) + } + + // MARK: - ParseableFormatStyle API (RecurrenceRule+FormatStyle) + + @Test("Parse via RecurrenceRule init with strategy (ParseStrategy API)") + func parseViaRecurrenceRuleInitWithStrategy() throws { + let rfcString = "FREQ=WEEKLY;BYDAY=MO,FR;COUNT=5" + let rule = try Calendar.RecurrenceRule(rfcString, strategy: parser) + #expect(rule.frequency == .weekly) + #expect(rule.weekdays == [.every(.monday), .every(.friday)]) + #expect(rule.end == .afterOccurrences(5)) + } } diff --git a/Tests/RRuleKitTests/RecurrenceRuleRFC5545RoundTripTests.swift b/Tests/RRuleKitTests/RecurrenceRuleRFC5545RoundTripTests.swift new file mode 100644 index 0000000..03c125a --- /dev/null +++ b/Tests/RRuleKitTests/RecurrenceRuleRFC5545RoundTripTests.swift @@ -0,0 +1,104 @@ +// +// RecurrenceRuleRFC5545RoundTripTests.swift +// RRuleKit +// + +import Testing +import Foundation +@testable import RRuleKit + +@Suite("Recurrence Rule RFC 5545 Round-Trip Tests") +struct RecurrenceRuleRFC5545RoundTripTests { + + var calendar: Calendar { + var cal = Calendar(identifier: .gregorian) + cal.timeZone = .gmt + return cal + } + + var style: RecurrenceRuleRFC5545FormatStyle { + RecurrenceRuleRFC5545FormatStyle(calendar: calendar) + } + + @Test("Parse then format then parse matches original (simple rule)") + func roundTripParseFormatParseSimple() throws { + let rfcString = "FREQ=DAILY;INTERVAL=2;COUNT=5" + let parsed = try style.parse(rfcString) + let formatted = style.format(parsed) + let reparsed = try style.parse(formatted) + #expect(parsed == reparsed) + } + + @Test("Parse then format then parse matches original (UNTIL date-only)") + func roundTripParseFormatParseUntilDate() throws { + let rfcString = "FREQ=DAILY;UNTIL=20250111" + let parsed = try style.parse(rfcString) + let formatted = style.format(parsed) + let reparsed = try style.parse(formatted) + #expect(parsed.end == reparsed.end) + #expect(parsed.frequency == reparsed.frequency) + } + + @Test("Parse then format then parse matches original (UNTIL date-time UTC)") + func roundTripParseFormatParseUntilDateTimeUTC() throws { + let rfcString = "FREQ=DAILY;UNTIL=20250111T235959Z" + let parsed = try style.parse(rfcString) + let formatted = style.format(parsed) + let reparsed = try style.parse(formatted) + #expect(parsed.end == reparsed.end) + } + + /// Audit Phase 3.1: round-trip must cover UNTIL with TZID. + @Test("Parse then format then parse matches original (UNTIL date-time with TZID)") + func roundTripParseFormatParseUntilWithTzid() throws { + let tz = try #require(TimeZone(identifier: "America/New_York")) + var cal = Calendar(identifier: .gregorian) + cal.timeZone = tz + let styleWithTz = RecurrenceRuleRFC5545FormatStyle(calendar: cal) + let rfcString = "FREQ=DAILY;UNTIL=TZID=America/New_York:20250111T235959" + let parsed = try styleWithTz.parse(rfcString) + let formatted = styleWithTz.format(parsed) + let reparsed = try styleWithTz.parse(formatted) + #expect(parsed.end == reparsed.end) + #expect(parsed.frequency == reparsed.frequency) + } + + @Test("Parse then format then parse matches original (rule with several BY* parts)") + func roundTripParseFormatParseWithByParts() throws { + let rfcString = "FREQ=MONTHLY;BYDAY=MO,TU;BYMONTH=1,6;BYSETPOS=1,-1;COUNT=5" + let parsed = try style.parse(rfcString) + let formatted = style.format(parsed) + let reparsed = try style.parse(formatted) + #expect(parsed == reparsed) + } + + @Test("Format then parse then format matches original (simple rule)") + func roundTripFormatParseFormatSimple() throws { + let rrule = Calendar.RecurrenceRule( + calendar: calendar, + frequency: .weekly, + interval: 1, + weekdays: [.every(.monday), .every(.friday)] + ) + let formatted = style.format(rrule) + let parsed = try style.parse(formatted) + let reformatted = style.format(parsed) + #expect(formatted == reformatted) + } + + @Test("Format then parse then format matches original (rule with UNTIL and BYDAY)") + func roundTripFormatParseFormatWithUntilAndByDay() throws { + let components = DateComponents(calendar: calendar, year: 2025, month: 6, day: 15) + let endDate = try #require(components.date) + let rrule = Calendar.RecurrenceRule( + calendar: calendar, + frequency: .weekly, + end: .afterDate(endDate), + weekdays: [.every(.monday), .nth(1, .wednesday)] + ) + let formatted = style.format(rrule) + let parsed = try style.parse(formatted) + let reformatted = style.format(parsed) + #expect(formatted == reformatted) + } +} From 3fb7a2be4aea3f25c63207524ffd3f84ca35707a Mon Sep 17 00:00:00 2001 From: Craig Date: Tue, 17 Feb 2026 21:13:56 -0500 Subject: [PATCH 3/3] Document RFC 5545 behavior: any order, case-insensitivity, folding, WKST DocC: - Update ParsingRFC5545 and FormattingRFC5545 with new options and rules. - Link from main Documentation to parsing/formatting topics. README: - Add "Key RFC 5545 parsing rules" (any order, case-insensitivity, folding, WKST, no SECONDLY). - Add "Add-ons" section (placeholder or link to interop). - Add "Enforced Rules" and expand Limitations (errata). - Update Testing list (round-trip, large-rule semantic checks). --- README.md | 34 +++++++++++++++++-- .../Documentation.docc/Documentation.md | 6 ++++ .../Documentation.docc/FormattingRFC5545.md | 18 ++++++++++ .../Documentation.docc/ParsingRFC5545.md | 21 ++++++++---- 4 files changed, 71 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 2da2866..d71198a 100644 --- a/README.md +++ b/README.md @@ -47,6 +47,13 @@ This library provides comprehensive support for parsing and formatting recurrenc - Validating ranges for numerical values (e.g., seconds, minutes, days). - Converts between `Calendar.RecurrenceRule` objects and RFC 5545-compliant strings. +**Key RFC 5545 parsing rules** (see [RFC 5545 Section 3.3.10](https://datatracker.ietf.org/doc/html/rfc5545#section-3.3.10)): + - **FREQ** is required exactly once; rule parts may appear in **any order** (e.g. `COUNT=5;FREQ=DAILY` is valid). The key must be exactly `FREQ` (case-insensitive). + - **Case-insensitivity**: Property names and enumerated values are case-insensitive (e.g. `freq=daily`, `BYDAY=mo,we`). + - **Content-line folding**: Input is unfolded before parsing (CRLF/LF + space removed). When formatting, use `foldLongLines: true` to fold lines at 75 octets. + - **WKST**: The `WKST` part is accepted when parsing (and ignored); use `emitWKST: true` when formatting to emit `WKST=MO`. + - **SECONDLY** is not supported (Foundation has no corresponding frequency). + --- ## Platform Support @@ -114,6 +121,16 @@ print(result) // Outputs: "FREQ=DAILY;INTERVAL=2;COUNT=5;BYDAY=MO,WE" --- +## Add-ons + +Optional modules that extend RRuleKit for specific integrations: + +| Product | Description | +|--------|-------------| +| **RRuleKitRruleJSInterop** | Interop with [jkbrzt/rrule](https://github.com/jkbrzt/rrule) (JavaScript): parse full `DTSTART` + `RRULE:` content from `rule.toString()`, reject `FREQ=SECONDLY` with a clear error, and format rules with an optional DTSTART line for rrule.js. See `Sources/RRuleKitRruleJSInterop/README.md` and add `RRuleKitRruleJSInterop` to your target dependencies. | + +--- + ## Calendar.RecurrenceRule.End Support The library includes support for the `.afterOccurrences` and `.afterDate` formats within `Calendar.RecurrenceRule.End`. However, these formats are available only on the following platform versions: @@ -129,7 +146,7 @@ The library includes support for the `.afterOccurrences` and `.afterDate` format ## Key RFC 5545 Parsing Rules - - **FREQ** is mandatory and must be the first key in the rule string. + - **FREQ** is required exactly once; rule parts may appear in any order. - Currently, FREQ=SECONDLY is not supported. - Only one of COUNT or UNTIL can be specified. - Keys and values are separated by = and must be delimited by ;. @@ -155,10 +172,21 @@ For example: - `BYMONTHDAY` values must be in the range -31–31. - `UNTIL` and `COUNT` cannot coexist in the same rule. +### Enforced Rules + +The library explicitly enforces the following RFC 5545 rules: + +- **FREQ required:** Exactly one `FREQ` part is required; rule parts may appear in any order (e.g. `COUNT=5;FREQ=DAILY` is valid). +- **No SECONDLY:** `FREQ=SECONDLY` is not supported (Foundation’s `RecurrenceRule.Frequency` does not include it). Parsing throws an error. +- **COUNT and UNTIL mutually exclusive:** Only one of `COUNT` or `UNTIL` may appear in a rule; duplicate or conflicting keys cause parse failure. +- **Duplicate keys:** Duplicate keys (e.g. `FREQ=DAILY;FREQ=WEEKLY`) cause parsing to fail. + ### Limitations `FREQ=SECONDLY` is not supported because `Calendar.RecurrenceRule.Frequency` does not currently include this frequency. If the input string specifies `FREQ=SECONDLY`, the library will throw an error. +RFC 5545 errata (e.g. restrictions on `BYDAY` with numeric modifiers when `BYWEEKNO` is present in YEARLY rules) are not validated; invalid combinations are left to Foundation’s `RecurrenceRule` semantics. + --- ## Testing @@ -166,7 +194,9 @@ For example: `RRuleKit` includes an extensive test suite that validates the following: - Correct parsing and formatting of all supported rule parts. -- Compliance with the RFC 5545 standard. +- Compliance with the RFC 5545 standard (FREQ required, any part order, no SECONDLY, duplicate keys rejected). +- Round-trip consistency: parse → format → parse and format → parse → format for representative rules (including UNTIL with date/date-time and multiple BY* parts). +- Large-rule formatting with semantic checks on output. - Buffer capacity adjustments to handle large RRULE strings efficiently. --- diff --git a/Sources/RRuleKit/Documentation.docc/Documentation.md b/Sources/RRuleKit/Documentation.docc/Documentation.md index 00f81a6..bfb63f6 100644 --- a/Sources/RRuleKit/Documentation.docc/Documentation.md +++ b/Sources/RRuleKit/Documentation.docc/Documentation.md @@ -37,6 +37,12 @@ Each feature is optimized for performance and adheres to the rules and constrain - Supports both UTC and local time zone representations. - Includes `TZID` for local times. +- **Enforced RFC 5545 rules**: + - FREQ is required (exactly once, any position); keys and values are case-insensitive. + - Input is unfolded (CRLF/LF + space removed) before parsing; optional line folding when formatting. + - WKST is accepted when parsing and optionally emitted when formatting; SECONDLY is not supported. + - COUNT and UNTIL are mutually exclusive; duplicate keys cause parse failure. + ## Topics ### Essentials diff --git a/Sources/RRuleKit/Documentation.docc/FormattingRFC5545.md b/Sources/RRuleKit/Documentation.docc/FormattingRFC5545.md index b06564d..9364e0b 100644 --- a/Sources/RRuleKit/Documentation.docc/FormattingRFC5545.md +++ b/Sources/RRuleKit/Documentation.docc/FormattingRFC5545.md @@ -41,3 +41,21 @@ let rfcString = formatter.format(rrule) print(rfcString) // Output: "FREQ=WEEKLY;BYDAY=MO,FR" ``` + +## Format Options + +- **foldLongLines**: When `true`, the formatted string is folded so no line exceeds 75 octets (RFC 5545 Section 3.1). Continuation lines are introduced with CRLF + SPACE. Default is `false`. +- **emitWKST**: When `true`, the formatter appends `;WKST=MO` for RECUR compliance (default week start). WKST is not stored in `RecurrenceRule`. Default is `false`. + +Example: + +```swift +let formatter = RecurrenceRuleRFC5545FormatStyle(calendar: .current, foldLongLines: true, emitWKST: true) +let rfcString = formatter.format(rrule) +``` + +## Notes + +- Output order follows RFC 5545: FREQ first, then COUNT or UNTIL (if present), INTERVAL, then BY* parts in a fixed order, optionally WKST. +- Date and date-time values in UNTIL are formatted per RFC 5545 (UTC with Z, or TZID for local time). +- Time components default to midnight when omitted. diff --git a/Sources/RRuleKit/Documentation.docc/ParsingRFC5545.md b/Sources/RRuleKit/Documentation.docc/ParsingRFC5545.md index 8449ed1..2cc7cd8 100644 --- a/Sources/RRuleKit/Documentation.docc/ParsingRFC5545.md +++ b/Sources/RRuleKit/Documentation.docc/ParsingRFC5545.md @@ -10,7 +10,7 @@ Parsing functionality allows conversion of strings like: FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=10 ``` -into structured `Calendar.RecurrenceRule objects`. +into structured `Calendar.RecurrenceRule` objects. ## Example @@ -21,16 +21,25 @@ import RRuleKit let parser = RecurrenceRuleRFC5545FormatStyle(calendar: .current) do { -let rfcString = "FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=10" -let recurrenceRule = try parser.parse(rfcString) + let rfcString = "FREQ=WEEKLY;BYDAY=MO,WE,FR;INTERVAL=2;COUNT=10" + let recurrenceRule = try parser.parse(rfcString) print(recurrenceRule) } catch { print("Parsing error: \(error)") } ``` -## Notes +## Key RFC 5545 Parsing Rules -- FREQ is mandatory and must appear first. -- COUNT and UNTIL cannot coexist in a rule. +- **FREQ** is mandatory and must appear exactly once. Rule parts may appear in **any order** (e.g. `COUNT=5;FREQ=DAILY` is valid). The key must be exactly `FREQ` (case-insensitive); `FREQUENCY=DAILY` is rejected. +- **Case-insensitivity**: Property names and enumerated values are case-insensitive (e.g. `freq=daily`, `BYDAY=mo,we`). +- **Content-line folding**: Input is unfolded before parsing: CRLF or LF followed by a single SPACE or HTAB is removed, so folded content lines (e.g. from .ics files) parse correctly. +- **WKST**: The `WKST` rule part is accepted and ignored (Foundation has no week-start API). +- **COUNT** and **UNTIL** cannot coexist; only one may be specified. +- **Duplicate keys** cause parsing to fail. +- **FREQ=SECONDLY** is not supported and will cause parsing to fail. - Invalid or unsupported keys will cause parsing to fail. + +## Limitations + +RFC 5545 errata (e.g. BYDAY with numeric modifiers in combination with BYWEEKNO for YEARLY rules) are not validated by the parser.