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
84 changes: 84 additions & 0 deletions PreciseNumber.Test/PreciseNumberParseExponentTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.PreciseNumber.Test;

using System.Globalization;
using System.Numerics;

/// <summary>
/// Pins how Parse applies a written exponent at the edges of the <see cref="int"/> exponent range:
/// padding zeros must not overflow a value that fits, and a zero is zero whatever its exponent.
/// </summary>
[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<OverflowException>(() => 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<FormatException>(() => 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.");
}
}
}
}
}
74 changes: 59 additions & 15 deletions PreciseNumber/PreciseNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -736,7 +736,7 @@
/// A fixed precision such as <c>E15</c> rounds values that need 17 digits, which turns
/// <see cref="double.MaxValue"/> into a number that converts back to infinity.
/// </remarks>
internal static string GetStringFormatForFloatType<TFloat>()

Check warning on line 739 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this method and declare a constant for this value.

Check warning on line 739 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this method and declare a constant for this value.
where TFloat : INumber<TFloat>
=> "R";

Expand Down Expand Up @@ -1197,7 +1197,7 @@
public static PreciseNumber MinMagnitudeNumber(PreciseNumber x, PreciseNumber y) => MinMagnitude(x, y);

/// <inheritdoc/>
public static PreciseNumber Parse(ReadOnlySpan<char> s, NumberStyles style, IFormatProvider? provider)

Check warning on line 1200 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 27 to the 15 allowed.

Check warning on line 1200 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'Parse' method overloads should be adjacent.

Check warning on line 1200 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 27 to the 15 allowed.

Check warning on line 1200 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'Parse' method overloads should be adjacent.
{
// Read the separator and signs from the same culture TryFormat writes them with, so that
// Parse(x.ToString(p), p) round-trips for every provider.
Expand Down Expand Up @@ -1232,9 +1232,10 @@
try
{
int digitCount = 0;
int exponent = 0;
bool hasDecimal = false;
int decimalDigits = 0;
ReadOnlySpan<char> exponentText = [];
bool hasExponent = false;

for (int i = startIndex; i < s.Length; i++)
{
Expand All @@ -1247,23 +1248,21 @@
}

hasDecimal = true;
i += decimalSeparator.Length - 1;

Check warning on line 1251 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 1251 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.
continue;
}

// Group separators may only sit between integral digits.
if (allowThousands && !hasDecimal && digitCount > 0 && s[i..].StartsWith(groupSeparator, StringComparison.Ordinal))
{
i += groupSeparator.Length - 1;

Check warning on line 1258 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 1258 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.
continue;
}

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;
}

Expand All @@ -1285,16 +1284,7 @@
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
{
Expand All @@ -1305,6 +1295,60 @@
}
}

/// <summary>
/// Builds the value <see cref="Parse(ReadOnlySpan{char}, NumberStyles, IFormatProvider?)"/> read: the
/// written <paramref name="digits"/>, of which the last <paramref name="decimalDigits"/> follow the
/// decimal separator, scaled by <paramref name="exponentText"/> when <paramref name="hasExponent"/> is set.
/// </summary>
private static PreciseNumber FromParsedParts(ReadOnlySpan<char> digits, int decimalDigits, bool hasExponent, ReadOnlySpan<char> 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);
}

/// <summary>Trims the leading and trailing white space that <paramref name="style"/> allows.</summary>
private static ReadOnlySpan<char> TrimAllowedWhite(ReadOnlySpan<char> s, NumberStyles style)
{
Expand Down
Loading