Skip to content
Open
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
96 changes: 96 additions & 0 deletions PreciseNumber.Test/PreciseNumberParseAffixTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.PreciseNumber.Test;

using System.Globalization;

/// <summary>
/// Covers the parts of a number <see cref="NumberStyles"/> allows around its digits: parentheses, a
/// trailing sign, and a currency symbol. Each case is checked against <see cref="decimal"/> parsing
/// the same text with the same style and format, so the expectation is the BCL's rather than ours.
/// </summary>
[TestClass]
public class PreciseNumberParseAffixTests
{
// Hand-built formats keep these tests independent of the ICU data installed on the machine.
// Dollars is shaped like en-US, Euros like de-DE, which writes the symbol after a no-break space.
private static readonly NumberFormatInfo Dollars = new()
{
CurrencySymbol = "$",
CurrencyDecimalSeparator = ".",
CurrencyGroupSeparator = ",",
NumberDecimalSeparator = ".",
NumberGroupSeparator = ",",
};

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

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

[TestMethod]
[DataRow("(1.5)", NumberStyles.Any, "Invariant")]
[DataRow("(1.5)", NumberStyles.AllowParentheses | NumberStyles.AllowDecimalPoint, "Invariant")]
[DataRow("1.5-", NumberStyles.Any, "Invariant")]
[DataRow("1.5-", NumberStyles.AllowTrailingSign | NumberStyles.AllowDecimalPoint, "Invariant")]
[DataRow("1.5+", NumberStyles.Any, "Invariant")]
[DataRow("1.5E2-", NumberStyles.Any, "Invariant")]
[DataRow("¤1.5", NumberStyles.Any, "Invariant")]
[DataRow("$1,234.50", NumberStyles.Currency, "Dollars")]
[DataRow("-$1,234.50", NumberStyles.Any, "Dollars")]
[DataRow("$-1,234.50", NumberStyles.Any, "Dollars")]
[DataRow("($1,234.50)", NumberStyles.Any, "Dollars")]
[DataRow("1.234,50 €", NumberStyles.Currency, "Euros")]
[DataRow("-1.234,50 €", NumberStyles.Currency, "Euros")]
[DataRow("1.234,50 €-", NumberStyles.Currency, "Euros")]
public void TestParseAcceptsWhatDecimalAccepts(string text, NumberStyles style, string format)
{
NumberFormatInfo numberFormat = Format(format);
PreciseNumber expected = decimal.Parse(text, style, numberFormat).ToPreciseNumber();

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

[TestMethod]
public void TestParseAcceptsANoBreakSpaceBeforeACurrencySymbol()
{
// Windows' de-DE and fr-FR write "1.234,50 €" with U+00A0 before the symbol. decimal only takes
// that from a culture whose own separators use U+00A0, but a copied amount should parse the same
// whether its space is typed or rendered, as group separators already do here.
Assert.AreEqual(PreciseNumber.Parse("-1234.5", CultureInfo.InvariantCulture), PreciseNumber.Parse("-1.234,50\u00A0€", NumberStyles.Currency, Euros));
Assert.AreEqual(PreciseNumber.Parse("1.5", CultureInfo.InvariantCulture), PreciseNumber.Parse("€\u00A01,5", NumberStyles.Currency, Euros));
}

[TestMethod]
[DataRow("(1.5)", NumberStyles.Float, "Invariant")]
[DataRow("1.5-", NumberStyles.Float, "Invariant")]
[DataRow("$1.5", NumberStyles.Number, "Dollars")]
[DataRow("1,5 €", NumberStyles.Number, "Euros")]
[DataRow("(-1.5)", NumberStyles.Any, "Invariant")]
[DataRow("-1.5-", NumberStyles.Any, "Invariant")]
[DataRow("$1.5$", NumberStyles.Any, "Dollars")]
[DataRow("()", NumberStyles.Any, "Invariant")]
[DataRow("$", NumberStyles.Any, "Dollars")]
[DataRow("-", NumberStyles.Any, "Invariant")]
public void TestParseRejectsWhatDecimalRejects(string text, NumberStyles style, string format)
{
NumberFormatInfo numberFormat = Format(format);
Assert.IsFalse(decimal.TryParse(text, style, numberFormat, out _), $"decimal accepts \"{text}\" under {style}, so the case is wrong");

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");
}
}
133 changes: 128 additions & 5 deletions PreciseNumber/PreciseNumber.cs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,10 @@
/// The message carried by the <see cref="FormatException"/> that parsing throws.
/// </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'];

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

#if NET8_0_OR_GREATER
Expand Down Expand Up @@ -744,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 @@ -1205,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 @@ -1225,19 +1229,20 @@
throw new FormatException(InvalidFormatMessage);
}

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

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

ReadOnlySpan<char> decimalSeparator = numberFormat.NumberDecimalSeparator;
ReadOnlySpan<char> groupSeparator = numberFormat.NumberGroupSeparator;
// 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;
bool allowDecimalPoint = style.HasFlag(NumberStyles.AllowDecimalPoint);
bool allowExponent = style.HasFlag(NumberStyles.AllowExponent);

int startIndex = ReadLeadingSign(s, style, numberFormat, out bool isNegative);

// Collect the digits first and hand them to BigInteger in one go. Accumulating with
// significand = significand * 10 + digit costs a full BigInteger multiply per character.
char[]? rentedDigits = s.Length > MaxStackAllocChars ? ArrayPool<char>.Shared.Rent(s.Length) : null;
Expand All @@ -1252,7 +1257,7 @@
ReadOnlySpan<char> exponentText = [];
bool hasExponent = false;

for (int i = startIndex; i < s.Length; i++)
for (int i = 0; i < s.Length; i++)
{
char c = s[i];
if (allowDecimalPoint && StartsWithSeparator(s[i..], decimalSeparator))
Expand All @@ -1263,14 +1268,14 @@
}

hasDecimal = true;
i += decimalSeparator.Length - 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))
{
i += groupSeparator.Length - 1;

Check warning on line 1278 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 1278 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 @@ -1407,6 +1412,124 @@
return s;
}

/// <summary>
/// Removes what <paramref name="style"/> allows around the digits: enclosing parentheses, a leading
/// or trailing sign, and a leading or trailing currency symbol, returning the digits that remain.
/// </summary>
/// <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.
/// </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)
{
bool hasParentheses = style.HasFlag(NumberStyles.AllowParentheses) && s.Length >= 2 && s[0] == '(' && s[^1] == ')';
if (hasParentheses)
{
s = s[1..^1];
}

ReadOnlySpan<char> currencySymbol = style.HasFlag(NumberStyles.AllowCurrencySymbol) ? numberFormat.CurrencySymbol : [];
bool hasSign = false;
isNegative = false;
hasCurrencySymbol = false;

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

if (hasParentheses)
{
if (hasSign)
{
throw new FormatException(InvalidFormatMessage);
}

isNegative = true;
}

return s;
}

/// <summary>
/// Removes a leading sign and a leading currency symbol, in either order, for <see cref="StripAffixes"/>.
/// </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)
{
for (int pass = 0; pass < 2 && !s.IsEmpty; pass++)
{
bool leadingNegative = false;
int signLength = hasSign ? 0 : ReadLeadingSign(s, style, numberFormat, out leadingNegative);
if (signLength > 0)
{
hasSign = true;
isNegative = leadingNegative;
s = s[signLength..];
}
else if (!hasCurrencySymbol && !currencySymbol.IsEmpty && s.StartsWith(currencySymbol, StringComparison.Ordinal))
{
hasCurrencySymbol = true;
s = s[currencySymbol.Length..].TrimStart(CurrencySymbolSpaces);
}
else
{
break;
}
}
}

/// <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);
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);
continue;
}

bool trailingNegative = false;
int signLength = hasSign || !allowTrailingSign ? 0 : ReadTrailingSign(s, numberFormat, out trailingNegative);
if (signLength == 0)
{
break;
}

hasSign = true;
isNegative = trailingNegative;
s = s[..^signLength];
}
}

/// <summary>
/// Reads a trailing sign spelled as <paramref name="numberFormat"/> spells it, or the ASCII hyphen,
/// returning its length, or zero when <paramref name="s"/> does not end with one.
/// </summary>
private static int ReadTrailingSign(ReadOnlySpan<char> s, NumberFormatInfo numberFormat, out bool isNegative)
{
isNegative = true;
if (!string.IsNullOrEmpty(numberFormat.NegativeSign) && s.EndsWith(numberFormat.NegativeSign, StringComparison.Ordinal))
{
return numberFormat.NegativeSign.Length;
}

if (s[^1] == '-')
{
return 1;
}

isNegative = false;
return !string.IsNullOrEmpty(numberFormat.PositiveSign) && s.EndsWith(numberFormat.PositiveSign, StringComparison.Ordinal)
? numberFormat.PositiveSign.Length
: 0;
}

/// <summary>
/// Reads a leading sign spelled as <paramref name="numberFormat"/> spells it, returning the index
/// of the first character after it. The ASCII hyphen stays accepted whatever the culture spells
Expand Down
Loading