Skip to content
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
37 changes: 33 additions & 4 deletions src/fable-library-py/src/ints.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1112,13 +1112,37 @@ fn create_parse_error(string: &str) -> PyErr {
))
}

/// Splits an optional leading `-` from the rest of the string
///
/// FSharp.Core consumes the sign before looking for a `0x`/`0o`/`0b`
/// specifier, so `-0x11` is -17. Only `-` is split: `+` is left in place for
/// the underlying parser, which matches `int "+0x11"` being a format error on
/// .NET while `int "+11"` is not.
#[inline]
fn split_sign(string: &str) -> (&str, &str) {
match string.strip_prefix('-') {
Some(digits) => ("-", digits),
None => ("", string),
}
}

/// Preprocesses a numeric string for parsing by handling radix detection,
/// whitespace trimming, prefix removal, and underscore cleanup
fn preprocess_numeric_string(string: &str, style: i32, default_radix: i32) -> (String, i32) {
let actual_radix = determine_radix(string, style, default_radix);
// Trim and split the sign *before* detecting the radix: the prefix follows
// both, so inspecting the raw string would miss " 0x11" and "-0x11", which
// are 17 and -17 on .NET
let trimmed = trim_whitespace(string, style);
let without_prefix = remove_prefix(trimmed, actual_radix);
let final_string = without_prefix.replace('_', "");
let (sign, unsigned) = split_sign(trimmed);
let actual_radix = determine_radix(unsigned, style, default_radix);
let digits = remove_prefix(unsigned, actual_radix).replace('_', "");

// Avoid re-allocating for the common unsigned case
let final_string = if sign.is_empty() {
digits
} else {
format!("{sign}{digits}")
};

(final_string, actual_radix)
}
Expand Down Expand Up @@ -1275,11 +1299,16 @@ pub fn parse_int64(
let (final_string, actual_radix) = preprocess_numeric_string(string, style, radix);

// Parse the integer - handle large hex values by parsing as u64 first for non-decimal
let v = if actual_radix != 10 {
let v = if actual_radix != 10 && !final_string.starts_with('-') {
// For non-decimal, parse as u64 first to handle large hex values
u64::from_str_radix(&final_string, actual_radix as u32)
.map(|u_val| u_val as i64) // Cast performs automatic two's complement conversion
.map_err(|_| create_parse_error(string))?
} else if actual_radix != 10 {
// A negative non-decimal literal such as "-0x11" is a plain signed
// value, not a two's complement bit pattern, so u64 cannot parse it
i64::from_str_radix(&final_string, actual_radix as u32)
.map_err(|_| create_parse_error(string))?
} else {
// For decimal, use standard i64 parsing
i64::from_str_radix(&final_string, actual_radix as u32)
Expand Down
52 changes: 52 additions & 0 deletions src/fable-library-py/tests/test_ints.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,58 @@ def test_int32_try_parse_multibyte_unicode_returns_false(style: int) -> None:
assert result.contents == 0


# NumberStyles.Any (511) is what the compiler emits for `int "..."`. Leading
# whitespace and the sign both precede the radix specifier, so they have to be
# stripped before the 0x/0o/0b prefix is looked for. Expectations verified
# against .NET.
@pytest.mark.parametrize(
("text", "expected"),
[
("0x11", 17),
(" 0x11", 17),
("0x11 ", 17),
(" 0o11 ", 9),
(" 0b11 ", 3),
("-0x11", -17),
(" -0x11 ", -17),
(" -0b11 ", -3),
("-1_1", -11),
("+11", 11),
("-11", -11),
],
)
def test_int32_parse_prefix_after_whitespace_and_sign(text: str, expected: int) -> None:
assert int32.parse(text, 511) == expected


@pytest.mark.parametrize(
("text", "expected"),
[
("-0x11", -17),
(" -0x11 ", -17),
("-0b11", -3),
("0x11", 17),
],
)
def test_int64_parse_signed_non_decimal(text: str, expected: int) -> None:
# A negative non-decimal literal is a plain signed value, not a two's
# complement bit pattern, so it cannot go through the u64 fast path
assert int64.parse(text, 511) == expected


def test_int64_parse_large_hex_is_twos_complement() -> None:
# The u64 fast path must still apply to unsigned values that set the high bit
assert int64.parse("FFFFFFFFFFFFFFFF", 515) == -1
assert int64.parse("7FFFFFFFFFFFFFFF", 515) == 9223372036854775807


@pytest.mark.parametrize("text", ["+0x11", "0x", "-", "foo", "–"])
def test_int32_parse_rejects_invalid(text: str) -> None:
# `int "+0x11"` is a format error on .NET: only `-` precedes the specifier
with pytest.raises(ValueError):
int32.parse(text, 511)


def test_byte_add() -> None:
assert byte(42) + byte(42) == 84
assert byte(255) + byte(1) == 0
Expand Down
7 changes: 7 additions & 0 deletions tests/Js/Main/ConvertTests.fs
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,13 @@ let tests =
equal 17 (int "0x11")
equal 9 (int "0o11")
equal 3 (int "0b11")
// Leading whitespace and the sign both precede the radix specifier
equal 17 (int " 0x11")
equal 9 (int " 0o11 ")
equal -17 (int "-0x11")
equal -3 (int " -0b11 ")
equal -17L (int64 "-0x11")
equal -11 (int "-1_1")

testCase "System.Int32.TryParse works" <| fun () ->
tryParse Int32.TryParse 0 "1" |> equal (true, 1)
Expand Down
7 changes: 7 additions & 0 deletions tests/Python/TestConvert.fs
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,13 @@ let ``test Parsing integers with different radices works`` () =
equal 17 (int "0x11")
equal 9 (int "0o11")
equal 3 (int "0b11")
// Leading whitespace and the sign both precede the radix specifier
equal 17 (int " 0x11")
equal 9 (int " 0o11 ")
equal -17 (int "-0x11")
equal -3 (int " -0b11 ")
equal -17L (int64 "-0x11")
equal -11 (int "-1_1")

[<Fact>]
let ``test System.Int32.TryParse works`` () =
Expand Down
Loading