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,10 +32,33 @@ public class PreciseNumberParseAffixTests
NumberGroupSeparator = ".",
};

// 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.
private static readonly NumberFormatInfo Mixed = new()
{
CurrencySymbol = "€",
CurrencyDecimalSeparator = ".",
CurrencyGroupSeparator = ",",
NumberDecimalSeparator = ",",
NumberGroupSeparator = ".",
};

private static readonly NumberFormatInfo Swiss = new()
{
CurrencySymbol = "CHF",
CurrencyDecimalSeparator = ".",
CurrencyGroupSeparator = " ",
NumberDecimalSeparator = ",",
NumberGroupSeparator = " ",
};

private static NumberFormatInfo Format(string name) => name switch
{
"Dollars" => Dollars,
"Euros" => Euros,
"Mixed" => Mixed,
"Swiss" => Swiss,
_ => NumberFormatInfo.InvariantInfo,
};

Expand All @@ -54,6 +77,21 @@ 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("1.5", NumberStyles.Any, "Mixed")]
[DataRow("1.5", NumberStyles.Currency, "Mixed")]
[DataRow("1.25", NumberStyles.Any, "Mixed")]
[DataRow("1.25", NumberStyles.Currency, "Mixed")]
[DataRow("1,5 €", NumberStyles.Any, "Mixed")]
[DataRow("1,5 €", NumberStyles.Currency, "Mixed")]
[DataRow("1.234,5", NumberStyles.Number, "Mixed")]
[DataRow("1.5", NumberStyles.Any, "Swiss")]
[DataRow("1.5", NumberStyles.Currency, "Swiss")]
[DataRow("1 234.5", NumberStyles.Any, "Swiss")]
[DataRow("1 234.5", NumberStyles.Currency, "Swiss")]
[DataRow("1 234,50 CHF", NumberStyles.Any, "Swiss")]
[DataRow("1 234,50 CHF", NumberStyles.Currency, "Swiss")]
[DataRow("CHF 1 234.50", NumberStyles.Currency, "Swiss")]
[DataRow("€1,5", NumberStyles.Any, "Mixed")]
public void TestParseAcceptsWhatDecimalAccepts(string text, NumberStyles style, string format)
{
NumberFormatInfo numberFormat = Format(format);
Expand All @@ -64,6 +102,21 @@ public void TestParseAcceptsWhatDecimalAccepts(string text, NumberStyles style,
Assert.AreEqual(expected, parsed, $"TryParse(\"{text}\", {style}) is wrong");
}

[TestMethod]
[DataRow("1.25", "Mixed")]
[DataRow("1.5", "Swiss")]
public void TestDefaultParseReadsCurrencySeparatorsAsDecimalDoes(string text, string format)
{
// Parse(s, provider) uses NumberStyles.Any, which allows a currency symbol, so a format whose
// money is written with other separators than its numbers (en-DE, fr-CH) reads both, as decimal does.
NumberFormatInfo numberFormat = Format(format);
PreciseNumber expected = decimal.Parse(text, NumberStyles.Any, numberFormat).ToPreciseNumber();

Assert.AreEqual(expected, PreciseNumber.Parse(text, numberFormat), $"Parse(\"{text}\", {format}) is wrong");
Assert.IsTrue(PreciseNumber.TryParse(text, numberFormat, out PreciseNumber parsed), $"TryParse(\"{text}\", {format}) failed");
Assert.AreEqual(expected, parsed, $"TryParse(\"{text}\", {format}) is wrong");
}

[TestMethod]
public void TestParseAcceptsANoBreakSpaceBeforeACurrencySymbol()
{
Expand All @@ -85,6 +138,7 @@ public void TestParseAcceptsANoBreakSpaceBeforeACurrencySymbol()
[DataRow("()", NumberStyles.Any, "Invariant")]
[DataRow("$", NumberStyles.Any, "Dollars")]
[DataRow("-", NumberStyles.Any, "Invariant")]
[DataRow("CHF 1,5", NumberStyles.Currency, "Swiss")]
public void TestParseRejectsWhatDecimalRejects(string text, NumberStyles style, string format)
{
NumberFormatInfo numberFormat = Format(format);
Expand Down
5 changes: 4 additions & 1 deletion PreciseNumber.Test/PreciseNumberParseCultureTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@ public class PreciseNumberParseCultureTests
{
// Hand-built formats keep these tests independent of the ICU data installed on the machine (and
// of globalization-invariant mode, where no named culture but the invariant one exists).
// CommaDecimal is shaped like de-DE, NarrowSpaceGroups like fr-FR.
// CommaDecimal is shaped like de-DE, NarrowSpaceGroups like fr-FR. de-DE writes money with the same
// separators as other numbers, and NumberStyles.Any reads the currency ones first, so they are set too.
private static readonly NumberFormatInfo CommaDecimal = new()
{
NumberDecimalSeparator = ",",
NumberGroupSeparator = ".",
CurrencyDecimalSeparator = ",",
CurrencyGroupSeparator = ".",
NegativeSign = "−",
PositiveSign = "+",
};
Expand Down
62 changes: 51 additions & 11 deletions PreciseNumber/PreciseNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -748,7 +748,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 751 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 751 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 +1209,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 1212 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 1212 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 1212 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 1212 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 All @@ -1229,17 +1229,16 @@
throw new FormatException(InvalidFormatMessage);
}

s = StripAffixes(s, style, numberFormat, out bool isNegative, out bool hasCurrencySymbol);
s = StripAffixes(s, style, numberFormat, out bool isNegative, out bool hasLeadingCurrencySymbol);

if (s.Length == 1 && s[0] == '0')
{
return Zero;
}

// Once a currency symbol has been read, the number is written the way the culture writes money.
ReadOnlySpan<char> decimalSeparator = hasCurrencySymbol ? numberFormat.CurrencyDecimalSeparator : numberFormat.NumberDecimalSeparator;
ReadOnlySpan<char> groupSeparator = hasCurrencySymbol ? numberFormat.CurrencyGroupSeparator : numberFormat.NumberGroupSeparator;
bool allowThousands = style.HasFlag(NumberStyles.AllowThousands) && !groupSeparator.IsEmpty;
(string decimalSeparator, string groupSeparator, string numberDecimalSeparator, string numberGroupSeparator) =
ChooseSeparators(style, numberFormat, hasLeadingCurrencySymbol);
bool allowThousands = style.HasFlag(NumberStyles.AllowThousands);
bool allowDecimalPoint = style.HasFlag(NumberStyles.AllowDecimalPoint);
bool allowExponent = style.HasFlag(NumberStyles.AllowExponent);

Expand All @@ -1260,22 +1259,24 @@
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
if (allowDecimalPoint && StartsWithSeparator(s[i..], decimalSeparator))
int decimalLength = allowDecimalPoint ? MatchSeparator(s[i..], decimalSeparator, numberDecimalSeparator) : 0;
if (decimalLength > 0)
{
if (hasDecimal)
{
throw new FormatException(InvalidFormatMessage);
}

hasDecimal = true;
i += decimalSeparator.Length - 1;
i += decimalLength - 1;

Check warning on line 1271 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 1271 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;
}

// Group separators may only sit between integral digits.
if (allowThousands && !hasDecimal && digitCount > 0 && StartsWithSeparator(s[i..], groupSeparator))
int groupLength = allowThousands && !hasDecimal && digitCount > 0 ? MatchSeparator(s[i..], groupSeparator, numberGroupSeparator) : 0;
if (groupLength > 0)
{
i += groupSeparator.Length - 1;
i += groupLength - 1;

Check warning on line 1279 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 1279 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 1279 in PreciseNumber/PreciseNumber.cs

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

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

See more on https://sonarcloud.io/project/issues?id=ktsu-dev_PreciseNumber&issues=AaHBYStdDUwocfnokPuG&open=AaHBYStdDUwocfnokPuG&pullRequest=198
continue;
}

Expand Down Expand Up @@ -1315,6 +1316,44 @@
}
}

/// <summary>
/// Chooses the separators <see cref="Parse(ReadOnlySpan{char}, NumberStyles, IFormatProvider?)"/> reads
/// between digits: a decimal and a group separator tried first, and a second pair tried after them,
/// empty when there is none.
/// </summary>
/// <remarks>
/// As in the BCL, a style that allows a currency symbol reads the culture's currency separators
/// first, and also accepts its number separators unless a symbol was read ahead of the digits.
/// Cultures such as fr-CH and en-DE separate money differently from other numbers, so <c>"1.5"</c>
/// is one and a half there under <see cref="NumberStyles.Any"/>, not fifteen.
/// </remarks>
private static (string Decimal, string Group, string NumberDecimal, string NumberGroup) ChooseSeparators(NumberStyles style, NumberFormatInfo numberFormat, bool hasLeadingCurrencySymbol)
{
if (!style.HasFlag(NumberStyles.AllowCurrencySymbol))
{
return (numberFormat.NumberDecimalSeparator, numberFormat.NumberGroupSeparator, string.Empty, string.Empty);
}

return hasLeadingCurrencySymbol
? (numberFormat.CurrencyDecimalSeparator, numberFormat.CurrencyGroupSeparator, string.Empty, string.Empty)
: (numberFormat.CurrencyDecimalSeparator, numberFormat.CurrencyGroupSeparator, numberFormat.NumberDecimalSeparator, numberFormat.NumberGroupSeparator);
}

/// <summary>
/// Returns the length of whichever of <paramref name="separator"/> and <paramref name="alternative"/>
/// <paramref name="s"/> starts with, trying <paramref name="separator"/> first, or zero when it starts
/// with neither. An empty separator never matches.
/// </summary>
private static int MatchSeparator(ReadOnlySpan<char> s, ReadOnlySpan<char> separator, ReadOnlySpan<char> alternative)
{
if (!separator.IsEmpty && StartsWithSeparator(s, separator))
{
return separator.Length;
}

return !alternative.IsEmpty && StartsWithSeparator(s, alternative) ? alternative.Length : 0;
}

/// <summary>
/// Determines whether <paramref name="s"/> starts with <paramref name="separator"/>, letting a U+0020
/// space in <paramref name="s"/> stand in for a U+00A0 or U+202F no-break space in the separator.
Expand Down Expand Up @@ -1430,7 +1469,7 @@
/// since cultures such as de-DE write <c>1.234,50 €</c>. At most one sign and one symbol are read.
/// </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 hasCurrencySymbol)
private static ReadOnlySpan<char> StripAffixes(ReadOnlySpan<char> s, NumberStyles style, NumberFormatInfo numberFormat, out bool isNegative, out bool hasLeadingCurrencySymbol)
{
bool hasParentheses = style.HasFlag(NumberStyles.AllowParentheses) && s.Length >= 2 && s[0] == '(' && s[^1] == ')';
if (hasParentheses)
Expand All @@ -1441,9 +1480,10 @@
ReadOnlySpan<char> currencySymbol = style.HasFlag(NumberStyles.AllowCurrencySymbol) ? numberFormat.CurrencySymbol : [];
bool hasSign = false;
isNegative = false;
hasCurrencySymbol = false;
bool hasCurrencySymbol = false;

StripLeadingAffixes(ref s, style, numberFormat, currencySymbol, ref hasSign, ref isNegative, ref hasCurrencySymbol);
hasLeadingCurrencySymbol = hasCurrencySymbol;
StripTrailingAffixes(ref s, style, numberFormat, currencySymbol, ref hasSign, ref isNegative, ref hasCurrencySymbol);

if (hasParentheses)
Expand Down
Loading