From fb63f1dfd9d76363a25bbaa144d8c69e819d6b4a Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 12 Nov 2026 02:27:16 +0000 Subject: [PATCH] Read a currency symbol outside the parentheses in Parse [patch] The parenthesis check ran before the currency symbol was stripped, so "$(45)", "(45)$" and "$ (45)" under Currency threw where decimal, double, long and BigInteger give -45. A currency symbol, with the spaces it may have beside it, is now removed first when what remains is wholly enclosed in parentheses. It counts as the number's one symbol, so "$($45)" and "$(45)$" still fail. Fixes #204 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW4B4xxTAPzAHBD4zKk --- .../PreciseNumberParseAffixTests.cs | 10 ++++ PreciseNumber/PreciseNumber.cs | 58 +++++++++++++++++-- 2 files changed, 64 insertions(+), 4 deletions(-) diff --git a/PreciseNumber.Test/PreciseNumberParseAffixTests.cs b/PreciseNumber.Test/PreciseNumberParseAffixTests.cs index 40b8aa2..3e338f8 100644 --- a/PreciseNumber.Test/PreciseNumberParseAffixTests.cs +++ b/PreciseNumber.Test/PreciseNumberParseAffixTests.cs @@ -117,6 +117,13 @@ public class PreciseNumberParseAffixTests [DataRow("1 234,50 CHF", NumberStyles.Currency, "Swiss")] [DataRow("CHF 1 234.50", NumberStyles.Currency, "Swiss")] [DataRow("€1,5", NumberStyles.Any, "Mixed")] + [DataRow("$(45)", NumberStyles.Currency, "Dollars")] + [DataRow("$(1,234.50)", NumberStyles.Currency, "Dollars")] + [DataRow("(45)$", NumberStyles.Currency, "Dollars")] + [DataRow("$ (45)", NumberStyles.Currency, "Dollars")] + [DataRow("¤(45)", NumberStyles.Any, "Invariant")] + [DataRow("(¤45)", NumberStyles.Any, "Invariant")] + [DataRow("(45¤)", NumberStyles.Any, "Invariant")] public void TestParseAcceptsWhatDecimalAccepts(string text, NumberStyles style, string format) { NumberFormatInfo numberFormat = Format(format); @@ -173,6 +180,9 @@ public void TestParseAcceptsANoBreakSpaceBeforeACurrencySymbol() [DataRow("-$ 5", NumberStyles.AllowCurrencySymbol | NumberStyles.AllowLeadingSign, "Dollars")] [DataRow("5 $", NumberStyles.AllowCurrencySymbol | NumberStyles.AllowTrailingSign, "Dollars")] [DataRow("CHF 1,5", NumberStyles.Currency, "Swiss")] + [DataRow("$($45)", NumberStyles.Currency, "Dollars")] + [DataRow("$(45)$", NumberStyles.Currency, "Dollars")] + [DataRow("$(45", NumberStyles.Currency, "Dollars")] public void TestParseRejectsWhatDecimalRejects(string text, NumberStyles style, string format) { NumberFormatInfo numberFormat = Format(format); diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index 8d5ec22..3ad24e9 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -1500,7 +1500,17 @@ private static ReadOnlySpan TrimAllowedWhite(ReadOnlySpan s, NumberS /// Thrown when the parentheses enclose a sign. private static ReadOnlySpan StripAffixes(ReadOnlySpan s, NumberStyles style, NumberFormatInfo numberFormat, out bool isNegative, out bool hasLeadingCurrencySymbol) { - bool hasParentheses = style.HasFlag(NumberStyles.AllowParentheses) && s.Length >= 2 && s[0] == '(' && s[^1] == ')'; + ReadOnlySpan currencySymbol = style.HasFlag(NumberStyles.AllowCurrencySymbol) ? numberFormat.CurrencySymbol : []; + bool hasCurrencySymbol = false; + bool hasOuterLeadingCurrencySymbol = false; + + bool allowParentheses = style.HasFlag(NumberStyles.AllowParentheses); + if (allowParentheses) + { + StripCurrencySymbolOutsideParentheses(ref s, style, currencySymbol, ref hasCurrencySymbol, ref hasOuterLeadingCurrencySymbol); + } + + bool hasParentheses = allowParentheses && IsEnclosedInParentheses(s); if (hasParentheses) { s = s[1..^1]; @@ -1517,13 +1527,12 @@ private static ReadOnlySpan StripAffixes(ReadOnlySpan s, NumberStyle } } - ReadOnlySpan currencySymbol = style.HasFlag(NumberStyles.AllowCurrencySymbol) ? numberFormat.CurrencySymbol : []; bool hasSign = false; isNegative = false; - bool hasCurrencySymbol = false; + bool hadCurrencySymbol = hasCurrencySymbol; StripLeadingAffixes(ref s, style, numberFormat, currencySymbol, ref hasSign, ref isNegative, ref hasCurrencySymbol); - hasLeadingCurrencySymbol = hasCurrencySymbol; + hasLeadingCurrencySymbol = hasOuterLeadingCurrencySymbol || (hasCurrencySymbol && !hadCurrencySymbol); StripTrailingAffixes(ref s, style, numberFormat, currencySymbol, ref hasSign, ref isNegative, ref hasCurrencySymbol); if (hasParentheses) @@ -1539,6 +1548,47 @@ private static ReadOnlySpan StripAffixes(ReadOnlySpan s, NumberStyle return s; } + /// + /// Removes a currency symbol written outside the parentheses, as in $(45), $ (45) and + /// (45)$, for , along with the spaces it may have beside it. + /// + /// + /// The BCL takes the symbol either inside or outside the parentheses. It is removed only when what + /// is left is wholly enclosed in them, and it counts as the one symbol a number may have, so + /// $($45) and $(45)$ still fail. + /// + private static void StripCurrencySymbolOutsideParentheses(ref ReadOnlySpan s, NumberStyles style, ReadOnlySpan currencySymbol, ref bool hasCurrencySymbol, ref bool isLeading) + { + if (currencySymbol.IsEmpty) + { + return; + } + + if (s.StartsWith(currencySymbol, StringComparison.Ordinal)) + { + ReadOnlySpan rest = s[currencySymbol.Length..].TrimStart(SpacesBesideCurrencySymbol(style.HasFlag(NumberStyles.AllowLeadingWhite))); + if (IsEnclosedInParentheses(rest)) + { + hasCurrencySymbol = true; + isLeading = true; + s = rest; + return; + } + } + + if (s.EndsWith(currencySymbol, StringComparison.Ordinal)) + { + ReadOnlySpan rest = s[..^currencySymbol.Length].TrimEnd(SpacesBesideCurrencySymbol(style.HasFlag(NumberStyles.AllowTrailingWhite))); + if (IsEnclosedInParentheses(rest)) + { + hasCurrencySymbol = true; + s = rest; + } + } + } + + private static bool IsEnclosedInParentheses(ReadOnlySpan s) => s.Length >= 2 && s[0] == '(' && s[^1] == ')'; + /// /// Removes a leading sign and a leading currency symbol, in either order, for . ///