diff --git a/PreciseNumber.Test/PreciseNumberTests.cs b/PreciseNumber.Test/PreciseNumberTests.cs index 70afcac..5e3b6ed 100644 --- a/PreciseNumber.Test/PreciseNumberTests.cs +++ b/PreciseNumber.Test/PreciseNumberTests.cs @@ -1523,7 +1523,9 @@ public void TestTryFormatEmptyFormat() public void TestTryFormatInvalidFormat() { PreciseNumber number = PreciseNumber.CreateFromComponents(-2, 12345); - Assert.ThrowsExactly(() => number.TryFormat(stackalloc char[50], out int charsWritten, "e", CultureInfo.InvariantCulture)); + FormatException exception = Assert.ThrowsExactly(() => number.TryFormat(stackalloc char[50], out int charsWritten, "0.00", CultureInfo.InvariantCulture)); + Assert.Contains("'0.00'", exception.Message); + Assert.Contains("F, N and E", exception.Message); } [TestMethod] @@ -1731,18 +1733,113 @@ public void TestHandlingTrailingZeros() public void TestToStringVariousFormats() { PreciseNumber number = PreciseNumber.CreateFromComponents(-2, 12345); - Assert.ThrowsExactly(() => number.ToString("E2", CultureInfo.InvariantCulture)); - Assert.ThrowsExactly(() => number.ToString("F2", CultureInfo.InvariantCulture)); - Assert.ThrowsExactly(() => number.ToString("N2", CultureInfo.InvariantCulture)); + Assert.AreEqual("1.23E+002", number.ToString("E2", CultureInfo.InvariantCulture)); + Assert.AreEqual("123.45", number.ToString("F2", CultureInfo.InvariantCulture)); + Assert.AreEqual("123.45", number.ToString("N2", CultureInfo.InvariantCulture)); } [TestMethod] public void TestTryFormatVariousFormats() { PreciseNumber number = PreciseNumber.CreateFromComponents(-2, 12345); - Assert.ThrowsExactly(() => number.TryFormat(stackalloc char[50], out int charsWritten, "E2".AsSpan(), CultureInfo.InvariantCulture)); - Assert.ThrowsExactly(() => number.TryFormat(stackalloc char[50], out int charsWritten, "F2".AsSpan(), CultureInfo.InvariantCulture)); - Assert.ThrowsExactly(() => number.TryFormat(stackalloc char[50], out int charsWritten, "N2".AsSpan(), CultureInfo.InvariantCulture)); + Span buffer = stackalloc char[50]; + Assert.IsTrue(number.TryFormat(buffer, out int charsWritten, "E2".AsSpan(), CultureInfo.InvariantCulture)); + Assert.AreEqual("1.23E+002", buffer[..charsWritten].ToString()); + Assert.IsTrue(number.TryFormat(buffer, out charsWritten, "F1".AsSpan(), CultureInfo.InvariantCulture)); + Assert.AreEqual("123.5", buffer[..charsWritten].ToString()); + Assert.IsTrue(number.TryFormat(buffer, out charsWritten, "N2".AsSpan(), CultureInfo.InvariantCulture)); + Assert.AreEqual("123.45", buffer[..charsWritten].ToString()); + } + + [TestMethod] + public void TestTryFormatStandardFormatReturnsFalseWhenDestinationTooSmall() + { + PreciseNumber number = PreciseNumber.CreateFromComponents(-2, 12345); + Assert.IsFalse(number.TryFormat(stackalloc char[5], out int charsWritten, "F2".AsSpan(), CultureInfo.InvariantCulture)); + Assert.AreEqual(0, charsWritten); + } + + [TestMethod] + [DataRow("1234.5678", "F2", "1234.57")] + [DataRow("1234.5678", "F0", "1235")] + [DataRow("1234.5", "F3", "1234.500")] + [DataRow("0.005", "F2", "0.01")] + [DataRow("-0.004", "F2", "0.00")] + [DataRow("-1.005", "F2", "-1.01")] + [DataRow("0.0001", "F6", "0.000100")] + [DataRow("12000", "F1", "12000.0")] + [DataRow("1234.5", "F", "1234.50")] + [DataRow("0", "F2", "0.00")] + [DataRow("1234567.891", "N2", "1,234,567.89")] + [DataRow("-1234567.891", "N0", "-1,234,568")] + [DataRow("999.995", "N2", "1,000.00")] + [DataRow("123", "N2", "123.00")] + [DataRow("1234.5678", "E5", "1.23457E+003")] + [DataRow("1234.5678", "e2", "1.23e+003")] + [DataRow("-0.00012345", "E3", "-1.235E-004")] + [DataRow("9.9999", "E2", "1.00E+001")] + [DataRow("5", "E0", "5E+000")] + [DataRow("0", "E2", "0.00E+000")] + [DataRow("12", "E", "1.200000E+001")] + [DataRow("1234.5678", "R", "1234.5678")] + [DataRow("1234.5678", "r", "1234.5678")] + [DataRow("1234.5678", "G5", "1234.6")] + [DataRow("1234.5678", "G0", "1234.5678")] + public void TestToStringStandardFormats(string value, string format, string expected) + { + PreciseNumber number = PreciseNumber.Parse(value, CultureInfo.InvariantCulture); + Assert.AreEqual(expected, number.ToString(format, CultureInfo.InvariantCulture)); + } + + [TestMethod] + public void TestInterpolatedFixedPointFormat() + { + PreciseNumber number = PreciseNumber.Parse("3.14159", CultureInfo.InvariantCulture); + Assert.AreEqual("Value: 3.14", string.Create(CultureInfo.InvariantCulture, $"Value: {number:F2}")); + Assert.AreEqual("3.14", string.Format(CultureInfo.InvariantCulture, "{0:N2}", number)); + } + + [TestMethod] + public void TestStandardFormatsUseCultureSeparators() + { + PreciseNumber number = PreciseNumber.Parse("-1234567.891", CultureInfo.InvariantCulture); + NumberFormatInfo german = (NumberFormatInfo)CultureInfo.InvariantCulture.NumberFormat.Clone(); + german.NumberDecimalSeparator = ","; + german.NumberGroupSeparator = "."; + Assert.AreEqual("-1.234.567,89", number.ToString("N2", german)); + Assert.AreEqual("-1234567,89", number.ToString("F2", german)); + Assert.AreEqual("-1,23E+006", number.ToString("E2", german)); + } + + [TestMethod] + public void TestNumberFormatHonoursGroupSizesAndNegativePattern() + { + NumberFormatInfo numberFormat = (NumberFormatInfo)CultureInfo.InvariantCulture.NumberFormat.Clone(); + numberFormat.NumberGroupSizes = [3, 2]; + numberFormat.NumberNegativePattern = 0; + PreciseNumber number = PreciseNumber.Parse("-123456789", CultureInfo.InvariantCulture); + Assert.AreEqual("(12,34,56,789)", number.ToString("N0", numberFormat)); + } + + [TestMethod] + public void TestScientificFormatReachesTinyExponentsWithoutFixedPointExpansion() + { + PreciseNumber number = PreciseNumber.CreateFromComponents(-1000000, 1); + Assert.AreEqual("1.00E-1000000", number.ToString("E2", CultureInfo.InvariantCulture)); + } + + [TestMethod] + [DataRow("0.00")] + [DataRow("#,##0")] + [DataRow("X")] + [DataRow("C2")] + [DataRow("R2")] + [DataRow("F1000000000")] + public void TestToStringUnsupportedFormatThrowsDescriptiveFormatException(string format) + { + PreciseNumber number = PreciseNumber.CreateFromComponents(-2, 12345); + FormatException exception = Assert.ThrowsExactly(() => number.ToString(format, CultureInfo.InvariantCulture)); + Assert.Contains(format, exception.Message); } [TestMethod] diff --git a/PreciseNumber/PreciseNumber.Formatting.cs b/PreciseNumber/PreciseNumber.Formatting.cs new file mode 100644 index 0000000..d341ab1 --- /dev/null +++ b/PreciseNumber/PreciseNumber.Formatting.cs @@ -0,0 +1,347 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber; + +using System; +using System.Globalization; +using System.Numerics; +using System.Text; + +/// +/// The standard numeric format strings other than the general one: fixed point (F), number +/// (N), scientific (E), round trip (R) and general with a precision (Gn). +/// +/// +/// Each specifier means what it means for , worked on the exact digits rather +/// than through a binary type, and rounds half away from zero like . Custom +/// format strings such as 0.00 are not supported and throw a +/// that names the ones that are. +/// +public readonly partial record struct PreciseNumber +{ + /// + /// The largest precision a standard format string accepts, matching the runtime's own limit. + /// + private const int MaxFormatPrecision = 999_999_999; + + /// + /// The default number of fractional digits E writes when no precision is given. + /// + private const int DefaultScientificPrecision = 6; + + /// + /// The fewest digits E writes for the exponent, matching the runtime's own types. + /// + private const int MinScientificExponentDigits = 3; + + /// + /// Returns whether a format string asks for the general, shortest exact rendering that + /// writes directly: empty, G, G0 or R. + /// + /// The format string. + /// when the format is the plain general one. + private static bool IsPlainGeneralFormat(ReadOnlySpan format) => + format.IsEmpty + || format.Equals("G", StringComparison.OrdinalIgnoreCase) + || format.Equals("G0", StringComparison.OrdinalIgnoreCase) + || format.Equals("R", StringComparison.OrdinalIgnoreCase); + + /// + /// Renders this number with a standard format string other than the plain general one. + /// + /// The format string. + /// The culture's number formatting information. + /// The rendered text. + /// Thrown when the format string is not one of the supported standard formats. + /// Thrown when the text would be longer than any string can hold. + private string FormatStandard(ReadOnlySpan format, NumberFormatInfo numberFormat) + { + if (!TryParseStandardFormat(format, out char specifier, out int? precision)) + { + throw new FormatException( + $"Format string '{format}' is not supported. PreciseNumber supports the standard formats G, R, F, N and E, each optionally followed by a precision such as F2."); + } + + return char.ToUpperInvariant(specifier) switch + { + 'F' => FormatFixedPoint(precision ?? numberFormat.NumberDecimalDigits, numberFormat, grouped: false), + 'N' => FormatFixedPoint(precision ?? numberFormat.NumberDecimalDigits, numberFormat, grouped: true), + 'E' => FormatScientific(precision ?? DefaultScientificPrecision, specifier, numberFormat), + _ => FormatGeneralWithPrecision(precision ?? 0, numberFormat), + }; + } + + /// + /// Splits a standard format string into its specifier and optional precision. + /// + /// The format string. + /// The specifier letter, as written. + /// The precision, or when none was given. + /// when the format is one of the supported standard formats. + private static bool TryParseStandardFormat(ReadOnlySpan format, out char specifier, out int? precision) + { + specifier = default; + precision = null; + if (format.IsEmpty || char.ToUpperInvariant(format[0]) is not ('F' or 'N' or 'E' or 'G' or 'R')) + { + return false; + } + + specifier = format[0]; + ReadOnlySpan digits = format[1..]; + if (digits.IsEmpty) + { + return true; + } + + // R takes no precision, as with the runtime's own types. + if (char.ToUpperInvariant(specifier) == 'R' + || !int.TryParse(digits, NumberStyles.None, InvariantCulture, out int parsed) + || parsed > MaxFormatPrecision) + { + return false; + } + + precision = parsed; + return true; + } + + /// + /// Renders the F and N formats: rounded to fractional + /// digits and padded out to exactly that many. + /// + /// The number of fractional digits. + /// The culture's number formatting information. + /// Whether to group the integral digits and apply the culture's negative pattern, as N does. + /// The rendered text. + private string FormatFixedPoint(int precision, NumberFormatInfo numberFormat, bool grouped) + { + PreciseNumber rounded = Round(precision); + string digits = BigInteger.Abs(rounded.Significand).ToString(InvariantCulture); + + // Rounding leaves at most precision fractional digits, so the exponent is no lower than + // -precision and the fraction only ever needs padding on the right. + long integralLength; + long fractionalLength; + if (rounded.Significand.IsZero || rounded.Exponent >= 0) + { + integralLength = rounded.Significand.IsZero ? 1 : digits.Length + (long)rounded.Exponent; + fractionalLength = 0; + } + else + { + fractionalLength = -(long)rounded.Exponent; + integralLength = long.Max(digits.Length - fractionalLength, 1); + } + + string decimalSeparator = numberFormat.NumberDecimalSeparator; + long bound = (integralLength * (1 + numberFormat.NumberGroupSeparator.Length)) + + precision + + decimalSeparator.Length + + numberFormat.NegativeSign.Length + + 4; + ThrowIfLongerThanAnyString(bound); + + string integral = IntegralDigits(rounded, digits, fractionalLength); + + StringBuilder body = new((int)bound); + if (grouped) + { + AppendGrouped(body, integral, numberFormat); + } + else + { + _ = body.Append(integral); + } + + if (precision > 0) + { + _ = body.Append(decimalSeparator); + if (fractionalLength > 0) + { + int written = (int)long.Min(digits.Length, fractionalLength); + _ = body.Append('0', (int)(fractionalLength - written)) + .Append(digits, digits.Length - written, written); + } + + _ = body.Append('0', (int)(precision - fractionalLength)); + } + + return rounded.Significand.Sign < 0 + ? ApplyNegativePattern(body.ToString(), numberFormat, grouped) + : body.ToString(); + } + + /// + /// Returns the integral digits of a number rounded for fixed point, with a single "0" when it has none. + /// + /// The rounded number. + /// The digits of its significand's magnitude. + /// How many of those digits fall after the decimal separator. + /// The integral digits. + private static string IntegralDigits(PreciseNumber rounded, string digits, long fractionalLength) + { + if (rounded.Significand.IsZero || digits.Length <= fractionalLength) + { + return "0"; + } + + return rounded.Exponent >= 0 + ? digits + new string('0', rounded.Exponent) + : digits[..(int)(digits.Length - fractionalLength)]; + } + + /// + /// Marks a rendered magnitude as negative, with the culture's number negative pattern for N + /// and a leading negative sign for F. + /// + /// The rendered magnitude. + /// The culture's number formatting information. + /// Whether this is the N format. + /// The negative rendering. + private static string ApplyNegativePattern(string body, NumberFormatInfo numberFormat, bool grouped) + { + string sign = numberFormat.NegativeSign; + return !grouped + ? sign + body + : numberFormat.NumberNegativePattern switch + { + 0 => $"({body})", + 2 => $"{sign} {body}", + 3 => $"{body}{sign}", + 4 => $"{body} {sign}", + _ => sign + body, + }; + } + + /// + /// Appends integral digits with the culture's group separator between groups. + /// + /// The builder to append to. + /// The integral digits, most significant first. + /// The culture's number formatting information. + private static void AppendGrouped(StringBuilder builder, string integral, NumberFormatInfo numberFormat) + { + int[] groupSizes = numberFormat.NumberGroupSizes; + string separator = numberFormat.NumberGroupSeparator; + + // Group boundaries are counted from the right. The last size repeats, and a size of zero + // leaves everything to its left in one group. + Span boundaries = integral.Length <= 256 ? stackalloc int[integral.Length] : new int[integral.Length]; + int boundaryCount = 0; + int position = integral.Length; + int sizeIndex = 0; + while (groupSizes.Length > 0) + { + int size = groupSizes[sizeIndex]; + if (size <= 0 || position - size <= 0) + { + break; + } + + position -= size; + boundaries[boundaryCount++] = position; + sizeIndex = int.Min(sizeIndex + 1, groupSizes.Length - 1); + } + + int start = 0; + for (int i = boundaryCount - 1; i >= 0; i--) + { + _ = builder.Append(integral, start, boundaries[i] - start).Append(separator); + start = boundaries[i]; + } + + _ = builder.Append(integral, start, integral.Length - start); + } + + /// + /// Renders the E format: one digit before the separator, + /// after it, then the exponent with a sign and at least three digits. + /// + /// The number of fractional digits. + /// The specifier as written, whose case the exponent marker follows. + /// The culture's number formatting information. + /// The rendered text. + private string FormatScientific(int precision, char specifier, NumberFormatInfo numberFormat) + { + long bound = (long)precision + + numberFormat.NegativeSign.Length + + numberFormat.NumberDecimalSeparator.Length + + numberFormat.PositiveSign.Length + + 16; + ThrowIfLongerThanAnyString(bound); + + string digits; + long exponent; + if (Significand.IsZero) + { + digits = "0"; + exponent = 0; + } + else + { + BigInteger magnitude = BigInteger.Abs(Significand); + exponent = (long)Exponent + SignificantDigits - 1; + int kept = precision + 1; + if (SignificantDigits > kept) + { + magnitude = DropDigitsRoundingHalfAwayFromZero(magnitude, SignificantDigits - kept); + } + + digits = magnitude.ToString(InvariantCulture); + + // Rounding 9.99… up carries into a new leading digit, a one followed only by zeros, which + // moves the exponent and leaves one digit more than was kept. + if (digits.Length > kept) + { + digits = digits[..kept]; + exponent++; + } + } + + StringBuilder builder = new((int)bound); + if (Significand.Sign < 0) + { + _ = builder.Append(numberFormat.NegativeSign); + } + + _ = builder.Append(digits[0]); + if (precision > 0) + { + _ = builder.Append(numberFormat.NumberDecimalSeparator) + .Append(digits, 1, digits.Length - 1) + .Append('0', precision - (digits.Length - 1)); + } + + _ = builder.Append(char.IsLower(specifier) ? 'e' : 'E') + .Append(exponent < 0 ? numberFormat.NegativeSign : numberFormat.PositiveSign) + .Append(long.Abs(exponent).ToString(InvariantCulture).PadLeft(MinScientificExponentDigits, '0')); + return builder.ToString(); + } + + /// + /// Renders the G format with a precision: reduced to that many significant digits, then + /// written like the plain general format. + /// + /// The number of significant digits, where zero keeps them all. + /// The culture's number formatting information. + /// The rendered text. + private string FormatGeneralWithPrecision(int precision, NumberFormatInfo numberFormat) + { + PreciseNumber reduced = precision > 0 ? ReduceSignificance(precision) : this; + return ToString(reduced, null, numberFormat); + } + + /// + /// Throws when a rendering would be longer than any string can hold. + /// + /// An upper bound on the rendering's length. + /// Thrown when exceeds . + private static void ThrowIfLongerThanAnyString(long length) + { + if (length > Array.MaxLength) + { + throw new OverflowException("The formatted text would be longer than a string can hold."); + } + } +} diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index 066ea33..bcf1511 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -495,6 +495,11 @@ public override int GetHashCode() public static string ToString(PreciseNumber number, string? format, IFormatProvider? formatProvider) { NumberFormatInfo numberFormat = NumberFormatInfo.GetInstance(formatProvider ?? InvariantCulture); + if (!IsPlainGeneralFormat(format)) + { + return number.FormatStandard(format, numberFormat); + } + long desiredAlloc = number.GetFormattedLengthBound(numberFormat); char[]? rentedBuffer = desiredAlloc > MaxStackAllocChars ? ArrayPool.Shared.Rent((int)desiredAlloc) : null; @@ -1385,15 +1390,23 @@ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, out TryParse(s, NumberStyles.Any, provider, out result); /// + /// Thrown when is not one of the standard formats G, R, F, N and E, optionally followed by a precision. /// Thrown when the text would be longer than any string can hold, whatever the size of . public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) { - if (!format.IsEmpty && !format.Equals("G", StringComparison.OrdinalIgnoreCase)) + NumberFormatInfo numberFormat = NumberFormatInfo.GetInstance(provider ?? InvariantCulture); + if (!IsPlainGeneralFormat(format)) { - throw new FormatException(); - } + string text = FormatStandard(format, numberFormat); + if (!text.TryCopyTo(destination)) + { + charsWritten = 0; + return false; + } - NumberFormatInfo numberFormat = NumberFormatInfo.GetInstance(provider ?? InvariantCulture); + charsWritten = text.Length; + return true; + } // Throws for text no string could hold, so that callers which grow their buffer on false // fail promptly instead of retrying without end. diff --git a/README.md b/README.md index 8d93a88..9c48f1c 100644 --- a/README.md +++ b/README.md @@ -326,13 +326,22 @@ if (PreciseNumber.TryParse("456.789", out var result)) Convert PreciseNumber to string: ```csharp +using System.Globalization; using ktsu.PreciseNumber; var number = 123.456.ToPreciseNumber(); string formatted = number.ToString(); // "123.456" +// Standard format strings, rounded half away from zero +number.ToString("F2", CultureInfo.InvariantCulture); // "123.46" +number.ToString("N1", CultureInfo.InvariantCulture); // "123.5" +number.ToString("E3", CultureInfo.InvariantCulture); // "1.235E+002" +number.ToString("G4", CultureInfo.InvariantCulture); // "123.5" + ``` +`G` (or empty) and `R` write the exact value. `F`, `N`, `E` and `G` take an optional precision, and `N` uses the culture's group separator and negative pattern. Custom format strings such as `0.00` throw a `FormatException` that names the supported formats. + ## Comparison with Built-in Types ### PreciseNumber vs. double/float