diff --git a/PreciseNumber.Test/PreciseNumberParseAffixTests.cs b/PreciseNumber.Test/PreciseNumberParseAffixTests.cs index e782e64..40b8aa2 100644 --- a/PreciseNumber.Test/PreciseNumberParseAffixTests.cs +++ b/PreciseNumber.Test/PreciseNumberParseAffixTests.cs @@ -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. @@ -57,6 +63,7 @@ public class PreciseNumberParseAffixTests { "Dollars" => Dollars, "Euros" => Euros, + "SpacedSign" => SpacedSign, "Mixed" => Mixed, "Swiss" => Swiss, _ => NumberFormatInfo.InvariantInfo, @@ -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")] @@ -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) { @@ -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(() => 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"); + } } diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index 159911d..8d5ec22 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -49,8 +49,16 @@ public readonly partial record struct PreciseNumber /// private const string InvalidFormatMessage = "Input string was not in a correct format."; - /// The spaces a culture may put between a currency symbol and the number, as de-DE does in 1.234,50 €. - private static readonly char[] CurrencySymbolSpaces = [' ', '\u00A0', '\u202F']; + /// + /// The rendered spaces a culture may put between a currency symbol and the number, as Windows' de-DE does in + /// 1.234,50 € 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 and + /// allow it, as the BCL parsers do. + /// + private const string CurrencySymbolSpaces = "\u00A0\u202F"; + + /// The rendered spaces of and the white space of . + private const string CurrencySymbolSpacesAndWhite = CurrencySymbolSpaces + AllowedWhite; private const string UnsupportedParseStylesMessage = "The number styles AllowHexSpecifier and AllowBinarySpecifier are not supported by PreciseNumber."; @@ -1481,8 +1489,13 @@ private static ReadOnlySpan TrimAllowedWhite(ReadOnlySpan s, NumberS /// /// 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 -$1.5, $-1.5 - /// and 1,5 €- do, and a space or no-break space may separate the symbol from the number, - /// since cultures such as de-DE write 1.234,50 €. At most one sign and one symbol are read. + /// and 1,5 €- 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 + /// is set, as 1,234.50 - (negative pattern 4) + /// needs, and on the leading side under after a currency + /// symbol, or after a sign or opening parenthesis once a currency symbol has been read or when the + /// format's is 2, - 1,234.50. + /// A no-break space next to a currency symbol is accepted under any style. /// /// Thrown when the parentheses enclose a sign. private static ReadOnlySpan StripAffixes(ReadOnlySpan s, NumberStyles style, NumberFormatInfo numberFormat, out bool isNegative, out bool hasLeadingCurrencySymbol) @@ -1491,6 +1504,17 @@ private static ReadOnlySpan StripAffixes(ReadOnlySpan s, NumberStyle 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 currencySymbol = style.HasFlag(NumberStyles.AllowCurrencySymbol) ? numberFormat.CurrencySymbol : []; @@ -1520,6 +1544,7 @@ private static ReadOnlySpan StripAffixes(ReadOnlySpan s, NumberStyle /// private static void StripLeadingAffixes(ref ReadOnlySpan s, NumberStyles style, NumberFormatInfo numberFormat, ReadOnlySpan 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; @@ -1528,12 +1553,12 @@ private static void StripLeadingAffixes(ref ReadOnlySpan s, NumberStyles s { 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 { @@ -1542,18 +1567,35 @@ private static void StripLeadingAffixes(ref ReadOnlySpan s, NumberStyles s } } + /// + /// Trims the white space after a leading sign that the BCL takes: only under + /// , and only once a currency symbol has been read or when the + /// format writes its negative numbers as - n. + /// + private static ReadOnlySpan TrimWhiteAfterLeadingSign(ReadOnlySpan s, bool allowLeadingWhite, NumberFormatInfo numberFormat, bool hasCurrencySymbol) => + allowLeadingWhite && (hasCurrencySymbol || numberFormat.NumberNegativePattern == 2) + ? s.TrimStart(AllowedWhite.AsSpan()) + : s; + + /// + /// The characters trimmed beside a currency symbol: its rendered spaces, and white space too when the style allows it on that side. + /// + private static ReadOnlySpan SpacesBesideCurrencySymbol(bool allowWhite) => + (allowWhite ? CurrencySymbolSpacesAndWhite : CurrencySymbolSpaces).AsSpan(); + /// /// Removes a trailing currency symbol and a trailing sign, in either order, for . /// private static void StripTrailingAffixes(ref ReadOnlySpan s, NumberStyles style, NumberFormatInfo numberFormat, ReadOnlySpan 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; } @@ -1567,6 +1609,12 @@ private static void StripTrailingAffixes(ref ReadOnlySpan s, NumberStyles 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()); + } } }