Skip to content

Commit 10091ea

Browse files
committed
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
1 parent 2eeb1b0 commit 10091ea

File tree

7 files changed

+126
-20
lines changed

7 files changed

+126
-20
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, 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/FloatingPointParseStrategy.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ public extension FloatingPointParseStrategy {
7979
self.formatStyle = format
8080
self.lenient = lenient
8181
self.locale = format.locale
82-
self.numberFormatType = .currency(format.collection)
82+
self.numberFormatType = .currency(format.collection, format.currencyCode)
8383
}
8484
}
8585

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, /*currency code*/ 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, let _):
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/IntegerParseStrategy.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,6 @@ public extension IntegerParseStrategy {
103103
self.formatStyle = format
104104
self.lenient = lenient
105105
self.locale = format.locale
106-
self.numberFormatType = .currency(format.collection)
106+
self.numberFormatType = .currency(format.collection, format.currencyCode)
107107
}
108108
}

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 {

Tests/FoundationInternationalizationTests/Formatting/NumberFormatStyleTests.swift

Lines changed: 56 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -595,6 +595,39 @@ final class NumberFormatStyleTests: XCTestCase {
595595
XCTAssertEqual((-922337203685477 as Decimal).formatted(baseStyle.rounded(rule: .awayFromZero, increment: 100)), "-$1000T")
596596
}
597597

598+
func testCurrency_Codable() throws {
599+
let gbpInUS = Decimal.FormatStyle.Currency(code: "GBP", locale: enUSLocale)
600+
let encoded = try JSONEncoder().encode(gbpInUS)
601+
let json_gbp = String(data: encoded, encoding: String.Encoding.utf8)!
602+
print(json_gbp)
603+
604+
let previouslyEncoded = """
605+
{
606+
"collection":
607+
{
608+
"presentation":
609+
{
610+
"option": 1
611+
}
612+
},
613+
"currencyCode": "GBP",
614+
"locale":
615+
{
616+
"current": 0,
617+
"identifier": "en_US"
618+
}
619+
}
620+
""".data(using: .utf8)
621+
622+
guard let previouslyEncoded else {
623+
XCTFail()
624+
return
625+
}
626+
627+
let decoded = try JSONDecoder().decode(Decimal.FormatStyle.Currency, from: previouslyEncoded)
628+
XCTAssertEqual(decoded, gbpInUS)
629+
}
630+
598631
func testCurrency_scientific() throws {
599632
let baseStyle = Decimal.FormatStyle.Currency(code: "USD", locale: Locale(identifier: "en_US")).notation(.scientific)
600633

@@ -1905,23 +1938,33 @@ final class FormatStylePatternMatchingTests : XCTestCase {
19051938
_verifyMatching("€52,249", formatStyle: floatStyle, expectedUpperBound: nil, expectedValue: nil)
19061939
_verifyMatching("€52,249", formatStyle: decimalStyle, expectedUpperBound: nil, expectedValue: nil)
19071940

1908-
// Different locale
19091941
let frenchStyle: IntegerFormatStyle<Int>.Currency = .init(code: "EUR", locale: frFR)
1910-
1911-
let frenchPrice = "57 379 €"
1912-
_verifyMatching(frenchPrice, formatStyle: frenchStyle, expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379)
1913-
_verifyMatching(frenchPrice, formatStyle: floatStyle.locale(frFR), expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379)
1914-
_verifyMatching(frenchPrice, formatStyle: decimalStyle.locale(frFR), expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379)
1915-
1916-
_verifyMatching("\(frenchPrice) semble beaucoup", formatStyle: frenchStyle, expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379)
1917-
_verifyMatching("\(frenchPrice) semble beaucoup", formatStyle: floatStyle.locale(frFR), expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379)
1918-
_verifyMatching("\(frenchPrice) semble beaucoup", formatStyle: decimalStyle.locale(frFR), expectedUpperBound: frenchPrice.endIndex, expectedValue: 57379)
1919-
1942+
let frenchPrice = frenchStyle.format(57379)
1943+
XCTAssertEqual(frenchPrice, "57 379,00 €")
1944+
_verifyMatching("57 379,00 €", formatStyle: frenchStyle, expectedUpperBound: "57 379,00 €".endIndex, expectedValue: 57379)
1945+
_verifyMatching("57 379 €", formatStyle: frenchStyle, expectedUpperBound: "57 379 €".endIndex, expectedValue: 57379)
1946+
_verifyMatching("57 379,00 € semble beaucoup", formatStyle: frenchStyle, expectedUpperBound: "57 379,00 €".endIndex, expectedValue: 57379)
1947+
1948+
// Does not match when matching with USD style
1949+
_verifyMatching("57 379,00 €", formatStyle: floatStyle.locale(frFR), expectedUpperBound: nil, expectedValue: nil)
1950+
_verifyMatching("57 379,00 €", formatStyle: decimalStyle.locale(frFR), expectedUpperBound: nil, expectedValue: nil)
1951+
1952+
// Mix currency and locale
1953+
_verifyMatching("57 379,00 $US", formatStyle: floatStyle.locale(frFR), expectedUpperBound: "57 379,00 $US".endIndex, expectedValue: 57379)
1954+
_verifyMatching("57 379,00 $US", formatStyle: decimalStyle.locale(frFR), expectedUpperBound: "57 379,00 $US".endIndex, expectedValue: 57379)
1955+
_verifyMatching("57 379,00 $US semble beaucoup", formatStyle: floatStyle.locale(frFR), expectedUpperBound: "57 379,00 $US".endIndex, expectedValue: 57379)
1956+
_verifyMatching("57 379,00 $US semble beaucoup", formatStyle: decimalStyle.locale(frFR), expectedUpperBound: "57 379,00 $US".endIndex, expectedValue: 57379)
1957+
1958+
// Range tests
19201959
let newFrenchStr = "<remplir le blanc> coûte \(frenchPrice)"
19211960
let frenchMatchRange = newFrenchStr.firstIndex(of: "5")! ..< newFrenchStr.endIndex
19221961
_verifyMatching(newFrenchStr, formatStyle: frenchStyle, range: frenchMatchRange, expectedUpperBound: newFrenchStr.endIndex, expectedValue: 57379)
1923-
_verifyMatching(newFrenchStr, formatStyle: floatStyle.locale(frFR), range: frenchMatchRange, expectedUpperBound: newFrenchStr.endIndex, expectedValue: 57379)
1924-
_verifyMatching(newFrenchStr, formatStyle: decimalStyle.locale(frFR), range: frenchMatchRange, expectedUpperBound: newFrenchStr.endIndex, expectedValue: 57379)
1962+
1963+
// Mix currency and locale range tests
1964+
let newFrenchUSDStr = "<remplir le blanc> coûte 57 379,00 $US"
1965+
let usdPriceRange = newFrenchUSDStr.firstIndex(of: "5")! ..< newFrenchUSDStr.endIndex
1966+
_verifyMatching(newFrenchUSDStr, formatStyle: floatStyle.locale(frFR), range: usdPriceRange, expectedUpperBound: newFrenchUSDStr.endIndex, expectedValue: 57379)
1967+
_verifyMatching(newFrenchUSDStr, formatStyle: decimalStyle.locale(frFR), range: usdPriceRange, expectedUpperBound: newFrenchUSDStr.endIndex, expectedValue: 57379)
19251968

19261969
// Sign tests
19271970
let signTests = [

Tests/FoundationInternationalizationTests/Formatting/NumberParseStrategyTests.swift

Lines changed: 62 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,67 @@ final class NumberParseStrategyTests : XCTestCase {
157157
_verifyRoundtripCurrency(negativeData, currencyStyle.decimalSeparator(strategy: .always), "currency style, decimal display: always")
158158
}
159159

160+
func testParseCurrencyWithDifferentCodes() throws {
161+
let enUS = Locale(identifier: "en_US")
162+
// Decimal
163+
let style = Decimal.FormatStyle.Currency(code: "GBP", locale: enUS).presentation(.isoCode)
164+
XCTAssertEqual(style.format(3.14), "GBP 3.14")
165+
166+
let parsed = try style.parseStrategy.parse("GBP 3.14")
167+
XCTAssertEqual(parsed, 3.14)
168+
169+
// Floating point
170+
let floatingPointStyle: FloatingPointFormatStyle<Double>.Currency = .init(code: "GBP", locale: enUS).presentation(.isoCode)
171+
XCTAssertEqual(floatingPointStyle.format(3.14), "GBP 3.14")
172+
173+
let parsedFloatingPoint = try floatingPointStyle.parseStrategy.parse("GBP 3.14")
174+
XCTAssertEqual(parsedFloatingPoint, 3.14)
175+
176+
// Integer
177+
let integerStyle: IntegerFormatStyle<Int32>.Currency = .init(code: "GBP", locale: enUS).presentation(.isoCode)
178+
XCTAssertEqual(integerStyle.format(32), "GBP 32.00")
179+
180+
let parsedInt = try integerStyle.parseStrategy.parse("GBP 32.00")
181+
XCTAssertEqual(parsedInt, 32)
182+
}
183+
184+
func test_roundtripForeignCurrency() {
185+
let testData: [Int] = [
186+
87650000, 8765000, 876500, 87650, 8765, 876, 87, 8, 0
187+
]
188+
let negativeData: [Int] = [
189+
-87650000, -8765000, -876500, -87650, -8765, -876, -87, -8
190+
]
191+
192+
func _verifyRoundtripCurrency(_ testData: [Int], _ style: IntegerFormatStyle<Int>.Currency, _ testName: String = "", file: StaticString = #filePath, line: UInt = #line) {
193+
for value in testData {
194+
let str = style.format(value)
195+
let parsed = try! Int(str, strategy: style.parseStrategy)
196+
XCTAssertEqual(value, parsed, "\(testName): formatted string: \(str) parsed: \(parsed)", file: file, line: line)
197+
198+
let nonLenientParsed = try! Int(str, format: style, lenient: false)
199+
XCTAssertEqual(value, nonLenientParsed, file: file, line: line)
200+
}
201+
}
202+
203+
let currencyStyle: IntegerFormatStyle<Int>.Currency = .init(code: "EUR", locale: Locale(identifier: "en_US"))
204+
_verifyRoundtripCurrency(testData, currencyStyle, "currency style")
205+
_verifyRoundtripCurrency(testData, currencyStyle.sign(strategy: .always()), "currency style, sign: always")
206+
_verifyRoundtripCurrency(testData, currencyStyle.grouping(.never), "currency style, grouping: never")
207+
_verifyRoundtripCurrency(testData, currencyStyle.presentation(.isoCode), "currency style, presentation: iso code")
208+
_verifyRoundtripCurrency(testData, currencyStyle.presentation(.fullName), "currency style, presentation: iso code")
209+
_verifyRoundtripCurrency(testData, currencyStyle.presentation(.narrow), "currency style, presentation: iso code")
210+
_verifyRoundtripCurrency(testData, currencyStyle.decimalSeparator(strategy: .always), "currency style, decimal display: always")
211+
212+
_verifyRoundtripCurrency(negativeData, currencyStyle, "currency style")
213+
_verifyRoundtripCurrency(negativeData, currencyStyle.sign(strategy: .accountingAlways()), "currency style, sign: always")
214+
_verifyRoundtripCurrency(negativeData, currencyStyle.grouping(.never), "currency style, grouping: never")
215+
_verifyRoundtripCurrency(negativeData, currencyStyle.presentation(.isoCode), "currency style, presentation: iso code")
216+
_verifyRoundtripCurrency(negativeData, currencyStyle.presentation(.fullName), "currency style, presentation: iso code")
217+
_verifyRoundtripCurrency(negativeData, currencyStyle.presentation(.narrow), "currency style, presentation: iso code")
218+
_verifyRoundtripCurrency(negativeData, currencyStyle.decimalSeparator(strategy: .always), "currency style, decimal display: always")
219+
}
220+
160221
let testNegativePositiveDecimalData: [Decimal] = [ Decimal(string:"87650")!, Decimal(string:"8765")!,
161222
Decimal(string:"876.5")!, Decimal(string:"87.65")!, Decimal(string:"8.765")!, Decimal(string:"0.8765")!, Decimal(string:"0.08765")!, Decimal(string:"0.008765")!, Decimal(string:"0")!, Decimal(string:"-0.008765")!, Decimal(string:"-876.5")!, Decimal(string:"-87650")! ]
162223

@@ -188,7 +249,7 @@ final class NumberParseStrategyTests : XCTestCase {
188249
XCTAssertEqual(try! strategy.parse("-1,234.56 US dollars"), Decimal(string: "-1234.56")!)
189250
XCTAssertEqual(try! strategy.parse("-USD\u{00A0}1,234.56"), Decimal(string: "-1234.56")!)
190251
}
191-
252+
192253
func testNumericBoundsParsing() throws {
193254
let locale = Locale(identifier: "en_US")
194255
do {

0 commit comments

Comments
 (0)