Skip to content
28 changes: 26 additions & 2 deletions src/libraries/Common/src/System/Number.Formatting.Common.cs
Original file line number Diff line number Diff line change
Expand Up @@ -435,7 +435,7 @@ internal static unsafe void NumberToStringFormat<TChar>(ref ValueListBuilder<TCh
}
else
{
if (number.Kind != NumberBufferKind.FloatingPoint)
if (number.Kind is not (NumberBufferKind.FloatingPoint or NumberBufferKind.DecimalIeee754))
{
// The integer types don't have a concept of -0 and decimal always format -0 as 0
number.IsNegative = false;
Expand Down Expand Up @@ -1085,7 +1085,7 @@ internal static unsafe void RoundNumber(ref NumberBuffer number, int pos, bool i

if (i == 0)
{
if (number.Kind != NumberBufferKind.FloatingPoint)
if (number.Kind is not (NumberBufferKind.FloatingPoint or NumberBufferKind.DecimalIeee754))
{
// The integer types don't have a concept of -0 and decimal always format -0 as 0
number.IsNegative = false;
Expand Down Expand Up @@ -1120,6 +1120,30 @@ static bool ShouldRoundUp(byte* dig, int i, NumberBufferKind numberKind, bool is
return false;
}

if (numberKind == NumberBufferKind.DecimalIeee754)
{
// The buffer holds the exact coefficient, so a '5' followed by nothing but zeros is a
// true tie rather than an artifact of a truncated expansion. IEEE 754 §5.12.1 requires
// the conversion to be correctly rounded under the applicable rounding-direction
// attribute, which is roundTiesToEven.

if (digit != '5')
{
return digit > '5';
}

for (int j = i + 1; dig[j] != '\0'; j++)
{
if (dig[j] != '0')
{
return true;
}
}

// A tie with no preceding digit rounds toward the implicit leading zero, which is even.
return (i > 0) && (((dig[i - 1] - '0') & 1) != 0);
}

// Values greater than or equal to 5 should round up, otherwise we round down. The IEEE
// 754 spec actually dictates that ties (exactly 5) should round to the nearest even number
// but that can have undesired behavior for custom numeric format strings. This probably
Expand Down
10 changes: 9 additions & 1 deletion src/libraries/Common/src/System/Number.NumberBuffer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ public NumberBuffer(NumberBufferKind kind, Span<byte> digits)
public void CheckConsistency()
{
#if DEBUG
Debug.Assert(Kind is NumberBufferKind.Integer or NumberBufferKind.Decimal or NumberBufferKind.FloatingPoint);
Debug.Assert(Kind is NumberBufferKind.Integer or NumberBufferKind.Decimal or NumberBufferKind.FloatingPoint or NumberBufferKind.DecimalIeee754);
Debug.Assert(Digits[0] != '0', "Leading zeros should never be stored in a Number");

int numDigits;
Expand Down Expand Up @@ -127,6 +127,14 @@ internal enum NumberBufferKind : byte
Integer = 1,
Decimal = 2,
FloatingPoint = 3,

/// <summary>
/// An IEEE 754 decimal interchange format. Unlike <see cref="NumberBufferKind.FloatingPoint"/> the buffer
/// holds the exact coefficient rather than a pre-rounded shortest representation, so formatting must round
/// it; unlike <see cref="NumberBufferKind.Decimal"/> that rounding is ties-to-even and a signed zero must
/// survive it.
/// </summary>
DecimalIeee754 = 4,
}
}
}
2 changes: 1 addition & 1 deletion src/libraries/Common/src/System/Number.Parsing.Common.cs
Original file line number Diff line number Diff line change
Expand Up @@ -268,7 +268,7 @@ private static unsafe bool TryParseNumber<TChar>(TChar* str, TChar* strEnd, Numb
{
if ((state & StateNonZero) == 0)
{
if (number.Kind != NumberBufferKind.Decimal)
if (number.Kind is not (NumberBufferKind.Decimal or NumberBufferKind.DecimalIeee754))
{
number.Scale = 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -376,15 +376,22 @@ internal static string FormatDecimalIeee754<TDecimal, TValue>(TValue value, stri
char fmt = ParseFormatSpecifier(format, out int digits);

byte* pDigits = stackalloc byte[TDecimal.BufferLength];
NumberBuffer number = new NumberBuffer(NumberBufferKind.Decimal, pDigits, TDecimal.BufferLength);
NumberBuffer number = new NumberBuffer(NumberBufferKind.DecimalIeee754, pDigits, TDecimal.BufferLength);

DecimalIeee754ToNumber<TDecimal, TValue>(value, ref number);

if (fmt != 0)
{
if (fmt is 'G' or 'R' or 'g' or 'r')
{
FormatGeneralAndRoundTripDecimalIeee754(ref vlb, ref number, fmt, digits, info);
if (fmt is 'R' or 'r')
{
// The roundtrip specifier ignores any precision specifier and is otherwise identical to the general specifier
fmt = (char)(fmt - ('R' - 'G'));
digits = -1;
}

FormatGeneralAndRoundTripDecimalIeee754(ref vlb, ref number, (char)(fmt - ('G' - 'E')), digits, info);
}
Comment thread
tannergooding marked this conversation as resolved.
else
{
Expand Down Expand Up @@ -418,14 +425,94 @@ internal static bool TryFormatDecimalIeee754<TDecimal, TValue, TChar>(TValue val
return success;
}

private static void FormatGeneralAndRoundTripDecimalIeee754<TChar>(ref ValueListBuilder<TChar> vlb, ref NumberBuffer number, char fmt, int digits, NumberFormatInfo info)
/// <summary>
/// Formats <paramref name="number"/> using the general format, preserving the quantum exponent so that
/// reparsing the result recovers the same member of the cohort.
/// </summary>
/// <remarks>
/// Fixed-point notation can only spell a quantum exponent that is at or below zero, since a positive
/// quantum would require trailing zeros that reparse as a larger coefficient. Scientific notation is
/// therefore required whenever the quantum exponent is positive, and is otherwise picked using the same
/// compactness heuristic as the binary floating-point types.
/// </remarks>
private static unsafe void FormatGeneralAndRoundTripDecimalIeee754<TChar>(ref ValueListBuilder<TChar> vlb, ref NumberBuffer number, char expChar, int nMaxDigits, NumberFormatInfo info)
where TChar : unmanaged, IUtfChar<TChar>
{
Debug.Assert(number.Kind == NumberBufferKind.DecimalIeee754);

bool rounded = (nMaxDigits > 0) && (nMaxDigits < number.DigitsCount);

if (rounded)
{
RoundNumber(ref number, nMaxDigits, isCorrectlyRounded: false);
}

if (number.IsNegative)
{
vlb.Append(info.NegativeSignTChar<TChar>());
}
FormatGeneral(ref vlb, ref number, digits, info, (char)(fmt - ('G' - 'E')), suppressScientific: true);

byte* dig = number.DigitsPtr;
int digitCount = number.DigitsCount;

// `Scale` is the coefficient digit count plus the quantum exponent, so `Scale` exceeding the number
// of significant digits means the quantum exponent is positive. Rounding drops trailing coefficient
// digits without touching `Scale`, so the requested precision is what remains significant in that
// case; the dropped digits are recovered as trailing zeros below.
int significantDigits = rounded ? nMaxDigits : digitCount;

// A zero coefficient has no stored digits but still participates as the single digit `0` when
// computing the adjusted exponent.
int adjustedExponent = (digitCount != 0) ? (number.Scale - 1) : number.Scale;

if ((number.Scale > significantDigits) || (adjustedExponent < -4))
{
vlb.Append(TChar.CastFrom((digitCount != 0) ? (char)dig[0] : '0'));

if (digitCount > 1)
{
vlb.Append(info.NumberDecimalSeparatorTChar<TChar>());

for (int i = 1; i < digitCount; i++)
{
vlb.Append(TChar.CastFrom((char)dig[i]));
}
}

FormatExponent(ref vlb, info, adjustedExponent, expChar, minDigits: 2, positiveSign: true);
return;
}

int integerDigits = number.Scale;

if (integerDigits > 0)
{
for (int i = 0; i < integerDigits; i++)
{
// Rounding can leave fewer digits than the scale requires, in which case the remaining
// integer positions are trailing zeros of the rounded coefficient.
vlb.Append(TChar.CastFrom((i < digitCount) ? (char)dig[i] : '0'));
}
}
else
{
vlb.Append(TChar.CastFrom('0'));
}

if (integerDigits < digitCount)
{
vlb.Append(info.NumberDecimalSeparatorTChar<TChar>());

for (int i = integerDigits; i < 0; i++)
{
vlb.Append(TChar.CastFrom('0'));
}

for (int i = Math.Max(integerDigits, 0); i < digitCount; i++)
{
vlb.Append(TChar.CastFrom((char)dig[i]));
}
}
}

public static unsafe string FormatDecimal(decimal value, ReadOnlySpan<char> format, NumberFormatInfo info)
Expand Down Expand Up @@ -491,7 +578,9 @@ internal static void DecimalIeee754ToNumber<TDecimal, TValue>(TValue value, ref

if (TValue.IsZero(unpackDecimal.Significand))
{
number.Scale = unpackDecimal.UnbiasedExponent < 0 ? unpackDecimal.UnbiasedExponent : 0;
// A zero coefficient has no stored digits, so `Scale` carries the quantum exponent directly.
// Every other format specifier calls `RoundNumber` (or resets `Scale` itself) before reading it.
number.Scale = unpackDecimal.UnbiasedExponent;
number.DigitsCount = 0;
number.Digits[0] = (byte)'\0';
number.CheckConsistency();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -994,7 +994,7 @@ internal static ParsingStatus TryParseDecimalIeee754<TChar, TDecimal, TValue>(Re
where TDecimal : unmanaged, IDecimalIeee754ParseAndFormatInfo<TDecimal, TValue>
where TValue : unmanaged, IBinaryInteger<TValue>
{
NumberBuffer number = new NumberBuffer(NumberBufferKind.Decimal, stackalloc byte[TDecimal.BufferLength]);
NumberBuffer number = new NumberBuffer(NumberBufferKind.DecimalIeee754, stackalloc byte[TDecimal.BufferLength]);
result = default;

if (!TryStringToNumber(value, styles, ref number, info, out elementsConsumed))
Expand Down
Loading