From 4841e200ed081d7685f1110e33bbb2024dd443be Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Fri, 25 Sep 2026 13:06:28 -0700 Subject: [PATCH] Allow whitespace after leading numeric signs Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../src/System/Number.Parsing.Common.cs | 6 ++--- .../src/System/Globalization/NumberStyles.cs | 6 ++--- .../src/System/Number.Parsing.cs | 15 +++++++++++ .../tests/BigInteger/parse.cs | 14 ++++++++++ .../System/DecimalTests.cs | 4 +++ .../System/DoubleTests.cs | 11 ++++++++ .../System.Runtime.Tests/System/Int32Tests.cs | 27 +++++++++++++++++++ 7 files changed, 77 insertions(+), 6 deletions(-) diff --git a/src/libraries/Common/src/System/Number.Parsing.Common.cs b/src/libraries/Common/src/System/Number.Parsing.Common.cs index e0fbdd9d5a7e6d..347a9a79589b7f 100644 --- a/src/libraries/Common/src/System/Number.Parsing.Common.cs +++ b/src/libraries/Common/src/System/Number.Parsing.Common.cs @@ -61,9 +61,9 @@ private static bool TryParseNumber(ReadOnlySpan value, NumberStyle while (true) { - // Eat whitespace unless we've found a sign which isn't followed by a currency symbol. - // "-Kr 1231.47" is legal but "- 1231.47" is not. - if (!IsWhite(ch) || (styles & NumberStyles.AllowLeadingWhite) == 0 || ((state & StateSign) != 0 && (state & StateCurrency) == 0 && info.NumberNegativePattern != 2)) + // Eat leading whitespace. This mirrors the trailing loop below so that whitespace is + // allowed after a leading sign or opening parenthesis (e.g. "- 1231.47" and "( 1)"). + if (!IsWhite(ch) || (styles & NumberStyles.AllowLeadingWhite) == 0) { int nextIndex; diff --git a/src/libraries/System.Private.CoreLib/src/System/Globalization/NumberStyles.cs b/src/libraries/System.Private.CoreLib/src/System/Globalization/NumberStyles.cs index d6303420c03f5a..c80870ae8d4953 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Globalization/NumberStyles.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Globalization/NumberStyles.cs @@ -13,9 +13,9 @@ public enum NumberStyles None = 0x00000000, /// - /// Bit flag indicating that leading whitespace is allowed. Character values - /// 0x0009, 0x000A, 0x000B, 0x000C, 0x000D, and 0x0020 are considered to be - /// whitespace. + /// Indicates that whitespace is allowed before the number, including after a permitted + /// leading sign or opening parenthesis. Character values 0x0009, 0x000A, 0x000B, 0x000C, + /// 0x000D, and 0x0020 are considered to be whitespace. /// AllowLeadingWhite = 0x00000001, diff --git a/src/libraries/System.Private.CoreLib/src/System/Number.Parsing.cs b/src/libraries/System.Private.CoreLib/src/System/Number.Parsing.cs index b2897a791f9816..bb3eae35defd84 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Number.Parsing.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Number.Parsing.cs @@ -529,6 +529,12 @@ internal static ParsingStatus TryParseBinaryIntegerStyle(ReadOn } goto HasTrailingChars; } + + // Leading whitespace was already consumed, so whitespace here can only follow a sign. + if ((styles & NumberStyles.AllowLeadingWhite) != 0 && IsWhite(num)) + { + return TryParseBinaryIntegerNumber(value, styles, info, out result, out elementsConsumed); + } goto FalseExit; DoneAtEndButPotentialOverflow: @@ -1252,6 +1258,15 @@ internal static bool TryParseHexFloatingPoint(ReadOnlySpan } } + // Skip whitespace between the sign and the "0x" prefix, mirroring TryParseNumber. + if ((styles & NumberStyles.AllowLeadingWhite) != 0) + { + while (index < value.Length && IsWhite(TChar.CastToUInt32(value[index]))) + { + index++; + } + } + if (index >= value.Length) { elementsConsumed = 0; diff --git a/src/libraries/System.Runtime.Numerics/tests/BigInteger/parse.cs b/src/libraries/System.Runtime.Numerics/tests/BigInteger/parse.cs index beb59cbb2acf24..de235cdfc44e71 100644 --- a/src/libraries/System.Runtime.Numerics/tests/BigInteger/parse.cs +++ b/src/libraries/System.Runtime.Numerics/tests/BigInteger/parse.cs @@ -137,6 +137,20 @@ public static void Parse_EmptySubspan_Fails() Assert.Equal(0, result); } + [Fact] + public static void Parse_WhitespaceAfterLeadingSign() + { + NumberStyles style = NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite | NumberStyles.AllowLeadingSign; + CultureInfo inv = CultureInfo.InvariantCulture; + + Assert.Equal(new BigInteger(-123), BigInteger.Parse("- 123", style, inv)); + Assert.Equal(new BigInteger(123), BigInteger.Parse("+ 123", style, inv)); + Assert.Equal(new BigInteger(-123), BigInteger.Parse(" - 123 ", style, inv)); + + // Without AllowLeadingWhite the interior whitespace must still be rejected. + Assert.Throws(() => BigInteger.Parse("- 123", NumberStyles.AllowLeadingSign, inv)); + } + [Fact] public static void ParseUtf8_EmptySubspan_Fails() { diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DecimalTests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DecimalTests.cs index 7ec7379b99461b..6f62b83a624a52 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DecimalTests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DecimalTests.cs @@ -864,6 +864,10 @@ public static IEnumerable Parse_Valid_TestData() yield return new object[] { "0", defaultStyle, null, 0m }; yield return new object[] { "123", defaultStyle, null, 123m }; yield return new object[] { " 123 ", defaultStyle, null, 123m }; + + // Whitespace between a leading sign and the digits (mirrors trailing sign + AllowTrailingWhite) + yield return new object[] { "- 123", NumberStyles.AllowLeadingWhite | NumberStyles.AllowLeadingSign, invariantFormat, -123m }; + yield return new object[] { " - 123 ", NumberStyles.Number, invariantFormat, -123m }; yield return new object[] { (567.89m).ToString(), defaultStyle, null, 567.89m }; yield return new object[] { (-567.89m).ToString(), defaultStyle, null, -567.89m }; yield return new object[] { "0.6666666666666666666666666666500000000000000000000000000000000000000000000000000000000000000", defaultStyle, invariantFormat, 0.6666666666666666666666666666m }; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DoubleTests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DoubleTests.cs index 966b93b0a9726d..f5aab28f657121 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DoubleTests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DoubleTests.cs @@ -282,6 +282,10 @@ public static IEnumerable Parse_Valid_TestData() yield return new object[] { "0", defaultStyle, null, 0.0 }; yield return new object[] { "123", defaultStyle, null, 123.0 }; yield return new object[] { " 123 ", defaultStyle, null, 123.0 }; + + // Whitespace between a leading sign and the digits (mirrors trailing sign + AllowTrailingWhite) + yield return new object[] { "- 123", NumberStyles.AllowLeadingWhite | NumberStyles.AllowLeadingSign, invariantFormat, -123.0 }; + yield return new object[] { " - 123 ", NumberStyles.Float, invariantFormat, -123.0 }; yield return new object[] { (567.89).ToString(), defaultStyle, null, 567.89 }; yield return new object[] { (-567.89).ToString(), defaultStyle, null, -567.89 }; yield return new object[] { "1E23", defaultStyle, null, 1E23 }; @@ -468,6 +472,11 @@ public static IEnumerable Parse_Valid_TestData() yield return new object[] { "0x1p0", NumberStyles.HexFloat, invariantFormat, 1.0 }; yield return new object[] { "0x100p0", NumberStyles.HexFloat, invariantFormat, 256.0 }; + // Whitespace between a leading sign and the "0x" prefix (AllowLeadingWhite) + yield return new object[] { "- 0x1p0", NumberStyles.HexFloat, invariantFormat, -1.0 }; + yield return new object[] { " - 0x1p0 ", NumberStyles.HexFloat, invariantFormat, -1.0 }; + yield return new object[] { "+ 0x1p0", NumberStyles.HexFloat, invariantFormat, 1.0 }; + // Large significand (many hex digits) yield return new object[] { "0xFFFFFFFFFFFFFFp0", NumberStyles.HexFloat, invariantFormat, (double)0xFFFFFFFFFFFFFF }; yield return new object[] { "0x1.0000000000000p0", NumberStyles.HexFloat, invariantFormat, 1.0 }; @@ -2017,6 +2026,7 @@ public static IEnumerable TryParsePartial_TestData() // With signs yield return new object[] { "+123.45abc", NumberStyles.Float, CultureInfo.InvariantCulture, 123.45, 7 }; yield return new object[] { "-456.78xyz", NumberStyles.Float, CultureInfo.InvariantCulture, -456.78, 7 }; + yield return new object[] { " - 123.5xyz", NumberStyles.Float, CultureInfo.InvariantCulture, -123.5, 10 }; // With exponent yield return new object[] { "1.23e10abc", NumberStyles.Float, CultureInfo.InvariantCulture, 1.23e10, 7 }; @@ -2046,6 +2056,7 @@ public static IEnumerable TryParsePartial_TestData() yield return new object[] { "0x1.8p0xyz", NumberStyles.HexFloat, CultureInfo.InvariantCulture, 1.5, 7 }; yield return new object[] { "0x1.0p10!!", NumberStyles.HexFloat, CultureInfo.InvariantCulture, 1024.0, 8 }; yield return new object[] { "0x1.8p0 x", NumberStyles.HexFloat, CultureInfo.InvariantCulture, 1.5, 9 }; + yield return new object[] { "- 0x1.8p0xyz", NumberStyles.HexFloat, CultureInfo.InvariantCulture, -1.5, 9 }; // Hex-float special values (Infinity/NaN) with trailing invalid characters yield return new object[] { "Infinityxyz", NumberStyles.HexFloat, CultureInfo.InvariantCulture, double.PositiveInfinity, 8 }; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Int32Tests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Int32Tests.cs index bad6ffd6a4c4fb..a14003e1f90bed 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Int32Tests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Int32Tests.cs @@ -351,6 +351,20 @@ public static IEnumerable Parse_Valid_TestData() yield return new object[] { " 0 ", NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite, null, 0 }; yield return new object[] { " 000000000 ", NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite, null, 0 }; + // Whitespace between a leading sign and the digits (mirrors trailing sign + AllowTrailingWhite) + yield return new object[] { "- 123", NumberStyles.AllowLeadingWhite | NumberStyles.AllowLeadingSign, null, -123 }; + yield return new object[] { "+ 123", NumberStyles.AllowLeadingWhite | NumberStyles.AllowLeadingSign, null, 123 }; + yield return new object[] { " - 123 ", NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite | NumberStyles.AllowLeadingSign, null, -123 }; + yield return new object[] { "- 123", NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite | NumberStyles.AllowLeadingSign | NumberStyles.AllowTrailingSign, null, -123 }; + yield return new object[] { "- 123", NumberStyles.Integer, CultureInfo.InvariantCulture, -123 }; + yield return new object[] { "- 123", NumberStyles.Number, CultureInfo.InvariantCulture, -123 }; + + NumberFormatInfo customSigns = new NumberFormatInfo() { NegativeSign = "~", PositiveSign = "++" }; + yield return new object[] { "~ 123", NumberStyles.Integer, customSigns, -123 }; + yield return new object[] { "++ 123", NumberStyles.Integer, customSigns, 123 }; + yield return new object[] { "~ 123", NumberStyles.Number, customSigns, -123 }; + yield return new object[] { "- 123", NumberStyles.Number, new NumberFormatInfo() { NumberNegativePattern = 2 }, -123 }; + // AllowThousands NumberFormatInfo thousandsFormat = new NumberFormatInfo() { NumberGroupSeparator = "|" }; yield return new object[] { "1000", NumberStyles.AllowThousands, thousandsFormat, 1000 }; @@ -375,6 +389,10 @@ public static IEnumerable Parse_Valid_TestData() yield return new object[] { "123", NumberStyles.AllowParentheses, null, 123 }; yield return new object[] { "(123)", NumberStyles.AllowParentheses, null, -123 }; + // Whitespace between an opening parenthesis and the digits (AllowLeadingWhite) + yield return new object[] { "( 123)", NumberStyles.AllowLeadingWhite | NumberStyles.AllowParentheses, null, -123 }; + yield return new object[] { "( 123 )", NumberStyles.AllowLeadingWhite | NumberStyles.AllowTrailingWhite | NumberStyles.AllowParentheses, null, -123 }; + // AllowDecimalPoint NumberFormatInfo decimalFormat = new NumberFormatInfo() { NumberDecimalSeparator = "|" }; yield return new object[] { "67|", NumberStyles.AllowDecimalPoint, decimalFormat, 67 }; @@ -525,6 +543,8 @@ public static IEnumerable Parse_Invalid_TestData() yield return new object[] { "-+123", NumberStyles.AllowLeadingSign, null, typeof(FormatException) }; yield return new object[] { "- 123", NumberStyles.AllowLeadingSign, null, typeof(FormatException) }; yield return new object[] { "+ 123", NumberStyles.AllowLeadingSign, null, typeof(FormatException) }; + yield return new object[] { "- ", NumberStyles.Integer, null, typeof(FormatException) }; + yield return new object[] { "( 123)", NumberStyles.AllowParentheses, null, typeof(FormatException) }; // AllowTrailingSign yield return new object[] { "123-+", NumberStyles.AllowTrailingSign, null, typeof(FormatException) }; @@ -1115,6 +1135,8 @@ public static IEnumerable TryParsePartial_TestData() yield return new object[] { "-456xyz", NumberStyles.Integer, null, -456, 4 }; yield return new object[] { "+0xyz", NumberStyles.Integer, null, 0, 2 }; yield return new object[] { "-0xyz", NumberStyles.Integer, null, 0, 2 }; + yield return new object[] { " - 123xyz", NumberStyles.Integer, CultureInfo.InvariantCulture, -123, 8 }; + yield return new object[] { "- 123xyz", NumberStyles.Number, CultureInfo.InvariantCulture, -123, 5 }; // HexNumber with trailing invalid characters yield return new object[] { "ABCxyz", NumberStyles.HexNumber, null, 0xABC, 3 }; @@ -1172,6 +1194,7 @@ public static IEnumerable TryParsePartial_TestData() yield return new object[] { " +123abc", NumberStyles.Integer, nonInvariantSignFormat, 123, 6 }; yield return new object[] { " \u2212456xyz", NumberStyles.Integer, nonInvariantSignFormat, -456, 6 }; yield return new object[] { " \u2212456", NumberStyles.Integer, nonInvariantSignFormat, -456, 6 }; + yield return new object[] { " \u2212 456xyz", NumberStyles.Integer, nonInvariantSignFormat, -456, 7 }; yield return new object[] { " 123 abc", NumberStyles.Integer, nonInvariantSignFormat, 123, 7 }; } @@ -1202,6 +1225,10 @@ public static void TryParsePartial(string value, NumberStyles style, IFormatProv { Assert.Equal(expectedCharsConsumed, bytesConsumed); } + else + { + Assert.Equal(Encoding.UTF8.GetByteCount(value.AsSpan(0, expectedCharsConsumed)), bytesConsumed); + } } public static IEnumerable TryParsePartial_Invalid_TestData()