From 497266fff3d2c83b1bc5648733ce0649d71360d4 Mon Sep 17 00:00:00 2001 From: Dag Brattli Date: Tue, 21 Jul 2026 17:42:25 +0200 Subject: [PATCH] refactor(python): tidy integer prefix matching and add parse regression tests Follow-up to #4825, which fixed the UTF-8 boundary panic in the Python integer parser. No behavior change. - Collapse the prefix matches in `determine_radix` and `remove_prefix` to nested or-patterns, and give `remove_prefix` an early return for radix 10. This lets the prefix arm fit on one line instead of a wrapped block. - Document why `&string[2..]` is safe at the line where the invariant lives: it is only reached after matching a two-byte ASCII prefix. - Explain what the 511 and 515 styles select in the native test, since they cover a different one of the two checked-slice sites each. - Add end-to-end regression coverage for multi-byte UTF-8 input to the F# suites for both Python and JS, which #4825 only covered natively. Cases are 2-, 3-, and 4-byte characters, verified against .NET first. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/fable-library-py/src/ints.rs | 24 ++++++++++++------------ src/fable-library-py/tests/test_ints.py | 4 ++++ tests/Js/Main/ConvertTests.fs | 6 ++++++ tests/Python/TestConvert.fs | 6 ++++++ 4 files changed, 28 insertions(+), 12 deletions(-) diff --git a/src/fable-library-py/src/ints.rs b/src/fable-library-py/src/ints.rs index eeb7b5a042..2b99939592 100644 --- a/src/fable-library-py/src/ints.rs +++ b/src/fable-library-py/src/ints.rs @@ -1067,9 +1067,9 @@ fn determine_radix(string: &str, style: i32, default_radix: i32) -> i32 { // `get` returns None when the string is shorter than two bytes or the // boundary falls inside a multi-byte character, so this never panics match string.get(..2) { - Some("0x") | Some("0X") => 16, - Some("0b") | Some("0B") => 2, - Some("0o") | Some("0O") => 8, + Some("0x" | "0X") => 16, + Some("0b" | "0B") => 2, + Some("0o" | "0O") => 8, _ => default_radix, } } @@ -1091,15 +1091,15 @@ fn trim_whitespace(string: &str, style: i32) -> &str { /// Removes numeric prefixes (0x, 0b, 0o) when radix is not decimal #[inline] fn remove_prefix(string: &str, radix: i32) -> &str { - if radix != 10 { - match string.get(..2) { - Some("0x") | Some("0X") | Some("0b") | Some("0B") | Some("0o") | Some("0O") => { - &string[2..] - } - _ => string, - } - } else { - string + if radix == 10 { + return string; + } + + // `get` never panics on a multi-byte boundary; `&string[2..]` is only + // reached after matching a two-byte ASCII prefix, so byte 2 is a boundary + match string.get(..2) { + Some("0x" | "0X" | "0b" | "0B" | "0o" | "0O") => &string[2..], + _ => string, } } diff --git a/src/fable-library-py/tests/test_ints.py b/src/fable-library-py/tests/test_ints.py index 1159cff3dd..e51fc05fe5 100644 --- a/src/fable-library-py/tests/test_ints.py +++ b/src/fable-library-py/tests/test_ints.py @@ -56,6 +56,10 @@ def test_uint64_create() -> None: assert uint64(uint32(-1)) == 4294967295 +# NumberStyles.Any (511) has no AllowHexSpecifier, so it reaches the prefix +# check in `determine_radix`. NumberStyles.HexNumber (515) short-circuits to +# radix 16 and reaches the prefix check in `remove_prefix` instead. Both used +# to panic on a multi-byte first character. @pytest.mark.parametrize( "style", [ diff --git a/tests/Js/Main/ConvertTests.fs b/tests/Js/Main/ConvertTests.fs index a1b368c55f..8047782f03 100644 --- a/tests/Js/Main/ConvertTests.fs +++ b/tests/Js/Main/ConvertTests.fs @@ -160,6 +160,8 @@ let tests = (fun () -> Int32.Parse("1FFFFFFFF", System.Globalization.NumberStyles.HexNumber)) |> throwsError "" (fun () -> Int32.Parse("5foo", System.Globalization.NumberStyles.HexNumber)) |> throwsError "" (fun () -> Int32.Parse("foo5", System.Globalization.NumberStyles.HexNumber)) |> throwsError "" + // Multi-byte UTF-8 input must fail to parse, not crash (see #4823) + (fun () -> Int32.Parse("–", System.Globalization.NumberStyles.HexNumber)) |> throwsError "" testCase "System.Int64.Parse works" <| fun () -> Int64.Parse("5") |> equal 5L @@ -207,6 +209,10 @@ let tests = tryParse Int32.TryParse 0 "X9TRE34" |> equal (false, 0) tryParse Int32.TryParse 0 "9SayWhat12Huh" |> equal (false, 0) tryParse Int32.TryParse 0 "-1" |> equal (true, -1) + // Multi-byte UTF-8 input must fail to parse, not crash (see #4823) + tryParse Int32.TryParse 0 "–" |> equal (false, 0) + tryParse Int32.TryParse 0 "é" |> equal (false, 0) + tryParse Int32.TryParse 0 "𝟙" |> equal (false, 0) testCase "BigInt.TryParse works" <| fun () -> tryParse bigint.TryParse 0I "4234523548923954" |> equal (true, 4234523548923954I) diff --git a/tests/Python/TestConvert.fs b/tests/Python/TestConvert.fs index 32b2283437..f8c2e4b542 100644 --- a/tests/Python/TestConvert.fs +++ b/tests/Python/TestConvert.fs @@ -160,6 +160,8 @@ let ``test System.Int32.Parse with hex works`` () = (fun () -> Int32.Parse("1FFFFFFFF", System.Globalization.NumberStyles.HexNumber)) |> throwsError "" (fun () -> Int32.Parse("5foo", System.Globalization.NumberStyles.HexNumber)) |> throwsError "" (fun () -> Int32.Parse("foo5", System.Globalization.NumberStyles.HexNumber)) |> throwsError "" + // Multi-byte UTF-8 input must fail to parse, not crash (see #4823) + (fun () -> Int32.Parse("–", System.Globalization.NumberStyles.HexNumber)) |> throwsError "" [] let ``test System.Int64.Parse works`` () = @@ -214,6 +216,10 @@ let ``test System.Int32.TryParse works`` () = tryParse Int32.TryParse 0 "X9TRE34" |> equal (false, 0) tryParse Int32.TryParse 0 "9SayWhat12Huh" |> equal (false, 0) tryParse Int32.TryParse 0 "-1" |> equal (true, -1) + // Multi-byte UTF-8 input must fail to parse, not crash (see #4823) + tryParse Int32.TryParse 0 "–" |> equal (false, 0) + tryParse Int32.TryParse 0 "é" |> equal (false, 0) + tryParse Int32.TryParse 0 "𝟙" |> equal (false, 0) [] let ``test BigInt.TryParse works`` () =