Skip to content
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

Fix parsing foreign currency strings #1074

Open
wants to merge 6 commits into
base: main
Choose a base branch
from
Open
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 @@ -54,7 +54,7 @@ extension Decimal.ParseStrategy {
numberFormatType = .percent(format.collection)
locale = format.locale
} else if let format = formatStyle as? Decimal.FormatStyle.Currency {
numberFormatType = .currency(format.collection)
numberFormatType = .currency(format.collection, currencyCode: format.currencyCode)
locale = format.locale
} else {
// 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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -418,23 +418,23 @@ extension FloatingPointFormatStyle {
extension FloatingPointFormatStyle : CustomConsumingRegexComponent {
public typealias RegexOutput = Value
public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range<String.Index>) throws -> (upperBound: String.Index, output: Value)? {
FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
try FloatingPointParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
}
}

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ import FoundationEssentials
public struct FloatingPointParseStrategy<Format> : Codable, Hashable where Format : FormatStyle, Format.FormatInput : BinaryFloatingPoint {
public var formatStyle: Format
public var lenient: Bool
var numberFormatType: ICULegacyNumberFormatter.NumberFormatType
var locale: Locale
}

@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
Expand All @@ -29,27 +27,42 @@ 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 {
guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else {
throw CocoaError(CocoaError.formatting, userInfo: [
NSDebugDescriptionErrorKey: "Cannot parse \(value), unable to create formatter" ])
}
if let v = parser.parseAsDouble(value._trimmingWhitespace()) {
return Format.FormatInput(v)
} else {
let trimmedString = value._trimmingWhitespace()
guard let result = try parse(trimmedString, startingAt: trimmedString.startIndex, in: trimmedString.startIndex..<trimmedString.endIndex) else {
let exampleString = formatStyle.format(3.14)
throw CocoaError(CocoaError.formatting, userInfo: [
NSDebugDescriptionErrorKey: "Cannot parse \(value). String should adhere to the specified format, such as \(exampleString)" ])
}
return result.1
}

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

let numberFormatType: ICULegacyNumberFormatter.NumberFormatType
let locale: Locale

if let format = formatStyle as? FloatingPointFormatStyle<Format.FormatInput> {
numberFormatType = .number(format.collection)
locale = format.locale
} else if let format = formatStyle as? FloatingPointFormatStyle<Format.FormatInput>.Percent {
numberFormatType = .percent(format.collection)
locale = format.locale
} else if let format = formatStyle as? FloatingPointFormatStyle<Format.FormatInput>.Currency {
numberFormatType = .currency(format.collection, currencyCode: format.currencyCode)
locale = format.locale
} else {
// 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.
numberFormatType = .number(.init())
locale = .autoupdatingCurrent
}

guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else {
return nil
throw CocoaError(CocoaError.formatting, userInfo: [
NSDebugDescriptionErrorKey: "Cannot parse \(value), unable to create formatter" ])
}
let substr = value[index..<range.upperBound]
var upperBound = 0
Expand All @@ -68,8 +81,6 @@ public extension FloatingPointParseStrategy {
init<Value>(format: Format, lenient: Bool = true) where Format == FloatingPointFormatStyle<Value> {
self.formatStyle = format
self.lenient = lenient
self.locale = format.locale
self.numberFormatType = .number(format.collection)
}
}

Expand All @@ -78,8 +89,6 @@ public extension FloatingPointParseStrategy {
init<Value>(format: Format, lenient: Bool = true) where Format == FloatingPointFormatStyle<Value>.Currency {
self.formatStyle = format
self.lenient = lenient
self.locale = format.locale
self.numberFormatType = .currency(format.collection)
}
}

Expand All @@ -88,7 +97,5 @@ public extension FloatingPointParseStrategy {
init<Value>(format: Format, lenient: Bool = true) where Format == FloatingPointFormatStyle<Value>.Percent {
self.formatStyle = format
self.lenient = lenient
self.locale = format.locale
self.numberFormatType = .percent(format.collection)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ internal final class ICULegacyNumberFormatter : @unchecked Sendable {
enum NumberFormatType : Hashable, Codable {
case number(NumberFormatStyleConfiguration.Collection)
case percent(NumberFormatStyleConfiguration.Collection)
case currency(CurrencyFormatStyleConfiguration.Collection)
case currency(CurrencyFormatStyleConfiguration.Collection, currencyCode: String)
case descriptive(DescriptiveNumberFormatConfiguration.Collection)
}

Expand All @@ -143,7 +143,7 @@ internal final class ICULegacyNumberFormatter : @unchecked Sendable {
}
case .percent(_):
icuType = .percent
case .currency(let config):
case .currency(let config, _):
icuType = config.icuNumberFormatStyle
case .descriptive(let config):
icuType = config.icuNumberFormatStyle
Expand Down Expand Up @@ -178,12 +178,13 @@ internal final class ICULegacyNumberFormatter : @unchecked Sendable {
}
}

case .currency(let config):
case .currency(let config, let currencyCode):
setMultiplier(config.scale, formatter: formatter)
setPrecision(config.precision, formatter: formatter)
setGrouping(config.group, formatter: formatter)
setDecimalSeparator(config.decimalSeparatorStrategy, formatter: formatter)
setRoundingIncrement(config.roundingIncrement, formatter: formatter)
try setTextAttribute(.currencyCode, formatter: formatter, value: currencyCode)

// Currency specific attributes
if let sign = config.signDisplayStrategy {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -563,23 +563,23 @@ extension IntegerFormatStyle {
extension IntegerFormatStyle : CustomConsumingRegexComponent {
public typealias RegexOutput = Value
public func consuming(_ input: String, startingAt index: String.Index, in bounds: Range<String.Index>) throws -> (upperBound: String.Index, output: Value)? {
IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
try IntegerParseStrategy(format: self, lenient: false).parse(input, startingAt: index, in: bounds)
}
}

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

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

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,8 +18,6 @@ import FoundationEssentials
public struct IntegerParseStrategy<Format> : Codable, Hashable where Format : FormatStyle, Format.FormatInput : BinaryInteger {
public var formatStyle: Format
public var lenient: Bool
var numberFormatType: ICULegacyNumberFormatter.NumberFormatType
var locale: Locale
}

@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
Expand All @@ -28,51 +26,63 @@ 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 {
guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else {
throw CocoaError(CocoaError.formatting, userInfo: [
NSDebugDescriptionErrorKey: "Cannot parse \(value). Could not create parser." ])
}
let trimmedString = value._trimmingWhitespace()
if let v = parser.parseAsInt(trimmedString) {
guard let exact = Format.FormatInput(exactly: v) else {
throw CocoaError(CocoaError.formatting, userInfo: [
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the valid bounds of the specified output type" ])
}
return exact
} else if let v = parser.parseAsDouble(trimmedString) {
guard v.magnitude < Double(sign: .plus, exponent: Double.significandBitCount + 1, significand: 1) else {
throw CocoaError(CocoaError.formatting, userInfo: [
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the lossless floating-point range" ])
}
guard let exact = Format.FormatInput(exactly: v) else {
throw CocoaError(CocoaError.formatting, userInfo: [
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the valid bounds of the specified output type" ])
}
return exact
} else {
guard let result = try parse(trimmedString, startingAt: trimmedString.startIndex, in: trimmedString.startIndex..<trimmedString.endIndex) else {
let exampleString = formatStyle.format(123)
throw CocoaError(CocoaError.formatting, userInfo: [
NSDebugDescriptionErrorKey: "Cannot parse \(value). String should adhere to the specified format, such as \(exampleString)" ])
}
return result.1
}

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

let numberFormatType: ICULegacyNumberFormatter.NumberFormatType
let locale: Locale

if let format = formatStyle as? IntegerFormatStyle<Format.FormatInput> {
numberFormatType = .number(format.collection)
locale = format.locale
} else if let format = formatStyle as? IntegerFormatStyle<Format.FormatInput>.Percent {
numberFormatType = .percent(format.collection)
locale = format.locale
} else if let format = formatStyle as? IntegerFormatStyle<Format.FormatInput>.Currency {
numberFormatType = .currency(format.collection, currencyCode: format.currencyCode)
locale = format.locale
} else {
// 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.
numberFormatType = .number(.init())
locale = .autoupdatingCurrent
}

guard let parser = ICULegacyNumberFormatter.formatter(for: numberFormatType, locale: locale, lenient: lenient) else {
return nil
}
let substr = value[index..<range.upperBound]
var upperBound = 0
if let value = parser.parseAsInt(substr, upperBound: &upperBound) {
guard let exact = Format.FormatInput(exactly: value) else {
throw CocoaError(CocoaError.formatting, userInfo: [
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the valid bounds of the specified output type" ])
}
let upperBoundInSubstr = String.Index(utf16Offset: upperBound, in: substr)
return (upperBoundInSubstr, Format.FormatInput(value))
} else if let value = parser.parseAsInt(substr, upperBound: &upperBound) {
return (upperBoundInSubstr, exact)
} else if let value = parser.parseAsDouble(substr, upperBound: &upperBound) {
guard value.magnitude < Double(sign: .plus, exponent: Double.significandBitCount + 1, significand: 1) else {
throw CocoaError(CocoaError.formatting, userInfo: [
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the lossless floating-point range" ])
}
guard let exact = Format.FormatInput(exactly: value) else {
throw CocoaError(CocoaError.formatting, userInfo: [
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the valid bounds of the specified output type" ])
}
let upperBoundInSubstr = String.Index(utf16Offset: upperBound, in: substr)
return (upperBoundInSubstr, Format.FormatInput(clamping: Int64(value)))
return (upperBoundInSubstr, exact)
}

return nil
}
}
Expand All @@ -82,8 +92,6 @@ public extension IntegerParseStrategy {
init<Value>(format: Format, lenient: Bool = true) where Format == IntegerFormatStyle<Value> {
self.formatStyle = format
self.lenient = lenient
self.locale = format.locale
self.numberFormatType = .number(format.collection)
}
}

Expand All @@ -92,8 +100,6 @@ public extension IntegerParseStrategy {
init<Value>(format: Format, lenient: Bool = true) where Format == IntegerFormatStyle<Value>.Percent {
self.formatStyle = format
self.lenient = lenient
self.locale = format.locale
self.numberFormatType = .percent(format.collection)
}
}

Expand All @@ -102,7 +108,5 @@ public extension IntegerParseStrategy {
init<Value>(format: Format, lenient: Bool = true) where Format == IntegerFormatStyle<Value>.Currency {
self.formatStyle = format
self.lenient = lenient
self.locale = format.locale
self.numberFormatType = .currency(format.collection)
}
}
1 change: 1 addition & 0 deletions Sources/FoundationInternationalization/ICU/ICU+Enums.swift
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ extension UNumberFormatAttribute {

extension UNumberFormatTextAttribute {
static let defaultRuleSet = UNUM_DEFAULT_RULESET
static let currencyCode = UNUM_CURRENCY_CODE
}

extension UDateRelativeDateTimeFormatterStyle {
Expand Down
4 changes: 4 additions & 0 deletions Sources/TestSupport/TestSupport.swift
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ public typealias FloatingPointFormatStyle = Foundation.FloatingPointFormatStyle
public typealias NumberFormatStyleConfiguration = Foundation.NumberFormatStyleConfiguration
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
public typealias CurrencyFormatStyleConfiguration = Foundation.CurrencyFormatStyleConfiguration
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
public typealias IntegerParseStrategy = Foundation.IntegerParseStrategy

@available(FoundationPreview 0.4, *)
public typealias DiscreteFormatStyle = Foundation.DiscreteFormatStyle
Expand Down Expand Up @@ -147,6 +149,8 @@ public typealias FloatingPointFormatStyle = FoundationInternationalization.Float
public typealias NumberFormatStyleConfiguration = FoundationInternationalization.NumberFormatStyleConfiguration
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
public typealias CurrencyFormatStyleConfiguration = FoundationInternationalization.CurrencyFormatStyleConfiguration
@available(macOS 12.0, iOS 15.0, tvOS 15.0, watchOS 8.0, *)
public typealias IntegerParseStrategy = FoundationInternationalization.IntegerParseStrategy

@available(FoundationPreview 0.4, *)
public typealias DiscreteFormatStyle = FoundationEssentials.DiscreteFormatStyle
Expand Down
Loading