From 7421004558cf7e626db48b2dc8f05172241c1e3a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:32:43 +0000 Subject: [PATCH 1/2] Parse values and zeros at the edges of the exponent range [patch] Parse subtracted every written fraction digit from the exponent in checked int arithmetic before normalizing, and read the exponent text as an int even when the significand was zero. So "2.500E-2147483647" and "1.0e-2147483648" threw OverflowException although the values fit, ToString("E") output near int.MinValue could not be parsed back, and "0e99999999999" threw where decimal and double return 0. Parse now drops trailing zeros before applying the exponent, does that arithmetic in long and range-checks only the result, and returns Zero for a zero significand whatever its well-formed exponent. Fixes #178 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5ShapPU8BXcXvikJm --- .../PreciseNumberParseExponentTests.cs | 84 +++++++++++++++++++ PreciseNumber/PreciseNumber.cs | 73 ++++++++++++---- 2 files changed, 142 insertions(+), 15 deletions(-) create mode 100644 PreciseNumber.Test/PreciseNumberParseExponentTests.cs diff --git a/PreciseNumber.Test/PreciseNumberParseExponentTests.cs b/PreciseNumber.Test/PreciseNumberParseExponentTests.cs new file mode 100644 index 0000000..1a77354 --- /dev/null +++ b/PreciseNumber.Test/PreciseNumberParseExponentTests.cs @@ -0,0 +1,84 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Test; + +using System.Globalization; +using System.Numerics; + +/// +/// Pins how Parse applies a written exponent at the edges of the exponent range: +/// padding zeros must not overflow a value that fits, and a zero is zero whatever its exponent. +/// +[TestClass] +public sealed class PreciseNumberParseExponentTests +{ + [TestMethod] + [DataRow("1e-2147483646", -2147483646, 1)] + [DataRow("1.000000E-2147483646", -2147483646, 1)] + [DataRow("2.500E-2147483647", -2147483648, 25)] + [DataRow("1.0e-2147483648", -2147483648, 1)] + [DataRow("-1.50e-2147483647", -2147483648, -15)] + [DataRow("10e2147483646", 2147483647, 1)] + [DataRow("1000e2147483644", 2147483647, 1)] + public void Parse_AppliesTheExponentAfterDroppingPaddingZeros(string text, int exponent, int significand) + { + PreciseNumber expected = new(exponent, new BigInteger(significand)); + + Assert.AreEqual(expected, PreciseNumber.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); + Assert.IsTrue(PreciseNumber.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out PreciseNumber parsed)); + Assert.AreEqual(expected, parsed); + } + + [TestMethod] + [DataRow("0e99999999999")] + [DataRow("0e-99999999999")] + [DataRow("0.000e99999999999999999999999")] + [DataRow("-0e2147483648")] + [DataRow("000e5")] + public void Parse_ReadsZeroWhateverItsWrittenExponent(string text) + { + Assert.AreEqual(0m, decimal.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); + Assert.AreEqual(PreciseNumber.Zero, PreciseNumber.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); + } + + [TestMethod] + [DataRow("1e2147483648")] + [DataRow("1e-2147483649")] + [DataRow("0.1e-2147483648")] + [DataRow("1e99999999999999999999")] + public void Parse_StillOverflowsForAValueOutsideTheExponentRange(string text) + { + Assert.ThrowsExactly(() => PreciseNumber.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); + Assert.IsFalse(PreciseNumber.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out _)); + } + + [TestMethod] + [DataRow("0e")] + [DataRow("0ex")] + [DataRow("0e 5")] + public void Parse_RejectsAMalformedExponentEvenOnZero(string text) => + Assert.ThrowsExactly(() => PreciseNumber.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture)); + + [TestMethod] + public void Parse_RoundTripsScientificTextAtTheEdgesOfTheExponentRange() + { + // At most seven digits, which is all the default "E" format writes. + BigInteger[] significands = [BigInteger.One, new BigInteger(25), new BigInteger(-1234567)]; + for (int offset = 0; offset <= 10; offset++) + { + foreach (BigInteger significand in significands) + { + foreach (int exponent in new[] { int.MinValue + offset, int.MaxValue - offset }) + { + PreciseNumber value = new(exponent, significand); + string text = value.ToString("E", CultureInfo.InvariantCulture); + + Assert.IsTrue( + PreciseNumber.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out PreciseNumber parsed), + $"\"{text}\" did not parse."); + Assert.AreEqual(value, parsed, $"\"{text}\" parsed to a different value."); + } + } + } + } +} diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index 40355c7..b7a69bc 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -1232,9 +1232,10 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor try { int digitCount = 0; - int exponent = 0; bool hasDecimal = false; int decimalDigits = 0; + ReadOnlySpan exponentText = []; + bool hasExponent = false; for (int i = startIndex; i < s.Length; i++) { @@ -1260,10 +1261,8 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor if (allowExponent && c is 'e' or 'E') { - // An exponent outside the range of int throws OverflowException, which TryParse reports as failure. - // Only a sign may precede its digits: NumberStyles.Integer would also let white space through, - // accepting "1e 5" where every BCL numeric type rejects it. - exponent = int.Parse(s[(i + 1)..], NumberStyles.AllowLeadingSign, numberFormat); + exponentText = s[(i + 1)..]; + hasExponent = true; break; } @@ -1285,16 +1284,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor throw new FormatException(InvalidFormatMessage); } - BigInteger significand = BigInteger.Parse(digits[..digitCount], NumberStyles.None, InvariantCulture); - - exponent = checked(exponent - decimalDigits); - - if (isNegative) - { - significand = -significand; - } - - return new(exponent, significand); + return FromParsedParts(digits[..digitCount], decimalDigits, hasExponent, exponentText, isNegative, numberFormat); } finally { @@ -1305,6 +1295,59 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor } } + /// + /// Builds the value read: the + /// written , of which the last follow the + /// decimal separator, scaled by when is set. + /// + private static PreciseNumber FromParsedParts(ReadOnlySpan digits, int decimalDigits, bool hasExponent, ReadOnlySpan exponentText, bool isNegative, NumberFormatInfo numberFormat) + { + // Drop trailing zeros before applying the exponent, so that padding such as the zeros in + // "2.500E-2147483647" can't push the arithmetic past int when the value itself fits. + int significantDigitCount = digits.Length; + while (significantDigitCount > 0 && digits[significantDigitCount - 1] == '0') + { + significantDigitCount--; + } + + bool isZero = significantDigitCount == 0; + long exponent = 0; + if (hasExponent) + { + // Only a sign may precede its digits: NumberStyles.Integer would also let white space through, + // accepting "1e 5" where every BCL numeric type rejects it. A zero is zero whatever its written + // exponent, as for decimal and double, so only a nonzero value can overflow here. + try + { + exponent = long.Parse(exponentText, NumberStyles.AllowLeadingSign, numberFormat); + } + catch (OverflowException) when (isZero) + { + } + } + + if (isZero) + { + return Zero; + } + + // An exponent outside the range of int throws OverflowException, which TryParse reports as failure. + exponent = checked(exponent - decimalDigits + (digits.Length - significantDigitCount)); + if (exponent is < int.MinValue or > int.MaxValue) + { + throw new OverflowException("Value was either too large or too small for a PreciseNumber."); + } + + BigInteger significand = BigInteger.Parse(digits[..significantDigitCount], NumberStyles.None, InvariantCulture); + + if (isNegative) + { + significand = -significand; + } + + return new((int)exponent, significand); + } + /// Trims the leading and trailing white space that allows. private static ReadOnlySpan TrimAllowedWhite(ReadOnlySpan s, NumberStyles style) { From b2e3e489a8fbbcfee5ad331aade10a8eefa0963d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 08:35:49 +0000 Subject: [PATCH 2/2] Return Zero from the overflow catch instead of leaving it empty Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5ShapPU8BXcXvikJm --- PreciseNumber/PreciseNumber.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index b7a69bc..2da3467 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -1323,6 +1323,7 @@ private static PreciseNumber FromParsedParts(ReadOnlySpan digits, int deci } catch (OverflowException) when (isZero) { + return Zero; } }