From c28ea81565b477fa9dcde0e461e60a761403be22 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 9 Nov 2026 17:27:16 +0000 Subject: [PATCH] Read the number separators as well as the currency ones under AllowCurrencySymbol [patch] Parse used the currency separators only once a symbol had been stripped, and the number separators otherwise. The BCL reads the currency separators first whenever the style allows a symbol, and the number separators too unless a symbol came ahead of the digits. For a culture that writes money with other separators than its numbers (en-DE, fr-CH, de-AT), Parse("1.25", en-DE) returned 125 and Parse("1.5", fr-CH) threw, where decimal reads 1.25 and 1.5. Fixes #195 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW3RtuaocwQ2QRF7djh --- .../PreciseNumberParseAffixTests.cs | 54 ++++++++++++++++ .../PreciseNumberParseCultureTests.cs | 5 +- PreciseNumber/PreciseNumber.cs | 62 +++++++++++++++---- 3 files changed, 109 insertions(+), 12 deletions(-) diff --git a/PreciseNumber.Test/PreciseNumberParseAffixTests.cs b/PreciseNumber.Test/PreciseNumberParseAffixTests.cs index 168dcc9..e782e64 100644 --- a/PreciseNumber.Test/PreciseNumberParseAffixTests.cs +++ b/PreciseNumber.Test/PreciseNumberParseAffixTests.cs @@ -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, }; @@ -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); @@ -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() { @@ -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); diff --git a/PreciseNumber.Test/PreciseNumberParseCultureTests.cs b/PreciseNumber.Test/PreciseNumberParseCultureTests.cs index 24e238b..168ad93 100644 --- a/PreciseNumber.Test/PreciseNumberParseCultureTests.cs +++ b/PreciseNumber.Test/PreciseNumberParseCultureTests.cs @@ -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 = "+", }; diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index 602eeaf..f1fb00f 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -1229,17 +1229,16 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor 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 decimalSeparator = hasCurrencySymbol ? numberFormat.CurrencyDecimalSeparator : numberFormat.NumberDecimalSeparator; - ReadOnlySpan 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); @@ -1260,7 +1259,8 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor 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) { @@ -1268,14 +1268,15 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor } hasDecimal = true; - i += decimalSeparator.Length - 1; + i += decimalLength - 1; 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; continue; } @@ -1315,6 +1316,44 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor } } + /// + /// Chooses the separators reads + /// between digits: a decimal and a group separator tried first, and a second pair tried after them, + /// empty when there is none. + /// + /// + /// 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 "1.5" + /// is one and a half there under , not fifteen. + /// + 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); + } + + /// + /// Returns the length of whichever of and + /// starts with, trying first, or zero when it starts + /// with neither. An empty separator never matches. + /// + private static int MatchSeparator(ReadOnlySpan s, ReadOnlySpan separator, ReadOnlySpan alternative) + { + if (!separator.IsEmpty && StartsWithSeparator(s, separator)) + { + return separator.Length; + } + + return !alternative.IsEmpty && StartsWithSeparator(s, alternative) ? alternative.Length : 0; + } + /// /// Determines whether starts with , letting a U+0020 /// space in stand in for a U+00A0 or U+202F no-break space in the separator. @@ -1430,7 +1469,7 @@ private static ReadOnlySpan TrimAllowedWhite(ReadOnlySpan s, NumberS /// since cultures such as de-DE write 1.234,50 €. At most one sign and one symbol are read. /// /// Thrown when the parentheses enclose a sign. - private static ReadOnlySpan StripAffixes(ReadOnlySpan s, NumberStyles style, NumberFormatInfo numberFormat, out bool isNegative, out bool hasCurrencySymbol) + 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] == ')'; if (hasParentheses) @@ -1441,9 +1480,10 @@ private static ReadOnlySpan StripAffixes(ReadOnlySpan s, NumberStyle ReadOnlySpan 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)