Skip to content

Commit c51be2f

Browse files
authored
Limit IntegerParseStrategy's fallback Double path to lossless integer range (#667)
* Some tests about IntegerParseStrategy's big int behavior w.r.t. Double. IntegerParseStrategy currently tries to parse a Double when it fails to parse an Int64, which results in some odd behavior for magnitudes greater than 2^53. The oddities stem the from fact that the parseAsDouble(_:) method rounds its input String to the nearest Double. This commit adds some tests that fail when this happens. * Updated: NumberParseStrategyTests/testNumericBoundsParsing(). I added some UInt8 and Int64 checks and some UInt64 test w.r.t. exact Double values. * Do not round to the nearest Double when parsing integers greater than Int64. This change rejects values greater than ±2^53 in IntegerParseStrategy's Double fallback path. This limit is the upper bound of Double's lossless integer range. The parseAsDouble(_:) method rounds greater magnitudes to the nearest Double. Ideally, the parser should not have to fall back on Double but that is out-of-scope of this contribution. * Disabled UInt64 parsing tests for values in (Int64.max, UInt64.max]. See previous commits. The currently supported integer parsing range is [Int64.min, Int64.max]. * Corrects the lossless range in IntegerParseStrategy's fallback Double path. A value of ±(2^53) may not be exact since ±(2^53+1) is not a Double and becomes ±(2^53) when rounded towards zero. * Reenabled some IntegerParseStrategy tests in accordance with review comments. The test suite now checks the new behavior rather than awaiting the desired behavior via code comments.
1 parent e53bc1c commit c51be2f

File tree

2 files changed

+59
-9
lines changed

2 files changed

+59
-9
lines changed

Sources/FoundationInternationalization/Formatting/Number/IntegerParseStrategy.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,10 @@ extension IntegerParseStrategy: ParseStrategy {
4040
}
4141
return exact
4242
} 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+
}
4347
guard let exact = Format.FormatInput(exactly: v) else {
4448
throw CocoaError(CocoaError.formatting, userInfo: [
4549
NSDebugDescriptionErrorKey: "Cannot parse \(value). The number does not fall within the valid bounds of the specified output type" ])

Tests/FoundationInternationalizationTests/Formatting/NumberParseStrategyTests.swift

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -192,22 +192,68 @@ final class NumberParseStrategyTests : XCTestCase {
192192
func testNumericBoundsParsing() throws {
193193
let locale = Locale(identifier: "en_US")
194194
do {
195+
let format: IntegerFormatStyle<Int8> = .init(locale: locale)
196+
let parseStrategy = IntegerParseStrategy(format: format, lenient: true)
197+
XCTAssertEqual(try parseStrategy.parse(Int8.min.formatted(format)), Int8.min)
198+
XCTAssertEqual(try parseStrategy.parse(Int8.max.formatted(format)), Int8.max)
199+
XCTAssertThrowsError(try parseStrategy.parse("-129"))
200+
XCTAssertThrowsError(try parseStrategy.parse("128"))
201+
}
202+
203+
do {
204+
let format: IntegerFormatStyle<Int64> = .init(locale: locale)
205+
let parseStrategy = IntegerParseStrategy(format: format, lenient: true)
206+
XCTAssertEqual(try parseStrategy.parse(Int64.min.formatted(format)), Int64.min)
207+
XCTAssertEqual(try parseStrategy.parse(Int64.max.formatted(format)), Int64.max)
208+
XCTAssertThrowsError(try parseStrategy.parse("-9223372036854775809"))
209+
XCTAssertThrowsError(try parseStrategy.parse("9223372036854775808"))
210+
}
211+
212+
do {
213+
let format: IntegerFormatStyle<UInt8> = .init(locale: locale)
214+
let parseStrategy = IntegerParseStrategy(format: format, lenient: true)
215+
XCTAssertEqual(try parseStrategy.parse(UInt8.min.formatted(format)), UInt8.min)
216+
XCTAssertEqual(try parseStrategy.parse(UInt8.max.formatted(format)), UInt8.max)
217+
XCTAssertThrowsError(try parseStrategy.parse("-1"))
218+
XCTAssertThrowsError(try parseStrategy.parse("256"))
219+
}
220+
221+
do {
222+
// TODO: Parse integers greater than Int64
195223
let format: IntegerFormatStyle<UInt64> = .init(locale: locale)
196224
let parseStrategy = IntegerParseStrategy(format: format, lenient: true)
197-
XCTAssertEqual(try parseStrategy.parse(0.formatted(format)), 0)
198-
let aboveInt64Max = UInt64(Int64.max) + 1
199-
XCTAssertEqual(try parseStrategy.parse(aboveInt64Max.formatted(format)), aboveInt64Max)
225+
XCTAssertEqual( try parseStrategy.parse(UInt64.min.formatted(format)), UInt64.min)
226+
XCTAssertThrowsError(try parseStrategy.parse(UInt64.max.formatted(format)))
200227
XCTAssertThrowsError(try parseStrategy.parse("-1"))
201-
XCTAssertThrowsError(try parseStrategy.parse("-1,000,000"))
228+
XCTAssertThrowsError(try parseStrategy.parse("18446744073709551616"))
229+
230+
// TODO: Parse integers greater than Int64
231+
let maxInt64 = UInt64(Int64.max)
232+
XCTAssertEqual( try parseStrategy.parse((maxInt64 + 0).formatted(format)), maxInt64) // not a Double
233+
XCTAssertThrowsError(try parseStrategy.parse((maxInt64 + 1).formatted(format))) // exact Double
234+
XCTAssertThrowsError(try parseStrategy.parse((maxInt64 + 2).formatted(format))) // not a Double
235+
XCTAssertThrowsError(try parseStrategy.parse((maxInt64 + 3).formatted(format))) // not a Double
236+
}
237+
}
238+
239+
func testIntegerParseStrategyDoesNotRoundLargeIntegersToNearestDouble() {
240+
XCTAssertEqual(Double("9007199254740992"), Double(exactly: UInt64(1) << 53)!) // +2^53 + 0 -> +2^53
241+
XCTAssertEqual(Double("9007199254740993"), Double(exactly: UInt64(1) << 53)!) // +2^53 + 1 -> +2^53
242+
XCTAssertEqual(Double.significandBitCount, 52, "Double can represent each integer in -2^53 ... 2^53")
243+
let locale = Locale(identifier: "en_US")
244+
245+
do {
246+
let format: IntegerFormatStyle<Int64> = .init(locale: locale)
247+
let parseStrategy = IntegerParseStrategy(format: format, lenient: true)
248+
XCTAssertNotEqual(try? parseStrategy.parse("-9223372036854776832"), -9223372036854775808) // -2^63 - 1024 (Double: -2^63)
249+
XCTAssertNil( try? parseStrategy.parse("-9223372036854776833")) // -2^63 - 1025 (Double: -2^63 - 2048)
202250
}
203251

204252
do {
205-
let format: IntegerFormatStyle<Int8> = .init(locale: locale)
253+
let format: IntegerFormatStyle<UInt64> = .init(locale: locale)
206254
let parseStrategy = IntegerParseStrategy(format: format, lenient: true)
207-
XCTAssertEqual(try parseStrategy.parse(Int8.min.formatted(format)), Int8.min)
208-
XCTAssertEqual(try parseStrategy.parse(Int8.max.formatted(format)), Int8.max)
209-
XCTAssertThrowsError(try parseStrategy.parse("-129"))
210-
XCTAssertThrowsError(try parseStrategy.parse("128"))
255+
XCTAssertNotEqual(try? parseStrategy.parse( "9223372036854776832"), 9223372036854775808) // +2^63 + 1024 (Double: +2^63)
256+
XCTAssertNotEqual(try? parseStrategy.parse( "9223372036854776833"), 9223372036854777856) // +2^63 + 1025 (Double: +2^63 + 2048)
211257
}
212258
}
213259
}

0 commit comments

Comments
 (0)