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
54 changes: 54 additions & 0 deletions PreciseNumber.Test/PreciseNumberParseAffixTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,12 @@ public class PreciseNumberParseAffixTests
NumberGroupSeparator = ".",
};

// Writes negative numbers as "- n", the one negative pattern after whose sign the BCL takes white space.
private static readonly NumberFormatInfo SpacedSign = new()
{
NumberNegativePattern = 2,
};

// Mixed and Swiss separate money differently from other numbers, as en-DE and fr-CH do. A style
// that allows a currency symbol reads the currency separators first, and the number separators
// too unless a symbol comes ahead of the digits.
Expand All @@ -57,6 +63,7 @@ public class PreciseNumberParseAffixTests
{
"Dollars" => Dollars,
"Euros" => Euros,
"SpacedSign" => SpacedSign,
"Mixed" => Mixed,
"Swiss" => Swiss,
_ => NumberFormatInfo.InvariantInfo,
Expand All @@ -77,6 +84,24 @@ public class PreciseNumberParseAffixTests
[DataRow("1.234,50 €", NumberStyles.Currency, "Euros")]
[DataRow("-1.234,50 €", NumberStyles.Currency, "Euros")]
[DataRow("1.234,50 €-", NumberStyles.Currency, "Euros")]
[DataRow("5 -", NumberStyles.Number, "Invariant")]
[DataRow("5\t-", NumberStyles.Number, "Invariant")]
[DataRow(" 5 - ", NumberStyles.Number, "Invariant")]
[DataRow("5 -", NumberStyles.Number, "Invariant")]
[DataRow("5 +", NumberStyles.Number, "Invariant")]
[DataRow("1,234.50 -", NumberStyles.Number, "Invariant")]
[DataRow("(5 )", NumberStyles.Number | NumberStyles.AllowParentheses, "Invariant")]
[DataRow("- 5", NumberStyles.Number, "SpacedSign")]
[DataRow("- 1,234.50", NumberStyles.Number, "SpacedSign")]
[DataRow("( 5)", NumberStyles.Number | NumberStyles.AllowParentheses, "SpacedSign")]
[DataRow("$\t5", NumberStyles.Currency, "Dollars")]
[DataRow("5\t$", NumberStyles.Currency, "Dollars")]
[DataRow("$ 5", NumberStyles.Currency, "Dollars")]
[DataRow("$- 5", NumberStyles.Currency, "Dollars")]
[DataRow("$ - 5", NumberStyles.Currency, "Dollars")]
[DataRow("-$ 5", NumberStyles.Currency, "Dollars")]
[DataRow("5 $ -", NumberStyles.Currency, "Dollars")]
[DataRow("5 - $", NumberStyles.Currency, "Dollars")]
[DataRow("1.5", NumberStyles.Any, "Mixed")]
[DataRow("1.5", NumberStyles.Currency, "Mixed")]
[DataRow("1.25", NumberStyles.Any, "Mixed")]
Expand Down Expand Up @@ -138,6 +163,15 @@ public void TestParseAcceptsANoBreakSpaceBeforeACurrencySymbol()
[DataRow("()", NumberStyles.Any, "Invariant")]
[DataRow("$", NumberStyles.Any, "Dollars")]
[DataRow("-", NumberStyles.Any, "Invariant")]
[DataRow("- 5", NumberStyles.Number, "Invariant")]
[DataRow("+ 5", NumberStyles.Number, "Invariant")]
[DataRow("( 5)", NumberStyles.Number | NumberStyles.AllowParentheses, "Invariant")]
[DataRow("5 -", NumberStyles.AllowTrailingSign, "Invariant")]
[DataRow("- $5", NumberStyles.Currency, "Dollars")]
[DataRow("$ 5", NumberStyles.AllowCurrencySymbol | NumberStyles.AllowLeadingSign, "Dollars")]
[DataRow("$- 5", NumberStyles.AllowCurrencySymbol | NumberStyles.AllowLeadingSign, "Dollars")]
[DataRow("-$ 5", NumberStyles.AllowCurrencySymbol | NumberStyles.AllowLeadingSign, "Dollars")]
[DataRow("5 $", NumberStyles.AllowCurrencySymbol | NumberStyles.AllowTrailingSign, "Dollars")]
[DataRow("CHF 1,5", NumberStyles.Currency, "Swiss")]
public void TestParseRejectsWhatDecimalRejects(string text, NumberStyles style, string format)
{
Expand All @@ -147,4 +181,24 @@ public void TestParseRejectsWhatDecimalRejects(string text, NumberStyles style,
Assert.IsFalse(PreciseNumber.TryParse(text, style, numberFormat, out _), $"TryParse(\"{text}\", {style}) accepted it");
Assert.ThrowsExactly<FormatException>(() => PreciseNumber.Parse(text, style, numberFormat), $"Parse(\"{text}\", {style}) accepted it");
}

[TestMethod]
[DataRow(0)]
[DataRow(1)]
[DataRow(2)]
[DataRow(3)]
[DataRow(4)]
public void TestParseReadsBackEveryNumberNegativePattern(int pattern)
{
NumberFormatInfo numberFormat = (NumberFormatInfo)NumberFormatInfo.InvariantInfo.Clone();
numberFormat.NumberNegativePattern = pattern;

// Pattern 0 writes "(n)", which only AllowParentheses reads, for decimal as for PreciseNumber
NumberStyles style = pattern == 0 ? NumberStyles.Number | NumberStyles.AllowParentheses : NumberStyles.Number;
PreciseNumber value = PreciseNumber.Parse("-1234.5", CultureInfo.InvariantCulture);
string text = value.ToString("N2", numberFormat);

Assert.AreEqual(decimal.Parse(text, style, numberFormat).ToPreciseNumber(), value, $"decimal reads \"{text}\" differently, so the case is wrong");
Assert.AreEqual(value, PreciseNumber.Parse(text, style, numberFormat), $"Parse(\"{text}\") under pattern {pattern} is wrong");
}
}
62 changes: 55 additions & 7 deletions PreciseNumber/PreciseNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -49,8 +49,16 @@
/// </summary>
private const string InvalidFormatMessage = "Input string was not in a correct format.";

/// <summary>The spaces a culture may put between a currency symbol and the number, as de-DE does in <c>1.234,50 €</c>.</summary>
private static readonly char[] CurrencySymbolSpaces = [' ', '\u00A0', '\u202F'];
/// <summary>
/// The rendered spaces a culture may put between a currency symbol and the number, as Windows' de-DE does in
/// <c>1.234,50 €</c> with U+00A0. They are accepted next to the symbol whatever the style. An ordinary space or
/// other white space there is accepted only as <see cref="NumberStyles.AllowLeadingWhite"/> and
/// <see cref="NumberStyles.AllowTrailingWhite"/> allow it, as the BCL parsers do.
/// </summary>
private const string CurrencySymbolSpaces = "\u00A0\u202F";

/// <summary>The rendered spaces of <see cref="CurrencySymbolSpaces"/> and the white space of <see cref="AllowedWhite"/>.</summary>
private const string CurrencySymbolSpacesAndWhite = CurrencySymbolSpaces + AllowedWhite;

private const string UnsupportedParseStylesMessage = "The number styles AllowHexSpecifier and AllowBinarySpecifier are not supported by PreciseNumber.";

Expand Down Expand Up @@ -748,7 +756,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 759 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Remove this method and declare a constant for this value.

Check warning on line 759 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

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

Expand Down Expand Up @@ -1209,7 +1217,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 1220 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

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

Check warning on line 1220 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'Parse' method overloads should be adjacent.

Check warning on line 1220 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

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

Check warning on line 1220 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

All 'Parse' method overloads should be adjacent.
{
// Read the separator and signs from the same culture TryFormat writes them with, so that
// Parse(x.ToString(p), p) round-trips for every provider.
Expand Down Expand Up @@ -1269,7 +1277,7 @@
}

hasDecimal = true;
i += decimalLength - 1;

Check warning on line 1280 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 1280 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.
continue;
}

Expand All @@ -1277,7 +1285,7 @@
int groupLength = allowThousands && !hasDecimal && digitCount > 0 ? MatchSeparator(s[i..], groupSeparator, numberGroupSeparator) : 0;
if (groupLength > 0)
{
i += groupLength - 1;

Check warning on line 1288 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.

Check warning on line 1288 in PreciseNumber/PreciseNumber.cs

View workflow job for this annotation

GitHub Actions / ci / .NET / Analyze & Release

Do not update the stop condition variable 'i' in the body of the for loop.
continue;
}

Expand Down Expand Up @@ -1481,8 +1489,13 @@
/// <remarks>
/// Mirrors the BCL parsers. Parentheses mean negative and may not be combined with a sign. The sign
/// and the currency symbol may come in either order on either side, as <c>-$1.5</c>, <c>$-1.5</c>
/// and <c>1,5 €-</c> do, and a space or no-break space may separate the symbol from the number,
/// since cultures such as de-DE write <c>1.234,50 €</c>. At most one sign and one symbol are read.
/// and <c>1,5 €-</c> do. At most one sign and one symbol are read. White space may separate them
/// from each other and from the digits as the BCL allows it: on the trailing side wherever
/// <see cref="NumberStyles.AllowTrailingWhite"/> is set, as <c>1,234.50 -</c> (negative pattern 4)
/// needs, and on the leading side under <see cref="NumberStyles.AllowLeadingWhite"/> after a currency
/// symbol, or after a sign or opening parenthesis once a currency symbol has been read or when the
/// format's <see cref="NumberFormatInfo.NumberNegativePattern"/> is 2, <c>- 1,234.50</c>.
/// A no-break space next to a currency symbol is accepted under any style.
/// </remarks>
/// <exception cref="FormatException">Thrown when the parentheses enclose a sign.</exception>
private static ReadOnlySpan<char> StripAffixes(ReadOnlySpan<char> s, NumberStyles style, NumberFormatInfo numberFormat, out bool isNegative, out bool hasLeadingCurrencySymbol)
Expand All @@ -1491,6 +1504,17 @@
if (hasParentheses)
{
s = s[1..^1];

// The opening parenthesis counts as a sign for the white space after it, as in the BCL
if (style.HasFlag(NumberStyles.AllowLeadingWhite) && numberFormat.NumberNegativePattern == 2)
{
s = s.TrimStart(AllowedWhite.AsSpan());
}

if (style.HasFlag(NumberStyles.AllowTrailingWhite))
{
s = s.TrimEnd(AllowedWhite.AsSpan());
}
}

ReadOnlySpan<char> currencySymbol = style.HasFlag(NumberStyles.AllowCurrencySymbol) ? numberFormat.CurrencySymbol : [];
Expand Down Expand Up @@ -1520,6 +1544,7 @@
/// </summary>
private static void StripLeadingAffixes(ref ReadOnlySpan<char> s, NumberStyles style, NumberFormatInfo numberFormat, ReadOnlySpan<char> currencySymbol, ref bool hasSign, ref bool isNegative, ref bool hasCurrencySymbol)
{
bool allowLeadingWhite = style.HasFlag(NumberStyles.AllowLeadingWhite);
for (int pass = 0; pass < 2 && !s.IsEmpty; pass++)
{
bool leadingNegative = false;
Expand All @@ -1528,12 +1553,12 @@
{
hasSign = true;
isNegative = leadingNegative;
s = s[signLength..];
s = TrimWhiteAfterLeadingSign(s[signLength..], allowLeadingWhite, numberFormat, hasCurrencySymbol);
}
else if (!hasCurrencySymbol && !currencySymbol.IsEmpty && s.StartsWith(currencySymbol, StringComparison.Ordinal))
{
hasCurrencySymbol = true;
s = s[currencySymbol.Length..].TrimStart(CurrencySymbolSpaces);
s = s[currencySymbol.Length..].TrimStart(SpacesBesideCurrencySymbol(allowLeadingWhite));
}
else
{
Expand All @@ -1542,18 +1567,35 @@
}
}

/// <summary>
/// Trims the white space after a leading sign that the BCL takes: only under
/// <see cref="NumberStyles.AllowLeadingWhite"/>, and only once a currency symbol has been read or when the
/// format writes its negative numbers as <c>- n</c>.
/// </summary>
private static ReadOnlySpan<char> TrimWhiteAfterLeadingSign(ReadOnlySpan<char> s, bool allowLeadingWhite, NumberFormatInfo numberFormat, bool hasCurrencySymbol) =>
allowLeadingWhite && (hasCurrencySymbol || numberFormat.NumberNegativePattern == 2)
? s.TrimStart(AllowedWhite.AsSpan())
: s;

/// <summary>
/// The characters trimmed beside a currency symbol: its rendered spaces, and white space too when the style allows it on that side.
/// </summary>
private static ReadOnlySpan<char> SpacesBesideCurrencySymbol(bool allowWhite) =>
(allowWhite ? CurrencySymbolSpacesAndWhite : CurrencySymbolSpaces).AsSpan();

/// <summary>
/// Removes a trailing currency symbol and a trailing sign, in either order, for <see cref="StripAffixes"/>.
/// </summary>
private static void StripTrailingAffixes(ref ReadOnlySpan<char> s, NumberStyles style, NumberFormatInfo numberFormat, ReadOnlySpan<char> currencySymbol, ref bool hasSign, ref bool isNegative, ref bool hasCurrencySymbol)
{
bool allowTrailingSign = style.HasFlag(NumberStyles.AllowTrailingSign);
bool allowTrailingWhite = style.HasFlag(NumberStyles.AllowTrailingWhite);
for (int pass = 0; pass < 2 && !s.IsEmpty; pass++)
{
if (!hasCurrencySymbol && !currencySymbol.IsEmpty && s.EndsWith(currencySymbol, StringComparison.Ordinal))
{
hasCurrencySymbol = true;
s = s[..^currencySymbol.Length].TrimEnd(CurrencySymbolSpaces);
s = s[..^currencySymbol.Length].TrimEnd(SpacesBesideCurrencySymbol(allowTrailingWhite));
continue;
}

Expand All @@ -1567,6 +1609,12 @@
hasSign = true;
isNegative = trailingNegative;
s = s[..^signLength];

// The BCL takes white space anywhere among the trailing affixes under AllowTrailingWhite
if (allowTrailingWhite)
{
s = s.TrimEnd(AllowedWhite.AsSpan());
}
}
}

Expand Down
Loading