From 14cd65e2defe1e9889924cc714c26d5909280638 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:34:48 +0000 Subject: [PATCH 1/2] Read white space around a sign or currency symbol as the BCL does [patch] Parse trimmed no white space between the digits and a trailing sign, or after a leading sign, so a value written with NumberNegativePattern 2 ("- 1,234.50") or 4 ("1,234.50 -") couldn't be read back with the same format, and "5 -", "$\t5" and "$- 5" failed where decimal reads them. An ordinary space next to a currency symbol was taken under any style, so "$ 5" parsed though no white-space flag allowed it. White space is now taken where the BCL takes it: among the trailing affixes under AllowTrailingWhite, and under AllowLeadingWhite after a leading currency symbol, or after a sign or opening parenthesis once a currency symbol has been read or when the negative pattern is 2. The rendered no-break spaces next to a currency symbol stay accepted under any style. Fixes ktsu-dev/PreciseNumber#189 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW2U6qwK2oTLt2cfE7k --- .../PreciseNumberParseAffixTests.cs | 54 +++++++++++++++++++ PreciseNumber/PreciseNumber.cs | 51 +++++++++++++++--- 2 files changed, 99 insertions(+), 6 deletions(-) diff --git a/PreciseNumber.Test/PreciseNumberParseAffixTests.cs b/PreciseNumber.Test/PreciseNumberParseAffixTests.cs index 168dcc9..20e87c7 100644 --- a/PreciseNumber.Test/PreciseNumberParseAffixTests.cs +++ b/PreciseNumber.Test/PreciseNumberParseAffixTests.cs @@ -32,10 +32,17 @@ 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, + }; + private static NumberFormatInfo Format(string name) => name switch { "Dollars" => Dollars, "Euros" => Euros, + "SpacedSign" => SpacedSign, _ => NumberFormatInfo.InvariantInfo, }; @@ -54,6 +61,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")] public void TestParseAcceptsWhatDecimalAccepts(string text, NumberStyles style, string format) { NumberFormatInfo numberFormat = Format(format); @@ -85,6 +110,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")] public void TestParseRejectsWhatDecimalRejects(string text, NumberStyles style, string format) { NumberFormatInfo numberFormat = Format(format); @@ -93,4 +127,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 602eeaf..a2c802c 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."; @@ -1426,8 +1434,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 hasCurrencySymbol) @@ -1436,6 +1449,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 : []; @@ -1464,6 +1488,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; @@ -1473,11 +1498,18 @@ private static void StripLeadingAffixes(ref ReadOnlySpan s, NumberStyles s hasSign = true; isNegative = leadingNegative; s = s[signLength..]; + + // The BCL takes white space after a sign only once a currency symbol has been read, or when the + // format writes its negative numbers as "- n" + if (allowLeadingWhite && (hasCurrencySymbol || numberFormat.NumberNegativePattern == 2)) + { + s = s.TrimStart(AllowedWhite.AsSpan()); + } } else if (!hasCurrencySymbol && !currencySymbol.IsEmpty && s.StartsWith(currencySymbol, StringComparison.Ordinal)) { hasCurrencySymbol = true; - s = s[currencySymbol.Length..].TrimStart(CurrencySymbolSpaces); + s = s[currencySymbol.Length..].TrimStart((allowLeadingWhite ? CurrencySymbolSpacesAndWhite : CurrencySymbolSpaces).AsSpan()); } else { @@ -1492,12 +1524,13 @@ private static void StripLeadingAffixes(ref ReadOnlySpan s, NumberStyles s 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((allowTrailingWhite ? CurrencySymbolSpacesAndWhite : CurrencySymbolSpaces).AsSpan()); continue; } @@ -1511,6 +1544,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()); + } } } From b8e23bb8a62c8098df15aa142a0bd23295dd163d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 7 Nov 2026 00:43:30 +0000 Subject: [PATCH 2/2] Split the white-space trims out of StripLeadingAffixes [patch] SonarCloud put StripLeadingAffixes' cognitive complexity at 17 against an allowance of 15 once it trimmed white space after a sign. The trim after a sign and the choice of what to trim beside a currency symbol are now their own helpers; behaviour is unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW2U6qwK2oTLt2cfE7k --- PreciseNumber/PreciseNumber.cs | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index a2c802c..c971665 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -1497,19 +1497,12 @@ private static void StripLeadingAffixes(ref ReadOnlySpan s, NumberStyles s { hasSign = true; isNegative = leadingNegative; - s = s[signLength..]; - - // The BCL takes white space after a sign only once a currency symbol has been read, or when the - // format writes its negative numbers as "- n" - if (allowLeadingWhite && (hasCurrencySymbol || numberFormat.NumberNegativePattern == 2)) - { - s = s.TrimStart(AllowedWhite.AsSpan()); - } + s = TrimWhiteAfterLeadingSign(s[signLength..], allowLeadingWhite, numberFormat, hasCurrencySymbol); } else if (!hasCurrencySymbol && !currencySymbol.IsEmpty && s.StartsWith(currencySymbol, StringComparison.Ordinal)) { hasCurrencySymbol = true; - s = s[currencySymbol.Length..].TrimStart((allowLeadingWhite ? CurrencySymbolSpacesAndWhite : CurrencySymbolSpaces).AsSpan()); + s = s[currencySymbol.Length..].TrimStart(SpacesBesideCurrencySymbol(allowLeadingWhite)); } else { @@ -1518,6 +1511,22 @@ 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 . /// @@ -1530,7 +1539,7 @@ private static void StripTrailingAffixes(ref ReadOnlySpan s, NumberStyles if (!hasCurrencySymbol && !currencySymbol.IsEmpty && s.EndsWith(currencySymbol, StringComparison.Ordinal)) { hasCurrencySymbol = true; - s = s[..^currencySymbol.Length].TrimEnd((allowTrailingWhite ? CurrencySymbolSpacesAndWhite : CurrencySymbolSpaces).AsSpan()); + s = s[..^currencySymbol.Length].TrimEnd(SpacesBesideCurrencySymbol(allowTrailingWhite)); continue; }