From 7df2463e09280b4aca4d0a710c23af3835ecb6ac Mon Sep 17 00:00:00 2001 From: Tony Parker Date: Wed, 6 Dec 2023 13:14:18 -0800 Subject: [PATCH] Fix up remaining issues where we cache a `Locale` for format styles, and an autoupdating locale will not cause a refreshed format. Follows up on 18ac5ac (#334) Fixes rdar://119010483 --- .../Formatting/ByteCountFormatStyle.swift | 2 +- .../Date/Date+IntervalFormatStyle.swift | 21 ++------ .../Date/Date+RelativeFormatStyle.swift | 2 +- .../Date/ICUDateIntervalFormatter.swift | 54 +++++++++++-------- .../Date/ICURelativeDateFormatter.swift | 25 +++++---- .../Formatting/ICUListFormatter.swift | 26 +++++---- .../Formatting/ListFormatStyle.swift | 2 +- .../Number/Decimal+ParseStrategy.swift | 2 +- .../Number/FloatingPointParseStrategy.swift | 4 +- .../Number/ICULegacyNumberFormatter.swift | 16 +++--- .../Number/IntegerParseStrategy.swift | 4 +- .../DateIntervalFormatStyleTests.swift | 23 ++++++++ .../DateRelativeFormatStyleTests.swift | 24 +++++++++ .../Formatting/ListFormatStyleTests.swift | 33 ++++++++++++ 14 files changed, 164 insertions(+), 74 deletions(-) diff --git a/Sources/FoundationInternationalization/Formatting/ByteCountFormatStyle.swift b/Sources/FoundationInternationalization/Formatting/ByteCountFormatStyle.swift index 14eebb8b6..296d49119 100644 --- a/Sources/FoundationInternationalization/Formatting/ByteCountFormatStyle.swift +++ b/Sources/FoundationInternationalization/Formatting/ByteCountFormatStyle.swift @@ -166,7 +166,7 @@ public struct ByteCountFormatStyle: FormatStyle, Sendable { } let configuration = DescriptiveNumberFormatConfiguration.Collection(presentation: .cardinal, capitalizationContext: .beginningOfSentence) - let spellOutFormatter = ICULegacyNumberFormatter.numberFormatterCreateIfNeeded(type: .descriptive(configuration), locale: locale) + let spellOutFormatter = ICULegacyNumberFormatter.formatter(for: .descriptive(configuration), locale: locale) guard let zeroFormatted = spellOutFormatter.format(Int64.zero) else { return attributedFormat diff --git a/Sources/FoundationInternationalization/Formatting/Date/Date+IntervalFormatStyle.swift b/Sources/FoundationInternationalization/Formatting/Date/Date+IntervalFormatStyle.swift index 75d078a8f..9c5bbfec3 100644 --- a/Sources/FoundationInternationalization/Formatting/Date/Date+IntervalFormatStyle.swift +++ b/Sources/FoundationInternationalization/Formatting/Date/Date+IntervalFormatStyle.swift @@ -26,6 +26,9 @@ extension Date { public var timeZone: TimeZone public var calendar: Calendar + // Internal + internal var symbols = Date.FormatStyle.DateFieldCollection() + /// Creates a new `FormatStyle` with the given configurations. /// - Parameters: /// - date: The style for formatting the date part of the given date pairs. Note that if `.omitted` is specified, but the date interval spans more than one day, a locale-specific fallback will be used. @@ -53,19 +56,7 @@ extension Date { // MARK: - FormatStyle conformance public func format(_ v: Range) -> String { - let formatter = Self.cache.formatter(for: self) { - var template = symbols.formatterTemplate(overridingDayPeriodWithLocale: locale) - - if template.isEmpty { - let defaultSymbols = Date.FormatStyle.DateFieldCollection() - .collection(date: .numeric) - .collection(time: .shortened) - template = defaultSymbols.formatterTemplate(overridingDayPeriodWithLocale: locale) - } - - return ICUDateIntervalFormatter(locale: locale, calendar: calendar, timeZone: timeZone, dateTemplate: template) - } - return formatter.string(from: v) + ICUDateIntervalFormatter.formatter(for: self).string(from: v) } public func locale(_ locale: Locale) -> Self { @@ -73,10 +64,6 @@ extension Date { new.locale = locale return new } - - // Internal - private var symbols = Date.FormatStyle.DateFieldCollection() - private static let cache = FormatterCache() } } diff --git a/Sources/FoundationInternationalization/Formatting/Date/Date+RelativeFormatStyle.swift b/Sources/FoundationInternationalization/Formatting/Date/Date+RelativeFormatStyle.swift index c4acfc726..ce38cfa3c 100644 --- a/Sources/FoundationInternationalization/Formatting/Date/Date+RelativeFormatStyle.swift +++ b/Sources/FoundationInternationalization/Formatting/Date/Date+RelativeFormatStyle.swift @@ -132,7 +132,7 @@ extension Date { } let (component, value) = _largestNonZeroComponent(destDate, reference: refDate, adjustComponent: strategy) - return ICURelativeDateFormatter.formatterCreateIfNeeded(format: self).format(value: value, component: component, presentation: self.presentation)! + return ICURelativeDateFormatter.formatter(for: self).format(value: value, component: component, presentation: self.presentation)! } diff --git a/Sources/FoundationInternationalization/Formatting/Date/ICUDateIntervalFormatter.swift b/Sources/FoundationInternationalization/Formatting/Date/ICUDateIntervalFormatter.swift index 6978bbd44..15d09e3aa 100644 --- a/Sources/FoundationInternationalization/Formatting/Date/ICUDateIntervalFormatter.swift +++ b/Sources/FoundationInternationalization/Formatting/Date/ICUDateIntervalFormatter.swift @@ -20,26 +20,25 @@ import FoundationEssentials package import FoundationICU #endif -final class ICUDateIntervalFormatter : Hashable { - let locale: Locale - let calendar: Calendar - let timeZone: TimeZone - let dateTemplate: String +final class ICUDateIntervalFormatter { + struct Signature : Hashable { + let localeComponents: Locale.Components + let calendarIdentifier: Calendar.Identifier + let timeZoneIdentifier: String + let dateTemplate: String + } + + internal static let cache = FormatterCache() let uformatter: OpaquePointer // UDateIntervalFormat - init(locale: Locale, calendar: Calendar, timeZone: TimeZone, dateTemplate: String) { - self.locale = locale - self.calendar = calendar - self.timeZone = timeZone - self.dateTemplate = dateTemplate - - var comps = Locale.Components(locale: locale) - comps.calendar = calendar.identifier + private init(signature: Signature) { + var comps = signature.localeComponents + comps.calendar = signature.calendarIdentifier let id = comps.icuIdentifier - let tz16 = Array(timeZone.identifier.utf16) - let dateTemplate16 = Array(dateTemplate.utf16) + let tz16 = Array(signature.timeZoneIdentifier.utf16) + let dateTemplate16 = Array(signature.dateTemplate.utf16) var status = U_ZERO_ERROR uformatter = tz16.withUnsafeBufferPointer { tz in @@ -71,14 +70,23 @@ final class ICUDateIntervalFormatter : Hashable { return "" } - static func == (lhs: ICUDateIntervalFormatter, rhs: ICUDateIntervalFormatter) -> Bool { - lhs.locale == rhs.locale && lhs.calendar == rhs.calendar && lhs.timeZone == rhs.timeZone && lhs.dateTemplate == rhs.dateTemplate - } + internal static func formatter(for style: Date.IntervalFormatStyle) -> ICUDateIntervalFormatter { + var template = style.symbols.formatterTemplate(overridingDayPeriodWithLocale: style.locale) - func hash(into hasher: inout Hasher) { - hasher.combine(locale) - hasher.combine(calendar) - hasher.combine(timeZone) - hasher.combine(dateTemplate) + if template.isEmpty { + let defaultSymbols = Date.FormatStyle.DateFieldCollection() + .collection(date: .numeric) + .collection(time: .shortened) + template = defaultSymbols.formatterTemplate(overridingDayPeriodWithLocale: style.locale) + } + + // This captures all of the special preferences that may be set on the locale + let comps = Locale.Components(locale: style.locale) + let signature = Signature(localeComponents: comps, calendarIdentifier: style.calendar.identifier, timeZoneIdentifier: style.timeZone.identifier, dateTemplate: template) + + let formatter = Self.cache.formatter(for: signature) { + ICUDateIntervalFormatter(signature: signature) + } + return formatter } } diff --git a/Sources/FoundationInternationalization/Formatting/Date/ICURelativeDateFormatter.swift b/Sources/FoundationInternationalization/Formatting/Date/ICURelativeDateFormatter.swift index 841688960..9b7499d46 100644 --- a/Sources/FoundationInternationalization/Formatting/Date/ICURelativeDateFormatter.swift +++ b/Sources/FoundationInternationalization/Formatting/Date/ICURelativeDateFormatter.swift @@ -22,7 +22,13 @@ package import FoundationICU @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) internal final class ICURelativeDateFormatter { - + struct Signature : Hashable { + let localeIdentifier: String + let numberFormatStyle: UNumberFormatStyle.RawValue? + let relativeDateStyle: UDateRelativeDateTimeFormatterStyle.RawValue + let context: UDisplayContext.RawValue + } + static let sortedAllowedComponents : [Calendar.Component] = [ .year, .month, .weekOfMonth, .day, .hour, .minute, .second ] static let componentsToURelativeDateUnit : [Calendar.Component: URelativeDateTimeUnit] = [ @@ -37,20 +43,20 @@ internal final class ICURelativeDateFormatter { let uformatter: OpaquePointer - internal static let cache = FormatterCache() + internal static let cache = FormatterCache() - private init?(uNumberFormatStyle: UNumberFormatStyle?, uRelDateStyle: UDateRelativeDateTimeFormatterStyle, locale: Locale, context: UDisplayContext) { + private init?(signature: Signature) { var status = U_ZERO_ERROR let numberFormat: UnsafeMutablePointer? - if let uNumberFormatStyle { + if let numberFormatStyle = signature.numberFormatStyle { // The uformatter takes ownership of this after we pass it to the open call below - numberFormat = unum_open(uNumberFormatStyle, nil, 0, locale.identifier, nil, &status) + numberFormat = unum_open(UNumberFormatStyle(rawValue: numberFormatStyle), nil, 0, signature.localeIdentifier, nil, &status) // If status is not a success, simply use nil } else { numberFormat = nil } - let result = ureldatefmt_open(locale.identifier, numberFormat, uRelDateStyle, context, &status) + let result = ureldatefmt_open(signature.localeIdentifier, numberFormat, UDateRelativeDateTimeFormatterStyle(rawValue: signature.relativeDateStyle), UDisplayContext(rawValue: signature.context), &status) guard let result, status.isSuccess else { return nil } uformatter = result } @@ -73,9 +79,10 @@ internal final class ICURelativeDateFormatter { } } - internal static func formatterCreateIfNeeded(format: Date.RelativeFormatStyle) -> ICURelativeDateFormatter { - let formatter = Self.cache.formatter(for: format) { - ICURelativeDateFormatter(uNumberFormatStyle: format.unitsStyle.icuNumberFormatStyle, uRelDateStyle: format.unitsStyle.icuRelativeDateStyle, locale: format.locale, context: format.capitalizationContext.icuContext) + internal static func formatter(for style: Date.RelativeFormatStyle) -> ICURelativeDateFormatter { + let signature = Signature(localeIdentifier: style.locale.identifier, numberFormatStyle: style.unitsStyle.icuNumberFormatStyle?.rawValue, relativeDateStyle: style.unitsStyle.icuRelativeDateStyle.rawValue, context: style.capitalizationContext.icuContext.rawValue) + let formatter = Self.cache.formatter(for: signature) { + ICURelativeDateFormatter(signature: signature) } return formatter! diff --git a/Sources/FoundationInternationalization/Formatting/ICUListFormatter.swift b/Sources/FoundationInternationalization/Formatting/ICUListFormatter.swift index 6a06250fd..b4a170822 100644 --- a/Sources/FoundationInternationalization/Formatting/ICUListFormatter.swift +++ b/Sources/FoundationInternationalization/Formatting/ICUListFormatter.swift @@ -24,14 +24,22 @@ package import FoundationICU internal final class ICUListFormatter { let uformatter: OpaquePointer - internal static let cache = FormatterCache() + struct Signature : Hashable { + let localeIdentifier: String + let listType: Int // format.listType.rawValue + let width: Int // format.width.rawValue + } - static let uListFormatterTypes: [UListFormatterType] = [ .and, .or, .units ] - static let uListFormatterWidths: [UListFormatterWidth] = [ .wide, .short, .narrow ] + internal static let cache = FormatterCache() - private init(locale: Locale, type: UListFormatterType, width: UListFormatterWidth) { + private init(signature: Signature) { var status = U_ZERO_ERROR - let result = ulistfmt_openForType(locale.identifier, type, width, &status) + let uListFormatterTypes: [UListFormatterType] = [ .and, .or, .units ] + let uListFormatterWidths: [UListFormatterWidth] = [ .wide, .short, .narrow ] + + let type = uListFormatterTypes[signature.listType] + let width = uListFormatterWidths[signature.width] + let result = ulistfmt_openForType(signature.localeIdentifier, type, width, &status) guard let result, status.isSuccess else { preconditionFailure("Unable to create list formatter: \(status.rawValue)") } @@ -68,11 +76,11 @@ internal final class ICUListFormatter { return result ?? "" } - internal static func formatterCreateIfNeeded(format: ListFormatStyle) -> ICUListFormatter { - let formatter = Self.cache.formatter(for: format) { - ICUListFormatter(locale: format.locale, type: uListFormatterTypes[format.listType.rawValue], width: uListFormatterWidths[format.width.rawValue]) + internal static func formatter(for style: ListFormatStyle) -> ICUListFormatter { + let signature = Signature(localeIdentifier: style.locale.identifier, listType: style.listType.rawValue, width: style.width.rawValue) + let formatter = Self.cache.formatter(for: signature) { + ICUListFormatter(signature: signature) } return formatter } - } diff --git a/Sources/FoundationInternationalization/Formatting/ListFormatStyle.swift b/Sources/FoundationInternationalization/Formatting/ListFormatStyle.swift index bdd8dbeb3..dcfbcc81d 100644 --- a/Sources/FoundationInternationalization/Formatting/ListFormatStyle.swift +++ b/Sources/FoundationInternationalization/Formatting/ListFormatStyle.swift @@ -29,7 +29,7 @@ public struct ListFormatStyle: FormatStyle w } public func format(_ value: Base) -> String { - let formatter = ICUListFormatter.formatterCreateIfNeeded(format: self) + let formatter = ICUListFormatter.formatter(for: self) return formatter.format(strings: value.map(memberStyle.format(_:))) } diff --git a/Sources/FoundationInternationalization/Formatting/Number/Decimal+ParseStrategy.swift b/Sources/FoundationInternationalization/Formatting/Number/Decimal+ParseStrategy.swift index aa4ce0a54..7c066a960 100644 --- a/Sources/FoundationInternationalization/Formatting/Number/Decimal+ParseStrategy.swift +++ b/Sources/FoundationInternationalization/Formatting/Number/Decimal+ParseStrategy.swift @@ -62,7 +62,7 @@ extension Decimal.ParseStrategy { locale = .autoupdatingCurrent } - let parser = ICULegacyNumberFormatter.numberFormatterCreateIfNeeded(type: numberFormatType, locale: locale, lenient: lenient) + let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) let substr = value[index.. Format.FormatInput { - let parser = ICULegacyNumberFormatter.numberFormatterCreateIfNeeded(type: numberFormatType, locale: locale, lenient: lenient) + let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) if let v = parser.parseAsDouble(value._trimmingWhitespace()) { return Format.FormatInput(v) } else { @@ -51,7 +51,7 @@ extension FloatingPointParseStrategy: ParseStrategy { return nil } - let parser = ICULegacyNumberFormatter.numberFormatterCreateIfNeeded(type: numberFormatType, locale: locale, lenient: lenient) + let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) let substr = value[index.. - private init(type: UNumberFormatStyle, locale: Locale) throws { + private init(type: UNumberFormatStyle, localeIdentifier: String) throws { var status = U_ZERO_ERROR - let result = unum_open(type, nil, 0, locale.identifier, nil, &status) + let result = unum_open(type, nil, 0, localeIdentifier, nil, &status) guard let result else { throw ICUError(code: U_UNSUPPORTED_ERROR) } try status.checkSuccess() uformatter = result @@ -232,9 +232,9 @@ internal final class ICULegacyNumberFormatter { case descriptive(DescriptiveNumberFormatConfiguration.Collection) } - private struct CacheSignature : Hashable { + private struct Signature : Hashable { let type: NumberFormatType - let locale: Locale + let localeIdentifier: String let lenient: Bool func createNumberFormatter() -> ICULegacyNumberFormatter { @@ -254,7 +254,7 @@ internal final class ICULegacyNumberFormatter { icuType = config.icuNumberFormatStyle } - let formatter = try! ICULegacyNumberFormatter(type: icuType, locale: locale) + let formatter = try! ICULegacyNumberFormatter(type: icuType, localeIdentifier: localeIdentifier) formatter.setAttribute(.lenientParse, value: lenient) switch type { @@ -317,10 +317,10 @@ internal final class ICULegacyNumberFormatter { } } - private static let cache = FormatterCache() + private static let cache = FormatterCache() // lenient is only used for parsing - static func numberFormatterCreateIfNeeded(type: NumberFormatType, locale: Locale, lenient: Bool = false) -> ICULegacyNumberFormatter { - let sig = CacheSignature(type: type, locale: locale, lenient: lenient) + static func formatter(for type: NumberFormatType, locale: Locale, lenient: Bool = false) -> ICULegacyNumberFormatter { + let sig = Signature(type: type, localeIdentifier: locale.identifier, lenient: lenient) let formatter = ICULegacyNumberFormatter.cache.formatter(for: sig, creator: sig.createNumberFormatter) return formatter diff --git a/Sources/FoundationInternationalization/Formatting/Number/IntegerParseStrategy.swift b/Sources/FoundationInternationalization/Formatting/Number/IntegerParseStrategy.swift index 14eb8004c..a5d0ded20 100644 --- a/Sources/FoundationInternationalization/Formatting/Number/IntegerParseStrategy.swift +++ b/Sources/FoundationInternationalization/Formatting/Number/IntegerParseStrategy.swift @@ -28,7 +28,7 @@ extension IntegerParseStrategy : Sendable where Format : Sendable {} @available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *) extension IntegerParseStrategy: ParseStrategy { public func parse(_ value: String) throws -> Format.FormatInput { - let parser = ICULegacyNumberFormatter.numberFormatterCreateIfNeeded(type: numberFormatType, locale: locale, lenient: lenient) + let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) let trimmedString = value._trimmingWhitespace() if let v = parser.parseAsInt(trimmedString) { return Format.FormatInput(v) @@ -52,7 +52,7 @@ extension IntegerParseStrategy: ParseStrategy { return nil } - let parser = ICULegacyNumberFormatter.numberFormatterCreateIfNeeded(type: numberFormatType, locale: locale, lenient: lenient) + let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) let substr = value[index..