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
85 changes: 83 additions & 2 deletions PreciseNumber.Test/PreciseNumberTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -947,8 +947,8 @@ public void TestLowestDecimalDigits()
{
PreciseNumber number1 = PreciseNumber.CreateFromComponents(-2, 12345);
PreciseNumber number2 = PreciseNumber.CreateFromComponents(-3, 678);
int result = PreciseNumber.LowestDecimalDigits(number1, number2);
Assert.AreEqual(2, result);
long result = PreciseNumber.LowestDecimalDigits(number1, number2);
Assert.AreEqual(2L, result);
}

[TestMethod]
Expand Down Expand Up @@ -2300,4 +2300,85 @@ public void TestParseRoundTripsLongDecimals()

Assert.AreEqual(text, parsed.ToString(CultureInfo.InvariantCulture));
}

[TestMethod]
public void TestCountDecimalDigitsAtExtremeExponents()
{
PreciseNumber tiny = PreciseNumber.CreateFromComponents(int.MinValue, BigInteger.One);
Assert.AreEqual(-(long)int.MinValue, tiny.CountDecimalDigits());

PreciseNumber huge = PreciseNumber.CreateFromComponents(int.MaxValue, BigInteger.One);
Assert.AreEqual(0L, huge.CountDecimalDigits());
}

[TestMethod]
public void TestTryFormatAtExtremeExponentsReturnsFalse()
{
// The text these need is longer than any int, so the only correct answer for a
// destination this size is false, not an exception.
Span<char> buffer = stackalloc char[64];
foreach (int exponent in new[] { int.MinValue, int.MaxValue })
{
PreciseNumber number = PreciseNumber.CreateFromComponents(exponent, BigInteger.One);

bool result = number.TryFormat(buffer, out int charsWritten, "G".AsSpan(), CultureInfo.InvariantCulture);

Assert.IsFalse(result, $"TryFormat should fail for an exponent of {exponent}");
Assert.AreEqual(0, charsWritten);
}
}

[TestMethod]
public void TestToStringAtExtremeExponentsThrowsOverflow()
{
// Neither value can exist as a string in fixed point notation, so the failure should say
// so rather than surface as an allocation or negation error.
foreach (int exponent in new[] { int.MinValue, int.MaxValue })
{
PreciseNumber number = PreciseNumber.CreateFromComponents(exponent, BigInteger.One);

Assert.ThrowsExactly<OverflowException>(
() => number.ToString(CultureInfo.InvariantCulture),
$"ToString should overflow for an exponent of {exponent}");
}
}

[TestMethod]
public void TestParseThenFormatAtExtremeExponentsRoundTrips()
{
// Parse accepts any exponent that fits an int, so formatting has to answer for one.
PreciseNumber tiny = PreciseNumber.Parse("1E-2147483648", NumberStyles.Float, CultureInfo.InvariantCulture);
Assert.AreEqual(int.MinValue, tiny.Exponent);
Assert.ThrowsExactly<OverflowException>(() => tiny.ToString(CultureInfo.InvariantCulture));

PreciseNumber huge = PreciseNumber.Parse("1E2147483647", NumberStyles.Float, CultureInfo.InvariantCulture);
Assert.AreEqual(int.MaxValue, huge.Exponent);
Assert.ThrowsExactly<OverflowException>(() => huge.ToString(CultureInfo.InvariantCulture));
}

[TestMethod]
public void TestRoundAtExtremeNegativeExponentGivesZero()
{
// Every significant digit sits far below the requested place, so the value rounds away.
PreciseNumber tiny = PreciseNumber.CreateFromComponents(int.MinValue, BigInteger.One);

Assert.AreEqual(PreciseNumber.Zero, tiny.Round(2));
}

[TestMethod]
public void TestFormatAtLargeButRepresentableExponents()
{
// Still fixed point, and still the full run of zeros, but a length a string can hold.
PreciseNumber tiny = PreciseNumber.CreateFromComponents(-1000, BigInteger.One);
string tinyText = tiny.ToString(CultureInfo.InvariantCulture);
Assert.AreEqual(1002, tinyText.Length);
Assert.AreEqual("0.", tinyText[..2]);
Assert.AreEqual('1', tinyText[^1]);

PreciseNumber huge = PreciseNumber.CreateFromComponents(1000, BigInteger.One);
string hugeText = huge.ToString(CultureInfo.InvariantCulture);
Assert.AreEqual(1001, hugeText.Length);
Assert.AreEqual('1', hugeText[0]);
Assert.AreEqual(new string('0', 1000), hugeText[1..]);
}
}
73 changes: 48 additions & 25 deletions PreciseNumber/PreciseNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -330,14 +330,21 @@
NumberFormatInfo numberFormat = NumberFormatInfo.GetInstance(formatProvider ?? InvariantCulture);

// Digits, plus the padding zeros implied by the exponent, plus the sign, the decimal
// separator and a possible leading "0".
int desiredAlloc = number.SignificantDigits
+ int.Abs(number.Exponent)
// separator and a possible leading "0". Widened, because an exponent of int.MinValue has
// no negation that fits an int and one near int.MaxValue would wrap the sum.
long desiredAlloc = number.SignificantDigits
+ long.Abs(number.Exponent)
+ numberFormat.NegativeSign.Length
+ numberFormat.NumberDecimalSeparator.Length
+ 1;

char[]? rentedBuffer = desiredAlloc > MaxStackAllocChars ? ArrayPool<char>.Shared.Rent(desiredAlloc) : null;
if (desiredAlloc > Array.MaxLength)
{
throw new OverflowException(
$"An exponent of {number.Exponent.ToString(InvariantCulture)} needs more characters in fixed point notation than a string can hold.");
}

char[]? rentedBuffer = desiredAlloc > MaxStackAllocChars ? ArrayPool<char>.Shared.Rent((int)desiredAlloc) : null;
Span<char> stackBuffer = stackalloc char[MaxStackAllocChars];
Span<char> buffer = rentedBuffer is null ? stackBuffer : rentedBuffer.AsSpan();

Expand Down Expand Up @@ -373,12 +380,18 @@
/// <remarks>Rounds half away from zero, so 1.235 becomes 1.24 and 1.2349 becomes 1.23.</remarks>
public PreciseNumber Round(int decimalDigits)
{
int currentDecimalDigits = CountDecimalDigits();
int decimalDifference = int.Abs(decimalDigits - currentDecimalDigits);
long currentDecimalDigits = CountDecimalDigits();
long decimalDifference = long.Abs(decimalDigits - currentDecimalDigits);
if (currentDecimalDigits > decimalDigits && decimalDifference > 0)
{
BigInteger newSignificand = DropDigitsRoundingHalfAwayFromZero(Significand, decimalDifference);
int newExponent = Exponent - int.CopySign(decimalDifference, Exponent);
// Dropping one digit more than the significand holds always leaves zero, so there is
// never a reason to raise ten to a wider power than that, however far below the
// requested place the value sits.
int droppedDigits = (int)long.Min(decimalDifference, SignificantDigits + 1);
BigInteger newSignificand = DropDigitsRoundingHalfAwayFromZero(Significand, droppedDigits);
int newExponent = newSignificand.IsZero
? 0
: Exponent - int.CopySign(droppedDigits, Exponent);
return new PreciseNumber(newExponent, newSignificand);
}

Expand Down Expand Up @@ -517,7 +530,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 533 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this method and declare a constant for this value.

Check warning on line 533 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this method and declare a constant for this value.

Check warning on line 533 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this method and declare a constant for this value.

Check warning on line 533 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this method and declare a constant for this value.

Check warning on line 533 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Remove this method and declare a constant for this value.

Check warning on line 533 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Expand Down Expand Up @@ -615,10 +628,10 @@
/// <param name="left">The first number.</param>
/// <param name="right">The second number.</param>
/// <returns>The lower of the decimal digit counts of the two numbers.</returns>
internal static int LowestDecimalDigits(PreciseNumber left, PreciseNumber right)
internal static long LowestDecimalDigits(PreciseNumber left, PreciseNumber right)
{
int leftDecimalDigits = left.CountDecimalDigits();
int rightDecimalDigits = right.CountDecimalDigits();
long leftDecimalDigits = left.CountDecimalDigits();
long rightDecimalDigits = right.CountDecimalDigits();

leftDecimalDigits = left.HasInfinitePrecision ? rightDecimalDigits : leftDecimalDigits;
rightDecimalDigits = right.HasInfinitePrecision ? leftDecimalDigits : rightDecimalDigits;
Expand Down Expand Up @@ -651,10 +664,14 @@
/// Counts the number of decimal digits in the current instance.
/// </summary>
/// <returns>The number of decimal digits in the current instance.</returns>
internal int CountDecimalDigits() =>
/// <remarks>
/// Widened, because an exponent of <see cref="int.MinValue"/> implies one more decimal digit
/// than an <see cref="int"/> can count.
/// </remarks>
internal long CountDecimalDigits() =>
Exponent > 0
? 0
: int.Abs(Exponent);
: -(long)Exponent;

/// <summary>
/// Reduces the significance of the current instance to a specified number of significant digits.
Expand Down Expand Up @@ -927,7 +944,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 947 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 947 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 947 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

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

Check warning on line 947 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / Analyze & Release

Refactor this method to reduce its Cognitive Complexity from 23 to the 15 allowed.
{
if (s.IsEmpty)
{
Expand Down Expand Up @@ -1114,9 +1131,12 @@
sign = numberFormat.NegativeSign;
}

// Every length below is widened, because the padding zeros an extreme exponent implies can
// outnumber anything an int holds. Past that point no destination is large enough, so the
// comparison against its length is what answers, rather than an overflow.
if (Exponent >= 0)
{
int wholeLength = sign.Length + digits.Length + Exponent;
long wholeLength = (long)sign.Length + digits.Length + Exponent;
if (destination.Length < wholeLength)
{
return false;
Expand All @@ -1125,47 +1145,50 @@
sign.CopyTo(destination);
digits.CopyTo(destination[sign.Length..]);
destination.Slice(sign.Length + digits.Length, Exponent).Fill('0');
charsWritten = wholeLength;
charsWritten = (int)wholeLength;
return true;
}

ReadOnlySpan<char> separator = numberFormat.NumberDecimalSeparator;
int fractionalDigits = -Exponent;
int integralDigits = digits.Length - fractionalDigits;
long fractionalDigits = -(long)Exponent;
long integralDigits = digits.Length - fractionalDigits;

// When the exponent consumes every digit the integral part is a single "0" and the
// fractional part is padded out to the full width with leading zeros.
int integralLength = integralDigits > 0 ? integralDigits : 1;
int required = sign.Length + integralLength + separator.Length + fractionalDigits;
long integralLength = integralDigits > 0 ? integralDigits : 1;
long required = sign.Length + integralLength + separator.Length + fractionalDigits;

if (destination.Length < required)
{
return false;
}

// The destination holds the whole rendering, so every length below fits an int.
int position = 0;
sign.CopyTo(destination);
position += sign.Length;

if (integralDigits > 0)
{
digits[..integralDigits].CopyTo(destination[position..]);
position += integralDigits;
int wholeDigits = (int)integralDigits;
digits[..wholeDigits].CopyTo(destination[position..]);
position += wholeDigits;
separator.CopyTo(destination[position..]);
position += separator.Length;
digits[integralDigits..].CopyTo(destination[position..]);
digits[wholeDigits..].CopyTo(destination[position..]);
}
else
{
int padding = (int)(fractionalDigits - digits.Length);
destination[position++] = '0';
separator.CopyTo(destination[position..]);
position += separator.Length;
destination.Slice(position, fractionalDigits - digits.Length).Fill('0');
position += fractionalDigits - digits.Length;
destination.Slice(position, padding).Fill('0');
position += padding;
digits.CopyTo(destination[position..]);
}

charsWritten = required;
charsWritten = (int)required;
return true;
}

Expand Down