From 2e33e4d66e62fd2170f603f3d1783e176707a804 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:30:46 +0000 Subject: [PATCH 1/2] Support the F, N, E, R and G standard format strings [patch] ToString and TryFormat threw a bare FormatException for every format other than empty or "G", so $"{x:F2}", "N2" and logging templates crashed at runtime. Fixed point (F), number (N, with the culture's group sizes, separator and negative pattern) and scientific (E) now render from the exact digits, rounding half away from zero like Round. R is the exact general form, and G reduces to n significant digits first. Any other format, including custom ones such as "0.00", throws a FormatException whose message names the supported formats. Fixes ktsu-dev/PreciseNumber#134 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BPNa479nM4FXr2G22kBHmy --- PreciseNumber.Test/PreciseNumberTests.cs | 111 ++++++- PreciseNumber/PreciseNumber.Formatting.cs | 337 ++++++++++++++++++++++ PreciseNumber/PreciseNumber.cs | 21 +- README.md | 9 + 4 files changed, 467 insertions(+), 11 deletions(-) create mode 100644 PreciseNumber/PreciseNumber.Formatting.cs diff --git a/PreciseNumber.Test/PreciseNumberTests.cs b/PreciseNumber.Test/PreciseNumberTests.cs index 70afcac..196bc31 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)); + StringAssert.Contains(exception.Message, "'0.00'"); + StringAssert.Contains(exception.Message, "F, N and E"); } [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)); + StringAssert.Contains(exception.Message, format); } [TestMethod] diff --git a/PreciseNumber/PreciseNumber.Formatting.cs b/PreciseNumber/PreciseNumber.Formatting.cs new file mode 100644 index 0000000..27d6005 --- /dev/null +++ b/PreciseNumber/PreciseNumber.Formatting.cs @@ -0,0 +1,337 @@ +// 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); + + StringBuilder integral = new((int)integralLength); + if (rounded.Significand.IsZero) + { + _ = integral.Append('0'); + } + else if (rounded.Exponent >= 0) + { + _ = integral.Append(digits).Append('0', rounded.Exponent); + } + else if (digits.Length > fractionalLength) + { + _ = integral.Append(digits, 0, (int)(digits.Length - fractionalLength)); + } + else + { + _ = integral.Append('0'); + } + + StringBuilder body = new((int)bound); + if (grouped) + { + AppendGrouped(body, integral.ToString(), 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)); + } + + bool negative = rounded.Significand.Sign < 0; + if (!negative) + { + return body.ToString(); + } + + if (!grouped) + { + return numberFormat.NegativeSign + body; + } + + return numberFormat.NumberNegativePattern switch + { + 0 => $"({body})", + 2 => $"{numberFormat.NegativeSign} {body}", + 3 => $"{body}{numberFormat.NegativeSign}", + 4 => $"{body} {numberFormat.NegativeSign}", + _ => numberFormat.NegativeSign + 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 From 449bec30ddf23d4571fefb4682074d5108ef96cc Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 12 Oct 2026 18:40:54 +0000 Subject: [PATCH 2/2] Split fixed-point formatting helpers and use Assert.Contains [patch] Addresses the SonarCloud findings on this change: FormatFixedPoint's cognitive complexity (integral digits and the negative pattern now live in their own helpers) and MSTEST0046 in the new tests. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01BPNa479nM4FXr2G22kBHmy --- PreciseNumber.Test/PreciseNumberTests.cs | 6 +- PreciseNumber/PreciseNumber.Formatting.cs | 76 +++++++++++++---------- 2 files changed, 46 insertions(+), 36 deletions(-) diff --git a/PreciseNumber.Test/PreciseNumberTests.cs b/PreciseNumber.Test/PreciseNumberTests.cs index 196bc31..5e3b6ed 100644 --- a/PreciseNumber.Test/PreciseNumberTests.cs +++ b/PreciseNumber.Test/PreciseNumberTests.cs @@ -1524,8 +1524,8 @@ public void TestTryFormatInvalidFormat() { PreciseNumber number = PreciseNumber.CreateFromComponents(-2, 12345); FormatException exception = Assert.ThrowsExactly(() => number.TryFormat(stackalloc char[50], out int charsWritten, "0.00", CultureInfo.InvariantCulture)); - StringAssert.Contains(exception.Message, "'0.00'"); - StringAssert.Contains(exception.Message, "F, N and E"); + Assert.Contains("'0.00'", exception.Message); + Assert.Contains("F, N and E", exception.Message); } [TestMethod] @@ -1839,7 +1839,7 @@ public void TestToStringUnsupportedFormatThrowsDescriptiveFormatException(string { PreciseNumber number = PreciseNumber.CreateFromComponents(-2, 12345); FormatException exception = Assert.ThrowsExactly(() => number.ToString(format, CultureInfo.InvariantCulture)); - StringAssert.Contains(exception.Message, format); + Assert.Contains(format, exception.Message); } [TestMethod] diff --git a/PreciseNumber/PreciseNumber.Formatting.cs b/PreciseNumber/PreciseNumber.Formatting.cs index 27d6005..d341ab1 100644 --- a/PreciseNumber/PreciseNumber.Formatting.cs +++ b/PreciseNumber/PreciseNumber.Formatting.cs @@ -142,28 +142,12 @@ private string FormatFixedPoint(int precision, NumberFormatInfo numberFormat, bo + 4; ThrowIfLongerThanAnyString(bound); - StringBuilder integral = new((int)integralLength); - if (rounded.Significand.IsZero) - { - _ = integral.Append('0'); - } - else if (rounded.Exponent >= 0) - { - _ = integral.Append(digits).Append('0', rounded.Exponent); - } - else if (digits.Length > fractionalLength) - { - _ = integral.Append(digits, 0, (int)(digits.Length - fractionalLength)); - } - else - { - _ = integral.Append('0'); - } + string integral = IntegralDigits(rounded, digits, fractionalLength); StringBuilder body = new((int)bound); if (grouped) { - AppendGrouped(body, integral.ToString(), numberFormat); + AppendGrouped(body, integral, numberFormat); } else { @@ -183,25 +167,51 @@ private string FormatFixedPoint(int precision, NumberFormatInfo numberFormat, bo _ = body.Append('0', (int)(precision - fractionalLength)); } - bool negative = rounded.Significand.Sign < 0; - if (!negative) - { - return body.ToString(); - } + return rounded.Significand.Sign < 0 + ? ApplyNegativePattern(body.ToString(), numberFormat, grouped) + : body.ToString(); + } - if (!grouped) + /// + /// 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 numberFormat.NegativeSign + body; + return "0"; } - return numberFormat.NumberNegativePattern switch - { - 0 => $"({body})", - 2 => $"{numberFormat.NegativeSign} {body}", - 3 => $"{body}{numberFormat.NegativeSign}", - 4 => $"{body} {numberFormat.NegativeSign}", - _ => numberFormat.NegativeSign + body, - }; + 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, + }; } ///