Skip to content

Commit 5cdd221

Browse files
cjchapmanitingliu
andauthored
rdar://138592292 (🌏 [New Calendars] Date Calculation & API Support) (#3168) (#1409)
* rdar://138592292 (🌏 [New Calendars] Date Calculation & API Support) - added support for "repeated day" (a.k.a. "adhika tithi" and "leap day") for use with Hindu lunisolar calendars - updated leap-month-related checks to include Vietnamese, Dangi, and Hindu lunisolar calendars - updated `availableCalendarIdentifiers` in `TestNSCalendar` to include new calendars * Update FoundationPreview/Sources/FoundationEssentials/Calendar/DateComponents.swift * Update FoundationPreview/Sources/FoundationEssentials/Calendar/DateComponents.swift * added brackets for ifs * made code that uses or depends on UCAL_IS_REPEATED_DAY conditional on FOUNDATION_FRAMEWORK * made CalendarTests.test_isRepeatedDayProperty() conditional on FOUNDATION_FRAMEWORK * removed incorrect uses of FOUNDATION_FRAMEWORK * changed incorrect use of setLeapMonth to be setRepeatedDay * added FIXME comments to the `#if FOUNDATION_FRAMEWORK` lines * add all new calendars to the allCalendars list in CalendarTests * replaced uses of array.contains() with direct identifier comparisons for efficiency * fixed problem with finding the start of a repeated day added test_isDateInSameDayAsRepeatedDay enabled all the Hindu calendars for the test_isDateInToday, test_isDateInYesterday, and test_isDateInTomorrow now that the ICU and Foundation issues affecting them are fixed * added support & tests for enumeratino with isRepeatedDay * changed Calendar_Enumerate.swift to use _dateComponents() instead of component() for isRepeatedDay backed out the change I'd made it DateComponents.swift to make component() work for isRepeatedDay * fixed test_repeatedDay in TestNSCalendar.m also moved it out of FIXED_152328671 conditional block and into FOUNDATION_FRAMEWORK conditional block * changed minimumRangeOf and maximumRangeOf for .isLeapMonth and .isRepeatedDay values based on those in kGregorianCalendarLimits in gregocal.cpp in ICU * updated comment in highestSetUnit * changed code to encode/decode repeated day as bool (was integer) * changed value from true to YES * renamed kCFCalendarUnitRepeatedDay to kCFCalendarUnitIsRepeatedDay * changed date component comparisons to require exact match for isRepeatedDay * changed isLunisolarCalendar check to do comparisons instead of using array.contains() * added FOUNDATION_FRAMEWORK guard around two uses of UCAL_IS_REPEATED_DAY in _locked_setToFirstInstant() * Adopt swift-testing * added hasRepeatingMonths() function to Calendar * changed range for repeated day * refactored test_repeatedDay() * added NSCalendarUnitIsRepeatedDay to NSCalendar.h removed the FOUNDATION_FRAMEWORK guard from TestNSCalendar.m * updated availability * changed hasRepeatingMonths from func to var, used it in one more place * added a test case for repeated days with a calendar that doesn't have any --------- Co-authored-by: T Liu <[email protected]>
1 parent d18dcdb commit 5cdd221

File tree

8 files changed

+320
-34
lines changed

8 files changed

+320
-34
lines changed

β€ŽSources/FoundationEssentials/Calendar/Calendar.swift

Lines changed: 35 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -248,6 +248,7 @@ public struct Calendar : Hashable, Equatable, Sendable {
248248
package static let timeZone = ComponentSet(rawValue: 1 << 15)
249249
package static let isLeapMonth = ComponentSet(rawValue: 1 << 16)
250250
package static let dayOfYear = ComponentSet(rawValue: 1 << 18)
251+
package static let isRepeatedDay = ComponentSet(rawValue: 1 << 19)
251252

252253
package var count: Int {
253254
rawValue.nonzeroBitCount
@@ -272,6 +273,7 @@ public struct Calendar : Hashable, Equatable, Sendable {
272273
if contains(.calendar) { result.insert(.calendar) }
273274
if contains(.timeZone) { result.insert(.timeZone) }
274275
if contains(.isLeapMonth) { result.insert(.isLeapMonth) }
276+
if contains(.isRepeatedDay) { result.insert(.isRepeatedDay) }
275277
if contains(.dayOfYear) { result.insert(.dayOfYear) }
276278
return result
277279
}
@@ -293,8 +295,9 @@ public struct Calendar : Hashable, Equatable, Sendable {
293295
if self.contains(.yearForWeekOfYear) { return .yearForWeekOfYear }
294296
if self.contains(.nanosecond) { return .nanosecond }
295297

296-
// The algorithms that call this function assume that isLeapMonth counts as a 'highest unit set', but the order is after nanosecond.
298+
// The algorithms that call this function assume that isLeapMonth and isRepeatedDay can count as 'highest unit set', but they are ordered after nanosecond.
297299
if self.contains(.isLeapMonth) { return .isLeapMonth }
300+
if self.contains(.isRepeatedDay) { return .isRepeatedDay }
298301

299302
// The calendar and timeZone properties do not count as a 'highest unit set', since they are not ordered in time like the others are.
300303
return nil
@@ -325,7 +328,8 @@ public struct Calendar : Hashable, Equatable, Sendable {
325328
case timeZone
326329
@available(macOS 14, iOS 17, tvOS 17, watchOS 10, *)
327330
case isLeapMonth
328-
331+
@available(FoundationPreview 6.2, *)
332+
case isRepeatedDay
329333
@available(macOS 15, iOS 18, tvOS 18, watchOS 11, *)
330334
case dayOfYear
331335

@@ -349,6 +353,7 @@ public struct Calendar : Hashable, Equatable, Sendable {
349353
case .calendar: return ComponentSet.calendar.rawValue
350354
case .timeZone: return ComponentSet.timeZone.rawValue
351355
case .isLeapMonth: return ComponentSet.isLeapMonth.rawValue
356+
case .isRepeatedDay: return ComponentSet.isRepeatedDay.rawValue
352357
}
353358
}
354359

@@ -372,6 +377,7 @@ public struct Calendar : Hashable, Equatable, Sendable {
372377
case .calendar: "calendar"
373378
case .timeZone: "timeZone"
374379
case .isLeapMonth: "isLeapMonth"
380+
case .isRepeatedDay: "isRepeatedDay"
375381
}
376382
}
377383
}
@@ -694,6 +700,11 @@ public struct Calendar : Hashable, Equatable, Sendable {
694700
return dc
695701
}
696702

703+
/// True if this is a lunisolar calendar that repeats the month number for a leap month, false otherwise.
704+
var hasRepeatingMonths: Bool {
705+
return identifier == .chinese || identifier == .dangi || identifier == .gujarati || identifier == .kannada || identifier == .marathi || identifier == .telugu || identifier == .vietnamese || identifier == .vikram
706+
}
707+
697708
/// Returns all the date components of a date, as if in a given time zone (instead of the `Calendar` time zone).
698709
///
699710
/// The time zone overrides the time zone of the `Calendar` for the purposes of this calculation.
@@ -809,7 +820,7 @@ public struct Calendar : Hashable, Equatable, Sendable {
809820
}
810821

811822
switch component {
812-
case .calendar, .timeZone, .isLeapMonth:
823+
case .calendar, .timeZone, .isLeapMonth, .isRepeatedDay:
813824
return .orderedSame
814825
case .day, .hour:
815826
// Day here so we don't assume that time zone fall back situations don't fall back into a previous day
@@ -902,6 +913,9 @@ public struct Calendar : Hashable, Equatable, Sendable {
902913
let comp1 = self.dateComponents(Set(units), from: date1)
903914
let comp2 = self.dateComponents(Set(units), from: date2)
904915

916+
// check if this is a lunisolar calendar that repeats the day number for a leap day
917+
let hasRepeatingDays = identifier == .gujarati || identifier == .kannada || identifier == .marathi || identifier == .telugu || identifier == .vikram
918+
905919
for c in units {
906920
guard let value1 = comp1.value(for: c), let value2 = comp2.value(for: c) else {
907921
return fallback
@@ -913,7 +927,7 @@ public struct Calendar : Hashable, Equatable, Sendable {
913927
return .orderedAscending
914928
}
915929

916-
if c == .month && identifier == .chinese {
930+
if c == .month && hasRepeatingMonths {
917931
let leap1 = comp1.isLeapMonth ?? false
918932
let leap2 = comp2.isLeapMonth ?? false
919933

@@ -924,6 +938,17 @@ public struct Calendar : Hashable, Equatable, Sendable {
924938
}
925939
}
926940

941+
if c == .day && hasRepeatingDays {
942+
let repeated1 = comp1.isRepeatedDay ?? false
943+
let repeated2 = comp2.isRepeatedDay ?? false
944+
945+
if !repeated1 && repeated2 {
946+
return .orderedAscending
947+
} else if repeated1 && !repeated2 {
948+
return .orderedDescending
949+
}
950+
}
951+
927952
if component == c {
928953
return .orderedSame
929954
}
@@ -1341,6 +1366,12 @@ public struct Calendar : Hashable, Equatable, Sendable {
13411366
comp.isLeapMonth = _dateComponents(.month, from: date).isLeapMonth
13421367
}
13431368

1369+
if components.isRepeatedDay != nil {
1370+
// `isRepeatedDay` isn't part of `actualUnits`, so we have to retrieve
1371+
// it separately
1372+
comp.isRepeatedDay = _dateComponents(.isRepeatedDay, from: date).isRepeatedDay
1373+
}
1374+
13441375
// Apply an epsilon to comparison of nanosecond values
13451376
if let nanosecond = comp.nanosecond, let tempNanosecond = tempComp.nanosecond {
13461377
if labs(CLong(nanosecond - tempNanosecond)) > 500 {

β€ŽSources/FoundationEssentials/Calendar/Calendar_Enumerate.swift

Lines changed: 24 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,7 @@ extension DateComponents {
149149
guard calendar.value(v, isValidFor: .nanosecond) else { return false }
150150
}
151151

152-
if !hasAtLeastOneFieldSet && (isLeapMonth ?? false) {
152+
if !hasAtLeastOneFieldSet && !(isLeapMonth ?? false) && !(isRepeatedDay ?? false) {
153153
return false
154154
}
155155

@@ -217,6 +217,7 @@ extension DateComponents {
217217
if self.yearForWeekOfYear != nil { units.insert(.yearForWeekOfYear) }
218218
if self.dayOfYear != nil { units.insert(.dayOfYear) }
219219
if self.nanosecond != nil { units.insert(.nanosecond) }
220+
if self.isRepeatedDay != nil { units.insert(.isRepeatedDay) }
220221
return units
221222
}
222223

@@ -1205,7 +1206,6 @@ extension Calendar {
12051206

12061207
// We need to check for leap* situations
12071208
let isGregorianCalendar = identifier == .gregorian
1208-
let isChineseCalendar = identifier == .chinese
12091209

12101210
if nextHighestUnit == .year || leapMonthMismatch {
12111211
let desiredMonth = compsToMatch.month
@@ -1216,7 +1216,7 @@ extension Calendar {
12161216
return matchDate
12171217
}
12181218

1219-
if isChineseCalendar {
1219+
if hasRepeatingMonths {
12201220
if leapMonthMismatch {
12211221
return try _adjustedDateForMismatchedChineseLeapMonth(start: start, searchingDate: searchingDate, matchDate: matchDate, matchingComponents: matchingComponents, compsToMatch: compsToMatch, direction: direction, matchingPolicy: matchingPolicy, repeatedTimePolicy: repeatedTimePolicy, isExactMatch: &isExactMatch, isLeapDay: &isLeapDay)
12221222
} else {
@@ -1624,8 +1624,7 @@ extension Calendar {
16241624
return nil
16251625
}
16261626

1627-
let isChineseCalendar = self.identifier == .chinese
1628-
let isLeapMonthDesired = isChineseCalendar && (components.isLeapMonth ?? false)
1627+
let isLeapMonthDesired = hasRepeatingMonths && (components.isLeapMonth ?? false)
16291628

16301629
// After this point, result is at least startDate
16311630
var result = startDate
@@ -1661,7 +1660,7 @@ extension Calendar {
16611660
} while month != dateMonth
16621661
}
16631662

1664-
// As far as we know, this is only relevant for the Chinese calendar. In that calendar, the leap month has the same month number as the preceding month.
1663+
// This is relevant for the Chinese, Vietnamese, Korean, and Hindu lunisolar calendars. In those calendars, the leap month has the same month number as the preceding month.
16651664
// If we're searching forwards in time looking for a leap month, we need to skip the first occurrence we found of that month number because the first occurrence would not be the leap month; however, we only do this is if we are matching strictly. If we don't care about strict matching, we can skip this and let the caller handle it so it can deal with the approximations if necessary.
16661665
if isLeapMonthDesired && strictMatching {
16671666
// Check to see if we are already at a leap month
@@ -1956,14 +1955,27 @@ extension Calendar {
19561955
return result
19571956
}
19581957

1958+
internal func dayMatches(day: Int?, dateDay: Int,
1959+
repeatedDay: Bool?, dateRepeatedDay: Bool) -> Bool {
1960+
// the intent here is to match day if the target component's day is not nil
1961+
// and to match isRepeatedDay if the target component's isRepeated day is not nil
1962+
let dayMatch = day == nil || day == dateDay
1963+
let repeatedMatch = repeatedDay == nil || repeatedDay == dateRepeatedDay
1964+
return dayMatch && repeatedMatch
1965+
}
1966+
19591967
internal func dateAfterMatchingDay(startingAt startDate: Date, originalStartDate: Date, components comps: DateComponents, direction: SearchDirection) throws -> Date? {
1960-
guard let day = comps.day else {
1968+
let day = comps.day
1969+
let repeatedDay = comps.isRepeatedDay
1970+
1971+
guard day != nil || repeatedDay == true else {
19611972
// Nothing to do
19621973
return nil
19631974
}
19641975

19651976
var result = startDate
19661977
var dateDay = component(.day, from: result)
1978+
var dateIsRepeatedDay = _dateComponents(.isRepeatedDay, from: result).isRepeatedDay ?? false
19671979
let month = comps.month
19681980

19691981
if month != nil && direction == .backward {
@@ -1978,12 +1990,13 @@ extension Calendar {
19781990
if let anotherFoundRange = dateInterval(of: .day, for: tempSearchDate) {
19791991
result = anotherFoundRange.start
19801992
dateDay = component(.day, from: result)
1993+
dateIsRepeatedDay = _dateComponents(.isRepeatedDay, from: result).isRepeatedDay ?? false
19811994
}
19821995
}
19831996
}
19841997
}
19851998

1986-
if day != dateDay {
1999+
if !dayMatches(day: day, dateDay: dateDay, repeatedDay: repeatedDay, dateRepeatedDay: dateIsRepeatedDay) {
19872000
// The condition below keeps us from blowing past a month day by day to find a day which does not exist.
19882001
// e.g. trying to find the 30th of February starting in January would go to March 30th if we don't stop here
19892002
let originalMonth = component(.month, from: result)
@@ -2028,6 +2041,7 @@ extension Calendar {
20282041
}
20292042

20302043
dateDay = component(.day, from: tempSearchDate)
2044+
dateIsRepeatedDay = _dateComponents(.isRepeatedDay, from: tempSearchDate).isRepeatedDay ?? false
20312045
let dateMonth = component(.month, from: tempSearchDate)
20322046

20332047
try verifyAdvancingResult(tempSearchDate, previous: result, direction: direction)
@@ -2039,7 +2053,7 @@ extension Calendar {
20392053
break
20402054
}
20412055

2042-
} while day != dateDay
2056+
} while !dayMatches(day: day, dateDay: dateDay, repeatedDay: repeatedDay, dateRepeatedDay: dateIsRepeatedDay)
20432057

20442058
// If we blew past a month in its entirety, roll back by a day to the very end of the month.
20452059
if (advancedPastWholeMonth) {
@@ -2456,7 +2470,7 @@ extension Calendar.Component {
24562470
return .yearForWeekOfYear
24572471
case .quarter, .isLeapMonth, .month, .dayOfYear:
24582472
return .year
2459-
case .day, .weekOfMonth, .weekdayOrdinal:
2473+
case .day, .weekOfMonth, .weekdayOrdinal, .isRepeatedDay:
24602474
return .month
24612475
case .weekday:
24622476
return .weekOfMonth

β€ŽSources/FoundationEssentials/Calendar/Calendar_Gregorian.swift

Lines changed: 12 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -350,8 +350,9 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
350350
case .weekOfYear: 1..<53
351351
case .yearForWeekOfYear: 140742..<140743
352352
case .nanosecond: 0..<1000000000
353-
// There is no leap month in Gregorian calendar
353+
// There is no leap month or repeated day in Gregorian calendar
354354
case .isLeapMonth: 0..<1
355+
case .isRepeatedDay: 0..<1
355356
case .dayOfYear: 1..<366
356357
case .calendar, .timeZone:
357358
nil
@@ -382,6 +383,7 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
382383
case .yearForWeekOfYear: return 140742..<144684
383384
case .nanosecond: return 0..<1000000000
384385
case .isLeapMonth: return 0..<1
386+
case .isRepeatedDay: return 0..<1
385387
case .dayOfYear: return 1..<367
386388
case .calendar, .timeZone:
387389
return nil
@@ -721,6 +723,8 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
721723
return nil
722724
case .isLeapMonth:
723725
return nil
726+
case .isRepeatedDay:
727+
return nil
724728
}
725729
}
726730

@@ -868,7 +872,7 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
868872

869873
var effectiveUnit = unit
870874
switch effectiveUnit {
871-
case .calendar, .timeZone, .isLeapMonth:
875+
case .calendar, .timeZone, .isLeapMonth, .isRepeatedDay:
872876
return nil
873877
case .era:
874878
if time < -63113904000.0 {
@@ -1447,7 +1451,7 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
14471451
let time = date.timeIntervalSinceReferenceDate
14481452
var effectiveUnit = component
14491453
switch effectiveUnit {
1450-
case .calendar, .timeZone, .isLeapMonth:
1454+
case .calendar, .timeZone, .isLeapMonth, .isRepeatedDay:
14511455
return nil
14521456
case .era:
14531457
if time < -63113904000.0 {
@@ -2329,7 +2333,7 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
23292333
// TODO: This isn't supported in Calendar_ICU either. We should do it here though.
23302334
return date
23312335
// nothing to do for the below fields
2332-
case .calendar, .timeZone, .isLeapMonth:
2336+
case .calendar, .timeZone, .isLeapMonth, .isRepeatedDay:
23332337
return date
23342338
case .day, .dayOfYear, .hour, .minute, .second, .weekday, .weekdayOrdinal, .weekOfMonth, .weekOfYear, .nanosecond:
23352339
// Handle below
@@ -2376,7 +2380,7 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
23762380
nanoseconds = Double(amount) / 1_000_000_000.0
23772381
keepWallTime = false
23782382

2379-
case .era, .year, .month, .quarter, .yearForWeekOfYear, .calendar, .timeZone, .isLeapMonth:
2383+
case .era, .year, .month, .quarter, .yearForWeekOfYear, .calendar, .timeZone, .isLeapMonth, .isRepeatedDay:
23802384
preconditionFailure("Should not reach")
23812385
}
23822386

@@ -2731,7 +2735,7 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
27312735

27322736
case .nanosecond:
27332737
return date + (Double(amount) * 1.0e-9)
2734-
case .calendar, .timeZone, .isLeapMonth:
2738+
case .calendar, .timeZone, .isLeapMonth, .isRepeatedDay:
27352739
return date
27362740
}
27372741

@@ -2896,7 +2900,7 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
28962900
}
28972901

28982902
switch component {
2899-
case .calendar, .timeZone, .isLeapMonth:
2903+
case .calendar, .timeZone, .isLeapMonth, .isRepeatedDay:
29002904
preconditionFailure("Invalid arguments")
29012905

29022906
case .era:
@@ -3078,7 +3082,7 @@ internal final class _CalendarGregorian: _CalendarProtocol, @unchecked Sendable
30783082
dc.setValue(end > start ? Int(Int32.max) : Int(Int32.min), for: component)
30793083
}
30803084

3081-
case .timeZone, .isLeapMonth, .calendar:
3085+
case .timeZone, .isLeapMonth, .isRepeatedDay, .calendar:
30823086
// No leap month support needed here, since these are quantities, not values
30833087
break
30843088
case .quarter:

0 commit comments

Comments
Β (0)