Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
756433a
Add support for floating-point hex formatting and parsing
stephentoub Feb 7, 2026
6d4ba39
Address PR review feedback for hex float formatting/parsing
stephentoub Mar 12, 2026
9112f47
Add additional hex float edge case tests
danmoseley Mar 14, 2026
aec06cb
Clean up hex float formatting: remove orphaned string, add assertions…
danmoseley Mar 14, 2026
72cc040
Remove unreachable shiftRight == 0 branch in hex float parsing
danmoseley Mar 14, 2026
102abdb
Add Debug.Assert for bitsToDiscard range in hex float formatter
danmoseley Mar 14, 2026
51fa1ab
Clarify significand accumulation guards in hex float parser
danmoseley Mar 14, 2026
3bda837
Assert shiftRight range after rounding logic in hex float parser
danmoseley Mar 14, 2026
f10634f
Emit 0x prefix
stephentoub Mar 14, 2026
f8a770d
Require 0x prefix when parsing
stephentoub Mar 15, 2026
7acd9b9
Fix doc comment
stephentoub Mar 15, 2026
5f44a7a
Require binary exponent (p/P) when parsing hex floats
stephentoub Mar 20, 2026
b4f8455
Apply suggestions from code review
stephentoub Mar 20, 2026
9dfd36d
Update src/libraries/System.Runtime/tests/System.Runtime.Tests/System…
stephentoub Mar 20, 2026
1a4520f
Update src/libraries/System.Runtime/tests/System.Runtime.Tests/System…
stephentoub Mar 20, 2026
26ca896
Address PR review feedback for hex float formatting/parsing
stephentoub Mar 25, 2026
199548f
Address tannergooding review feedback
stephentoub Mar 26, 2026
c032026
Add AllowExponent to HexFloat and fix exponent doc wording
stephentoub Mar 26, 2026
e7fa025
Guard against empty NegativeSign in hex float parsing
stephentoub Mar 26, 2026
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
4 changes: 2 additions & 2 deletions src/libraries/Common/tests/System/RealFormatterTestsBase.cs
Original file line number Diff line number Diff line change
Expand Up @@ -824,7 +824,7 @@ public abstract class RealFormatterTestsBase

public static IEnumerable<object[]> TestFormatterDouble_InvalidMemberData =>
from value in new[] { double.Epsilon, double.MaxValue, Math.E, Math.PI, 0.0, 0.84551240822557006, 1.0, 1844674407370955.25 }
from format in new[] { "D", "D4", "D20", "X", "X4", "X20" }
from format in new[] { "D", "D4", "D20" }
select new object[] { value, format };

[Theory]
Expand Down Expand Up @@ -1644,7 +1644,7 @@ protected void TestFormatterDouble_Standard(double value, string format, string

public static IEnumerable<object[]> TestFormatterSingle_InvalidMemberData =>
from value in new[] { float.Epsilon, float.MaxValue, MathF.E, MathF.PI, 0.0, 0.845512390f, 1.0, 429496.72 }
from format in new[] { "D", "D4", "D20", "X", "X4", "X20" }
from format in new[] { "D", "D4", "D20" }
select new object[] { value, format };

[Theory]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -403,8 +403,11 @@
<data name="Arg_GuidArrayCtor" xml:space="preserve">
<value>Byte array for Guid must be exactly {0} bytes long.</value>
</data>
<data name="Arg_HexBinaryStylesNotSupported" xml:space="preserve">
<value>The number styles AllowHexSpecifier and AllowBinarySpecifier are not supported on floating point data types.</value>
<data name="Arg_BinaryStyleNotSupported" xml:space="preserve">
<value>The number style AllowBinarySpecifier is not supported on floating point data types.</value>
</data>
<data name="Arg_InvalidHexFloatStyle" xml:space="preserve">
<value>With the AllowHexSpecifier bit set in the enum bit field, the only other valid bits that can be combined into the enum value must be a subset of HexFloat (AllowLeadingWhite, AllowTrailingWhite, AllowLeadingSign, AllowDecimalPoint, and AllowExponent). AllowExponent is required when AllowHexSpecifier is specified.</value>
Comment thread
stephentoub marked this conversation as resolved.
</data>
<data name="Arg_HTCapacityOverflow" xml:space="preserve">
<value>Hashtable's capacity overflowed and went negative. Check load factor, capacity and the current size of the table.</value>
Expand Down
4 changes: 2 additions & 2 deletions src/libraries/System.Private.CoreLib/src/System/Double.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2264,14 +2264,14 @@ public static double TanPi(double x)
/// <inheritdoc cref="INumberBase{TSelf}.Parse(ReadOnlySpan{byte}, NumberStyles, IFormatProvider?)" />
public static double Parse(ReadOnlySpan<byte> utf8Text, NumberStyles style = NumberStyles.Float | NumberStyles.AllowThousands, IFormatProvider? provider = null)
{
NumberFormatInfo.ValidateParseStyleInteger(style);
NumberFormatInfo.ValidateParseStyleFloatingPoint(style);
return Number.ParseFloat<byte, double>(utf8Text, style, NumberFormatInfo.GetInstance(provider));
}

/// <inheritdoc cref="INumberBase{TSelf}.TryParse(ReadOnlySpan{byte}, NumberStyles, IFormatProvider?, out TSelf)" />
public static bool TryParse(ReadOnlySpan<byte> utf8Text, NumberStyles style, IFormatProvider? provider, out double result)
{
NumberFormatInfo.ValidateParseStyleInteger(style);
NumberFormatInfo.ValidateParseStyleFloatingPoint(style);
return Number.TryParseFloat(utf8Text, style, NumberFormatInfo.GetInstance(provider), out result);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -827,13 +827,23 @@ static void ThrowInvalid(NumberStyles value)

internal static void ValidateParseStyleFloatingPoint(NumberStyles style)
{
// Check for undefined flags or hex number
if ((style & (InvalidNumberStyles | NumberStyles.AllowHexSpecifier | NumberStyles.AllowBinarySpecifier)) != 0)
// Check for undefined flags, AllowBinarySpecifier (never valid for float), or AllowHexSpecifier with anything other than HexFloat flags.
// When AllowHexSpecifier is specified, AllowExponent must also be specified; this reserves
// AllowHexSpecifier without AllowExponent for possible future use (e.g. optional p exponent).
if ((style & (InvalidNumberStyles | NumberStyles.AllowBinarySpecifier | NumberStyles.AllowHexSpecifier)) != 0 &&
((style & ~NumberStyles.HexFloat) != 0 ||
(style & NumberStyles.AllowHexSpecifier) != 0 && (style & NumberStyles.AllowExponent) == 0))
{
ThrowInvalid(style);

static void ThrowInvalid(NumberStyles value) =>
throw new ArgumentException((value & InvalidNumberStyles) != 0 ? SR.Argument_InvalidNumberStyles : SR.Arg_HexBinaryStylesNotSupported, nameof(style));
static void ThrowInvalid(NumberStyles value)
{
throw new ArgumentException(
(value & InvalidNumberStyles) != 0 ? SR.Argument_InvalidNumberStyles :
(value & NumberStyles.AllowBinarySpecifier) != 0 ? SR.Arg_BinaryStyleNotSupported :
SR.Arg_InvalidHexFloatStyle,
nameof(style));
}
}
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,31 @@ public enum NumberStyles
Float = AllowLeadingWhite | AllowTrailingWhite | AllowLeadingSign |
AllowDecimalPoint | AllowExponent,

/// <summary>
/// Indicates that the <see cref="AllowLeadingWhite"/>, <see cref="AllowTrailingWhite"/>,
/// <see cref="AllowLeadingSign"/>, <see cref="AllowDecimalPoint"/>, <see cref="AllowExponent"/>,
/// and <see cref="AllowHexSpecifier"/> styles are used.
/// This is a composite number style used for parsing hexadecimal floating-point values
/// based on the syntax defined in IEEE 754:2008 §5.12.3:
/// <code>
/// [sign] 0x hexSignificand pExponent
/// </code>
/// where <c>sign</c> is an optional <c>+</c> or <c>-</c>,
/// <c>0x</c> (or <c>0X</c>) is a required hexadecimal indicator,
/// <c>hexSignificand</c> is one of <c>hh</c>, <c>hh.</c>, <c>hh.hh</c>, or <c>.hh</c>
/// (where <c>hh</c> represents one or more hexadecimal digits), and
/// <c>pExponent</c> is a required <c>p</c> (or <c>P</c>) followed by an optional sign (<c>+</c> or <c>-</c>)
/// and one or more decimal digits specifying an exponent in the radix of the floating-point format
/// (for binary types such as <see cref="float"/> and <see cref="double"/>,
/// the significand is multiplied by 2 raised to this power).
Comment thread
stephentoub marked this conversation as resolved.
/// </summary>
/// <remarks>
/// Note that unlike <see cref="HexNumber"/> for integer types (which rejects a "0x"/"0X" prefix),
/// <see cref="HexFloat"/> requires the prefix. This difference exists because the
/// IEEE 754 hex float grammar (e.g., <c>0x1.921fb54442d18p+1</c>) naturally includes the prefix.
/// </remarks>
Comment thread
stephentoub marked this conversation as resolved.
HexFloat = AllowLeadingWhite | AllowTrailingWhite | AllowLeadingSign | AllowDecimalPoint | AllowExponent | AllowHexSpecifier,

Currency = AllowLeadingWhite | AllowTrailingWhite | AllowLeadingSign | AllowTrailingSign |
AllowParentheses | AllowDecimalPoint | AllowThousands | AllowCurrencySymbol,

Expand Down
4 changes: 2 additions & 2 deletions src/libraries/System.Private.CoreLib/src/System/Half.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2311,14 +2311,14 @@ public static (Half SinPi, Half CosPi) SinCosPi(Half x)
/// <inheritdoc cref="INumberBase{TSelf}.Parse(ReadOnlySpan{byte}, NumberStyles, IFormatProvider?)" />
public static Half Parse(ReadOnlySpan<byte> utf8Text, NumberStyles style = NumberStyles.Float | NumberStyles.AllowThousands, IFormatProvider? provider = null)
{
NumberFormatInfo.ValidateParseStyleInteger(style);
NumberFormatInfo.ValidateParseStyleFloatingPoint(style);
return Number.ParseFloat<byte, Half>(utf8Text, style, NumberFormatInfo.GetInstance(provider));
}

/// <inheritdoc cref="INumberBase{TSelf}.TryParse(ReadOnlySpan{byte}, NumberStyles, IFormatProvider?, out TSelf)" />
public static bool TryParse(ReadOnlySpan<byte> utf8Text, NumberStyles style, IFormatProvider? provider, out Half result)
{
NumberFormatInfo.ValidateParseStyleInteger(style);
NumberFormatInfo.ValidateParseStyleFloatingPoint(style);
return Number.TryParseFloat(utf8Text, style, NumberFormatInfo.GetInstance(provider), out result);
}

Expand Down
211 changes: 211 additions & 0 deletions src/libraries/System.Private.CoreLib/src/System/Number.Formatting.cs
Original file line number Diff line number Diff line change
Expand Up @@ -541,6 +541,209 @@ static int Slow(char fmt, ref int precision, NumberFormatInfo info, out bool isS
}
}

private static unsafe void FormatFloatingPointAsHex<TNumber, TChar>(ref ValueListBuilder<TChar> vlb, TNumber value, char fmt, int precision, NumberFormatInfo info)
where TNumber : unmanaged, IBinaryFloatParseAndFormatInfo<TNumber>
where TChar : unmanaged, IUtfChar<TChar>
{
Debug.Assert((fmt | 0x20) == 'x');
Debug.Assert(TNumber.IsFinite(value));

bool isNegative = TNumber.IsNegative(value);

if (isNegative)
{
vlb.Append(info.NegativeSignTChar<TChar>());
}

vlb.Append(TChar.CastFrom('0'));
vlb.Append(TChar.CastFrom(fmt));

ulong fraction = ExtractFractionAndBiasedExponent(value, out int exponent);

if (fraction == 0)
{
// +/- 0
vlb.Append(TChar.CastFrom('0'));

if (precision > 0)
{
vlb.Append(info.NumberDecimalSeparatorTChar<TChar>());
vlb.AppendSpan(precision).Fill(TChar.CastFrom('0'));
}

// Exponent sign is always emitted ('+' or '-'), consistent with the 'E' format.
vlb.Append(TChar.CastFrom(fmt == 'X' ? 'P' : 'p'));
vlb.Append(TChar.CastFrom('+'));
Comment thread
stephentoub marked this conversation as resolved.
vlb.Append(TChar.CastFrom('0'));

return;
}

// ExtractFractionAndBiasedExponent returns (note: despite the name, the exponent is unbiased):
// For normal: fraction = (1 << DenormalMantissaBits) | mantissa, exponent = biasedExp - ExponentBias - DenormalMantissaBits
// For denormal: fraction = mantissa, exponent = MinBinaryExponent - DenormalMantissaBits
//
// We want the form: 1.xxxxx * 2^e
// So we need to normalize so that the leading 1 bit is at bit DenormalMantissaBits.
// For normal numbers, this is already the case.
// For denormal numbers, we need to shift left until the leading 1 is there.

int mantissaBits = TNumber.DenormalMantissaBits;

if (fraction < (1UL << mantissaBits))
{
// Denormal: shift the leading 1 up to the implicit bit position
int lz = BitOperations.LeadingZeroCount(fraction) - (63 - mantissaBits);
fraction <<= lz;
exponent -= lz;
}

// Now fraction has the leading 1 at bit [mantissaBits], and the remaining bits below.
// The unbiased exponent for the value is: exponent + mantissaBits (since fraction is
// really fraction * 2^exponent, and we want 1.xxx * 2^actualExponent).
int actualExponent = exponent + mantissaBits;

// Strip the implicit leading 1 to get the fractional bits
ulong significandBits = fraction & ((1UL << mantissaBits) - 1);

// Leading digit is normally '1' for non-zero (the implicit bit)
int leadingDigit = 1;

// Determine how many hex digits to emit for the fractional part
int defaultHexDigits = (mantissaBits + 3) / 4;

if (precision == 0)
{
// Round significandBits into the leading digit
ulong half = (mantissaBits > 0) ? (1UL << (mantissaBits - 1)) : 0;
if (significandBits > half || (significandBits == half && (leadingDigit & 1) != 0))
{
leadingDigit++;
// leadingDigit can't exceed 2 since it started at 1
}

significandBits = 0;
}
Comment thread
danmoseley marked this conversation as resolved.

vlb.Append(TChar.CastFrom((char)('0' + leadingDigit)));

if (precision > 0)
{
ulong shifted;

if (precision < defaultHexDigits)
{
// Need to round
int bitsToKeep = precision * 4;
int bitsToDiscard = mantissaBits - bitsToKeep;

// bitsToDiscard is always in (0, mantissaBits) here because precision >= 1
// (we're in the precision > 0 branch) and precision < defaultHexDigits
// (checked above), so bitsToKeep < mantissaBits and bitsToDiscard > 0.
// For all IEEE types mantissaBits <= 52, so bitsToDiscard < 64.
Debug.Assert(bitsToDiscard > 0 && bitsToDiscard < 64);
if (bitsToDiscard > 0 && bitsToDiscard < 64)
{
ulong roundBit = 1UL << (bitsToDiscard - 1);
ulong discardedBits = significandBits & ((1UL << bitsToDiscard) - 1);
bool roundUp = discardedBits > roundBit || (discardedBits == roundBit && ((significandBits >> bitsToDiscard) & 1) != 0);

if (roundUp)
{
significandBits = (significandBits >> bitsToDiscard) + 1;

// Check if rounding overflowed into leading digit
if (significandBits >= (1UL << bitsToKeep))
{
significandBits = 0;
actualExponent++;
}
}
else
{
significandBits >>= bitsToDiscard;
}

shifted = significandBits << (64 - bitsToKeep);
}
else
{
shifted = significandBits << (64 - mantissaBits);
}
}
else
{
shifted = significandBits << (64 - mantissaBits);
}

vlb.Append(info.NumberDecimalSeparatorTChar<TChar>());

// Emit real nibbles
int realDigits = Math.Min(precision, defaultHexDigits);
for (int i = 0; i < realDigits; i++)
{
vlb.Append(TChar.CastFrom(fmt == 'X' ? HexConverter.ToCharUpper((int)(shifted >> 60)) : HexConverter.ToCharLower((int)(shifted >> 60))));
shifted <<= 4;
}

// Emit padding zeros (when precision > defaultHexDigits)
int padCount = precision - realDigits;
if (padCount > 0)
{
vlb.AppendSpan(padCount).Fill(TChar.CastFrom('0'));
}
}
else if (precision < 0)
{
// Default precision: emit significant hex digits, trimming trailing zeros.
// Compute trailing zero nibbles from the nibble-aligned representation.
int trimmedDigits = 0;
if (significandBits != 0)
{
// Align significand to nibble boundary (pad LSB so total bits = defaultHexDigits * 4),
// then count trailing zero nibbles via trailing zero bits.
int paddingBits = defaultHexDigits * 4 - mantissaBits;
ulong nibbleAligned = significandBits << paddingBits;
int trailingZeroBits = BitOperations.TrailingZeroCount(nibbleAligned);
trimmedDigits = defaultHexDigits - (trailingZeroBits / 4);

if (trimmedDigits > 0)
{
vlb.Append(info.NumberDecimalSeparatorTChar<TChar>());

ulong shifted = significandBits << (64 - mantissaBits);
for (int i = 0; i < trimmedDigits; i++)
{
vlb.Append(TChar.CastFrom(fmt == 'X' ? HexConverter.ToCharUpper((int)(shifted >> 60)) : HexConverter.ToCharLower((int)(shifted >> 60))));
shifted <<= 4;
}
}
}
}

// Emit exponent: p+NNN or p-NNN
// The exponent sign is always ASCII '+'/'-' per IEEE 754 §5.12.3,
// independent of NumberFormatInfo (which only governs the leading value sign).
vlb.Append(TChar.CastFrom(fmt == 'X' ? 'P' : 'p'));

if (actualExponent >= 0)
{
vlb.Append(TChar.CastFrom('+'));
}
else
{
vlb.Append(TChar.CastFrom('-'));
actualExponent = -actualExponent;
}

// Write exponent digits
Debug.Assert(actualExponent >= 0);
int digitCount = FormattingHelpers.CountDigits((uint)actualExponent);
TChar* pExponent = stackalloc TChar[digitCount];
UInt32ToDecChars(pExponent + digitCount, (uint)actualExponent);
vlb.Append(new ReadOnlySpan<TChar>(pExponent, digitCount));
}

public static string FormatFloat<TNumber>(TNumber value, string? format, NumberFormatInfo info)
where TNumber : unmanaged, IBinaryFloatParseAndFormatInfo<TNumber>
{
Expand Down Expand Up @@ -606,6 +809,14 @@ public static bool TryFormatFloat<TNumber, TChar>(TNumber value, ReadOnlySpan<ch
}

char fmt = ParseFormatSpecifier(format, out int precision);

// Handle hex float formatting (X/x format specifier)
if ((fmt | 0x20) == 'x')
{
FormatFloatingPointAsHex(ref vlb, value, fmt, precision, info);
return null;
}

byte* pDigits = stackalloc byte[TNumber.NumberBufferLength];

if (fmt == '\0')
Expand Down
Loading
Loading