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..2da3467 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,60 @@ 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) + { + return Zero; + } + } + + 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) {