Skip to content

Commit e9d59b6

Browse files
authored
Fix parsing foreign currency strings (#1074)
* Fix parsing foreign currency strings Currently parsing a currency string would fail if the currency code does not match `FormatStyle`'s locale region. For example, ```swift let style = Decimal.FormatStyle.Currency(code: "GBP", locale: .init(identifier: "en_US")).presentation(.isoCode) ``` This formats 3.14 into "GBP\u{0xa0}3.14", but parsing such string fails. Fix this by always set the ICU formatter's currency code. Resolves rdar://138179737 * Update the test to throw properly instead of force unwrap * Remove another force try * Remove the stored `numberFormatType` and `locale` inside `IntegerParseStrategy` and `FloatingPointParseStrategy` These properties are redundant as the information is already available through the public variable `formatStyle`. * Address feedback * Fix a typo
1 parent 2bc9094 commit e9d59b6

File tree

10 files changed

+232
-74
lines changed

10 files changed

+232
-74
lines changed

Sources/FoundationInternationalization/Formatting/Number/Decimal+ParseStrategy.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ extension Decimal.ParseStrategy {
5454
numberFormatType = .percent(format.collection)
5555
locale = format.locale
5656
} else if let format = formatStyle as? Decimal.FormatStyle.Currency {
57-
numberFormatType = .currency(format.collection)
57+
numberFormatType = .currency(format.collection, currencyCode: format.currencyCode)
5858
locale = format.locale
5959
} else {
6060
// For some reason we've managed to accept a format style of a type that we don't own, which shouldn't happen. Fallback to the default decimal style and try anyways.

Sources/FoundationInternationalization/Formatting/Number/FloatingPointFormatStyle.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -418,23 +418,23 @@ extension FloatingPointFormatStyle {
418418
extension FloatingPointFormatStyle : CustomConsumingRegexComponent {
419419
public typealias RegexOutput = Value
420420
public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range<String.Index>) throws -> (upperBound: String.Index, output: Value)? {
421-
FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
421+
try FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
422422
}
423423
}
424424

425425
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
426426
extension FloatingPointFormatStyle.Percent : CustomConsumingRegexComponent {
427427
public typealias RegexOutput = Value
428428
public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range<String.Index>) throws -> (upperBound: String.Index, output: Value)? {
429-
FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
429+
try FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
430430
}
431431
}
432432

433433
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
434434
extension FloatingPointFormatStyle.Currency : CustomConsumingRegexComponent {
435435
public typealias RegexOutput = Value
436436
public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range<String.Index>) throws -> (upperBound: String.Index, output: Value)? {
437-
FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
437+
try FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
438438
}
439439
}
440440

Sources/FoundationInternationalization/Formatting/Number/FloatingPointParseStrategy.swift

Lines changed: 24 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ import FoundationEssentials
1818
public struct FloatingPointParseStrategy<Format> : Codable, Hashable where Format : FormatStyle, Format.FormatInput : BinaryFloatingPoint {
1919
public var formatStyle: Format
2020
public var lenient: Bool
21-
var numberFormatType: ICULegacyNumberFormatter.NumberFormatType
22-
var locale: Locale
2321
}
2422

2523
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
@@ -29,27 +27,42 @@ extension FloatingPointParseStrategy : Sendable where Format : Sendable {}
2927
extension FloatingPointParseStrategy: ParseStrategy {
3028
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
3129
public func parse(_ value: String) throws -> Format.FormatInput {
32-
guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else {
33-
throw CocoaError(CocoaError.formatting, userInfo: [
34-
NSDebugDescriptionErrorKey: "Cannot parse \(value), unable to create formatter" ])
35-
}
36-
if let v = parser.parseAsDouble(value._trimmingWhitespace()) {
37-
return Format.FormatInput(v)
38-
} else {
30+
let trimmedString = value._trimmingWhitespace()
31+
guard let result = try parse(trimmedString, startingAt: trimmedString.startIndex, in: trimmedString.startIndex..<trimmedString.endIndex) else {
3932
let exampleString = formatStyle.format(3.14)
4033
throw CocoaError(CocoaError.formatting, userInfo: [
4134
NSDebugDescriptionErrorKey: "Cannot parse \(value). String should adhere to the specified format, such as \(exampleString)" ])
4235
}
36+
return result.1
4337
}
4438

4539
// Regex component utility
46-
internal func parse(_ value: String, startingAt index: String.Index, in range: Range<String.Index>) -> (String.Index, Format.FormatInput)? {
40+
internal func parse(_ value: String, startingAt index: String.Index, in range: Range<String.Index>) throws -> (String.Index, Format.FormatInput)? {
4741
guard index < range.upperBound else {
4842
return nil
4943
}
5044

45+
let numberFormatType: ICULegacyNumberFormatter.NumberFormatType
46+
let locale: Locale
47+
48+
if let format = formatStyle as? FloatingPointFormatStyle<Format.FormatInput> {
49+
numberFormatType = .number(format.collection)
50+
locale = format.locale
51+
} else if let format = formatStyle as? FloatingPointFormatStyle<Format.FormatInput>.Percent {
52+
numberFormatType = .percent(format.collection)
53+
locale = format.locale
54+
} else if let format = formatStyle as? FloatingPointFormatStyle<Format.FormatInput>.Currency {
55+
numberFormatType = .currency(format.collection, currencyCode: format.currencyCode)
56+
locale = format.locale
57+
} else {
58+
// For some reason we've managed to accept a format style of a type that we don't own, which shouldn't happen. Fallback to the default decimal style and try anyways.
59+
numberFormatType = .number(.init())
60+
locale = .autoupdatingCurrent
61+
}
62+
5163
guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else {
52-
return nil
64+
throw CocoaError(CocoaError.formatting, userInfo: [
65+
NSDebugDescriptionErrorKey: "Cannot parse \(value), unable to create formatter" ])
5366
}
5467
let substr = value[index..<range.upperBound]
5568
var upperBound = 0
@@ -68,8 +81,6 @@ public extension FloatingPointParseStrategy {
6881
init<Value>(format: Format, lenient: Bool = true) where Format == FloatingPointFormatStyle<Value> {
6982
self.formatStyle = format
7083
self.lenient = lenient
71-
self.locale = format.locale
72-
self.numberFormatType = .number(format.collection)
7384
}
7485
}
7586

@@ -78,8 +89,6 @@ public extension FloatingPointParseStrategy {
7889
init<Value>(format: Format, lenient: Bool = true) where Format == FloatingPointFormatStyle<Value>.Currency {
7990
self.formatStyle = format
8091
self.lenient = lenient
81-
self.locale = format.locale
82-
self.numberFormatType = .currency(format.collection)
8392
}
8493
}
8594

@@ -88,7 +97,5 @@ public extension FloatingPointParseStrategy {
8897
init<Value>(format: Format, lenient: Bool = true) where Format == FloatingPointFormatStyle<Value>.Percent {
8998
self.formatStyle = format
9099
self.lenient = lenient
91-
self.locale = format.locale
92-
self.numberFormatType = .percent(format.collection)
93100
}
94101
}

Sources/FoundationInternationalization/Formatting/Number/ICULegacyNumberFormatter.swift

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ internal final class ICULegacyNumberFormatter : @unchecked Sendable {
123123
enum NumberFormatType : Hashable, Codable {
124124
case number(NumberFormatStyleConfiguration.Collection)
125125
case percent(NumberFormatStyleConfiguration.Collection)
126-
case currency(CurrencyFormatStyleConfiguration.Collection)
126+
case currency(CurrencyFormatStyleConfiguration.Collection, currencyCode: String)
127127
case descriptive(DescriptiveNumberFormatConfiguration.Collection)
128128
}
129129

@@ -143,7 +143,7 @@ internal final class ICULegacyNumberFormatter : @unchecked Sendable {
143143
}
144144
case .percent(_):
145145
icuType = .percent
146-
case .currency(let config):
146+
case .currency(let config, _):
147147
icuType = config.icuNumberFormatStyle
148148
case .descriptive(let config):
149149
icuType = config.icuNumberFormatStyle
@@ -178,12 +178,13 @@ internal final class ICULegacyNumberFormatter : @unchecked Sendable {
178178
}
179179
}
180180

181-
case .currency(let config):
181+
case .currency(let config, let currencyCode):
182182
setMultiplier(config.scale, formatter: formatter)
183183
setPrecision(config.precision, formatter: formatter)
184184
setGrouping(config.group, formatter: formatter)
185185
setDecimalSeparator(config.decimalSeparatorStrategy, formatter: formatter)
186186
setRoundingIncrement(config.roundingIncrement, formatter: formatter)
187+
try setTextAttribute(.currencyCode, formatter: formatter, value: currencyCode)
187188

188189
// Currency specific attributes
189190
if let sign = config.signDisplayStrategy {

Sources/FoundationInternationalization/Formatting/Number/IntegerFormatStyle.swift

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -563,23 +563,23 @@ extension IntegerFormatStyle {
563563
extension IntegerFormatStyle : CustomConsumingRegexComponent {
564564
public typealias RegexOutput = Value
565565
public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range<String.Index>) throws -> (upperBound: String.Index, output: Value)? {
566-
IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
566+
try IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
567567
}
568568
}
569569

570570
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
571571
extension IntegerFormatStyle.Percent : CustomConsumingRegexComponent {
572572
public typealias RegexOutput = Value
573573
public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range<String.Index>) throws -> (upperBound: String.Index, output: Value)? {
574-
IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
574+
try IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
575575
}
576576
}
577577

578578
@available(macOS 13.0, iOS 16.0, tvOS 16.0, watchOS 9.0, *)
579579
extension IntegerFormatStyle.Currency : CustomConsumingRegexComponent {
580580
public typealias RegexOutput = Value
581581
public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range<String.Index>) throws -> (upperBound: String.Index, output: Value)? {
582-
IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
582+
try IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
583583
}
584584
}
585585

Sources/FoundationInternationalization/Formatting/Number/IntegerParseStrategy.swift

Lines changed: 37 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,6 @@ import FoundationEssentials
1818
public struct IntegerParseStrategy<Format> : Codable, Hashable where Format : FormatStyle, Format.FormatInput : BinaryInteger {
1919
public var formatStyle: Format
2020
public var lenient: Bool
21-
var numberFormatType: ICULegacyNumberFormatter.NumberFormatType
22-
var locale: Locale
2321
}
2422

2523
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
@@ -28,51 +26,63 @@ extension IntegerParseStrategy : Sendable where Format : Sendable {}
2826
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
2927
extension IntegerParseStrategy: ParseStrategy {
3028
public func parse(_ value: String) throws -> Format.FormatInput {
31-
guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else {
32-
throw CocoaError(CocoaError.formatting, userInfo: [
33-
NSDebugDescriptionErrorKey: "Cannot parse \(value). Could not create parser." ])
34-
}
3529
let trimmedString = value._trimmingWhitespace()
36-
if let v = parser.parseAsInt(trimmedString) {
37-
guard let exact = Format.FormatInput(exactly: v) else {
38-
throw CocoaError(CocoaError.formatting, userInfo: [
39-
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the valid bounds of the specified output type" ])
40-
}
41-
return exact
42-
} else if let v = parser.parseAsDouble(trimmedString) {
43-
guard v.magnitude < Double(sign: .plus, exponent: Double.significandBitCount + 1, significand: 1) else {
44-
throw CocoaError(CocoaError.formatting, userInfo: [
45-
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the lossless floating-point range" ])
46-
}
47-
guard let exact = Format.FormatInput(exactly: v) else {
48-
throw CocoaError(CocoaError.formatting, userInfo: [
49-
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the valid bounds of the specified output type" ])
50-
}
51-
return exact
52-
} else {
30+
guard let result = try parse(trimmedString, startingAt: trimmedString.startIndex, in: trimmedString.startIndex..<trimmedString.endIndex) else {
5331
let exampleString = formatStyle.format(123)
5432
throw CocoaError(CocoaError.formatting, userInfo: [
5533
NSDebugDescriptionErrorKey: "Cannot parse \(value). String should adhere to the specified format, such as \(exampleString)" ])
5634
}
35+
return result.1
5736
}
5837

59-
internal func parse(_ value: String, startingAt index: String.Index, in range: Range<String.Index>) -> (String.Index, Format.FormatInput)? {
38+
internal func parse(_ value: String, startingAt index: String.Index, in range: Range<String.Index>) throws -> (String.Index, Format.FormatInput)? {
6039
guard index < range.upperBound else {
6140
return nil
6241
}
6342

43+
let numberFormatType: ICULegacyNumberFormatter.NumberFormatType
44+
let locale: Locale
45+
46+
if let format = formatStyle as? IntegerFormatStyle<Format.FormatInput> {
47+
numberFormatType = .number(format.collection)
48+
locale = format.locale
49+
} else if let format = formatStyle as? IntegerFormatStyle<Format.FormatInput>.Percent {
50+
numberFormatType = .percent(format.collection)
51+
locale = format.locale
52+
} else if let format = formatStyle as? IntegerFormatStyle<Format.FormatInput>.Currency {
53+
numberFormatType = .currency(format.collection, currencyCode: format.currencyCode)
54+
locale = format.locale
55+
} else {
56+
// For some reason we've managed to accept a format style of a type that we don't own, which shouldn't happen. Fallback to the default decimal style and try anyways.
57+
numberFormatType = .number(.init())
58+
locale = .autoupdatingCurrent
59+
}
60+
6461
guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else {
6562
return nil
6663
}
6764
let substr = value[index..<range.upperBound]
6865
var upperBound = 0
6966
if let value = parser.parseAsInt(substr, upperBound: &upperBound) {
67+
guard let exact = Format.FormatInput(exactly: value) else {
68+
throw CocoaError(CocoaError.formatting, userInfo: [
69+
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the valid bounds of the specified output type" ])
70+
}
7071
let upperBoundInSubstr = String.Index(utf16Offset: upperBound, in: substr)
71-
return (upperBoundInSubstr, Format.FormatInput(value))
72-
} else if let value = parser.parseAsInt(substr, upperBound: &upperBound) {
72+
return (upperBoundInSubstr, exact)
73+
} else if let value = parser.parseAsDouble(substr, upperBound: &upperBound) {
74+
guard value.magnitude < Double(sign: .plus, exponent: Double.significandBitCount + 1, significand: 1) else {
75+
throw CocoaError(CocoaError.formatting, userInfo: [
76+
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the lossless floating-point range" ])
77+
}
78+
guard let exact = Format.FormatInput(exactly: value) else {
79+
throw CocoaError(CocoaError.formatting, userInfo: [
80+
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the valid bounds of the specified output type" ])
81+
}
7382
let upperBoundInSubstr = String.Index(utf16Offset: upperBound, in: substr)
74-
return (upperBoundInSubstr, Format.FormatInput(clamping: Int64(value)))
83+
return (upperBoundInSubstr, exact)
7584
}
85+
7686
return nil
7787
}
7888
}
@@ -82,8 +92,6 @@ public extension IntegerParseStrategy {
8292
init<Value>(format: Format, lenient: Bool = true) where Format == IntegerFormatStyle<Value> {
8393
self.formatStyle = format
8494
self.lenient = lenient
85-
self.locale = format.locale
86-
self.numberFormatType = .number(format.collection)
8795
}
8896
}
8997

@@ -92,8 +100,6 @@ public extension IntegerParseStrategy {
92100
init<Value>(format: Format, lenient: Bool = true) where Format == IntegerFormatStyle<Value>.Percent {
93101
self.formatStyle = format
94102
self.lenient = lenient
95-
self.locale = format.locale
96-
self.numberFormatType = .percent(format.collection)
97103
}
98104
}
99105

@@ -102,7 +108,5 @@ public extension IntegerParseStrategy {
102108
init<Value>(format: Format, lenient: Bool = true) where Format == IntegerFormatStyle<Value>.Currency {
103109
self.formatStyle = format
104110
self.lenient = lenient
105-
self.locale = format.locale
106-
self.numberFormatType = .currency(format.collection)
107111
}
108112
}

Sources/FoundationInternationalization/ICU/ICU+Enums.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ extension UNumberFormatAttribute {
109109

110110
extension UNumberFormatTextAttribute {
111111
static let defaultRuleSet = UNUM_DEFAULT_RULESET
112+
static let currencyCode = UNUM_CURRENCY_CODE
112113
}
113114

114115
extension UDateRelativeDateTimeFormatterStyle {

Sources/TestSupport/TestSupport.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,8 @@ public typealias FloatingPointFormatStyle = Foundation.FloatingPointFormatStyle
4949
public typealias NumberFormatStyleConfiguration = Foundation.NumberFormatStyleConfiguration
5050
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
5151
public typealias CurrencyFormatStyleConfiguration = Foundation.CurrencyFormatStyleConfiguration
52+
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
53+
public typealias IntegerParseStrategy = Foundation.IntegerParseStrategy
5254

5355
@available(FoundationPreview 0.4, *)
5456
public typealias DiscreteFormatStyle = Foundation.DiscreteFormatStyle
@@ -147,6 +149,8 @@ public typealias FloatingPointFormatStyle = FoundationInternationalization.Float
147149
public typealias NumberFormatStyleConfiguration = FoundationInternationalization.NumberFormatStyleConfiguration
148150
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
149151
public typealias CurrencyFormatStyleConfiguration = FoundationInternationalization.CurrencyFormatStyleConfiguration
152+
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
153+
public typealias IntegerParseStrategy = FoundationInternationalization.IntegerParseStrategy
150154

151155
@available(FoundationPreview 0.4, *)
152156
public typealias DiscreteFormatStyle = FoundationEssentials.DiscreteFormatStyle

0 commit comments

Comments
 (0)