Skip to content

Fix remaining FormatStyle caches of autoupdating locales #342

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -53,30 +56,14 @@ extension Date {
// MARK: - FormatStyle conformance

public func format(_ v: Range<Date>) -> 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 {
var new = self
new.locale = locale
return new
}

// Internal
private var symbols = Date.FormatStyle.DateFieldCollection()
private static let cache = FormatterCache<Self, ICUDateIntervalFormatter>()
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)!
}


Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<Signature, ICUDateIntervalFormatter>()

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
Expand Down Expand Up @@ -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
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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] = [
Expand All @@ -37,20 +43,20 @@ internal final class ICURelativeDateFormatter {

let uformatter: OpaquePointer

internal static let cache = FormatterCache<AnyHashable, ICURelativeDateFormatter?>()
internal static let cache = FormatterCache<Signature, ICURelativeDateFormatter?>()

private init?(uNumberFormatStyle: UNumberFormatStyle?, uRelDateStyle: UDateRelativeDateTimeFormatterStyle, locale: Locale, context: UDisplayContext) {
private init?(signature: Signature) {
var status = U_ZERO_ERROR
let numberFormat: UnsafeMutablePointer<UNumberFormat?>?
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
}
Expand All @@ -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)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be the preference-capturing locale identifier to account for, say, custom first weekday? I imagine that information would be useful for calculating relative date in terms of weeks

Copy link
Contributor Author

@parkera parkera Dec 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hm, that may be another bug. I'll file one to check if ICURelativeDateFormatter should be using preference-capturing locale.

let formatter = Self.cache.formatter(for: signature) {
ICURelativeDateFormatter(signature: signature)
}

return formatter!
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,14 +24,22 @@ package import FoundationICU
internal final class ICUListFormatter {
let uformatter: OpaquePointer

internal static let cache = FormatterCache<AnyHashable, ICUListFormatter>()
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<Signature, ICUListFormatter>()

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)")
}
Expand Down Expand Up @@ -68,11 +76,11 @@ internal final class ICUListFormatter {
return result ?? ""
}

internal static func formatterCreateIfNeeded<Style, Base>(format: ListFormatStyle<Style, Base>) -> 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<Style, Base>(for style: ListFormatStyle<Style, Base>) -> 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
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ public struct ListFormatStyle<Style: FormatStyle, Base: Sequence>: 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(_:)))
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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..<range.upperBound]
var upperBound = 0
guard let value = parser.parseAsDecimal(substr, upperBound: &upperBound) else {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ extension FloatingPointParseStrategy : Sendable where Format : Sendable {}
extension FloatingPointParseStrategy: ParseStrategy {
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
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)
if let v = parser.parseAsDouble(value._trimmingWhitespace()) {
return Format.FormatInput(v)
} else {
Expand All @@ -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..<range.upperBound]
var upperBound = 0
if let value = parser.parseAsDouble(substr, upperBound: &upperBound) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ internal final class ICULegacyNumberFormatter {

let uformatter: UnsafeMutablePointer<UNumberFormat?>

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
Expand Down Expand Up @@ -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 {
Expand All @@ -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 {
Expand Down Expand Up @@ -317,10 +317,10 @@ internal final class ICULegacyNumberFormatter {
}
}

private static let cache = FormatterCache<CacheSignature, ICULegacyNumberFormatter>()
private static let cache = FormatterCache<Signature, ICULegacyNumberFormatter>()
// 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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..<range.upperBound]
var upperBound = 0
if let value = parser.parseAsInt(substr, upperBound: &upperBound) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,27 @@ final class DateIntervalFormatStyleTests: XCTestCase {
}
}

func testAutoupdatingCurrentChangesFormatResults() {
let locale = Locale.autoupdatingCurrent
let range = Date.now..<(Date.now + 3600)

// Get a formatted result from es-ES
var prefs = LocalePreferences()
prefs.languages = ["es-ES"]
prefs.locale = "es_ES"
LocaleCache.cache.resetCurrent(to: prefs)
let formattedSpanish = range.formatted(.interval.locale(locale))

// Get a formatted result from en-US
prefs.languages = ["en-US"]
prefs.locale = "en_US"
LocaleCache.cache.resetCurrent(to: prefs)
let formattedEnglish = range.formatted(.interval.locale(locale))

// Reset to current preferences before any possibility of failing this test
LocaleCache.cache.reset()

// No matter what 'current' was before this test was run, formattedSpanish and formattedEnglish should be different.
XCTAssertNotEqual(formattedSpanish, formattedEnglish)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -303,4 +303,28 @@ final class DateRelativeFormatStyleTests: XCTestCase {
_verifyStyle(725759999.0, relativeTo: 645019200.0, expected: "in 2 years")

}

func testAutoupdatingCurrentChangesFormatResults() {
let locale = Locale.autoupdatingCurrent
let date = Date.now + 3600

// Get a formatted result from es-ES
var prefs = LocalePreferences()
prefs.languages = ["es-ES"]
prefs.locale = "es_ES"
LocaleCache.cache.resetCurrent(to: prefs)
let formattedSpanish = date.formatted(.relative(presentation: .named).locale(locale))

// Get a formatted result from en-US
prefs.languages = ["en-US"]
prefs.locale = "en_US"
LocaleCache.cache.resetCurrent(to: prefs)
let formattedEnglish = date.formatted(.relative(presentation: .named).locale(locale))

// Reset to current preferences before any possibility of failing this test
LocaleCache.cache.reset()

// No matter what 'current' was before this test was run, formattedSpanish and formattedEnglish should be different.
XCTAssertNotEqual(formattedSpanish, formattedEnglish)
}
}
Loading