diff --git a/PreciseNumber.Test/PreciseNumberParseCultureTests.cs b/PreciseNumber.Test/PreciseNumberParseCultureTests.cs index 1ad4117..ac429ee 100644 --- a/PreciseNumber.Test/PreciseNumberParseCultureTests.cs +++ b/PreciseNumber.Test/PreciseNumberParseCultureTests.cs @@ -26,6 +26,28 @@ public class PreciseNumberParseCultureTests NegativeSign = "-", }; + // Shaped like ru-RU, pl-PL or sv-SE, which group with U+00A0, and like de-CH, which groups with U+2019. + private static readonly NumberFormatInfo NoBreakSpaceGroups = new() + { + NumberDecimalSeparator = ",", + NumberGroupSeparator = "\u00A0", + NegativeSign = "-", + }; + + private static readonly NumberFormatInfo ApostropheGroups = new() + { + NumberDecimalSeparator = ",", + NumberGroupSeparator = "\u2019", + NegativeSign = "-", + }; + + private static readonly NumberFormatInfo NoBreakSpaceDecimal = new() + { + NumberDecimalSeparator = "\u00A0", + NumberGroupSeparator = ".", + NegativeSign = "-", + }; + private static readonly NumberFormatInfo MultiCharacterSeparator = new() { NumberDecimalSeparator = "<>", @@ -172,4 +194,51 @@ public void Parse_ReadsACulturesNegativeSignOnlyWhenTheStyleAllowsIt() Assert.IsFalse(PreciseNumber.TryParse("−2", NumberStyles.None, CommaDecimal, out _)); Assert.IsFalse(PreciseNumber.TryParse("-2", NumberStyles.None, CommaDecimal, out _)); } + + // A keyboard types U+0020, and every BCL numeric type lets it stand in for a U+00A0 or U+202F + // separator, so generic INumberBase.Parse code failed only for PreciseNumber. + [TestMethod] + [DataRow("1 234,5", "1234.5")] + [DataRow("-1 234 567", "-1234567")] + [DataRow("1 234 567,25", "1234567.25")] + public void Parse_AcceptsATypedSpaceForANoBreakSpaceGroupSeparator(string input, string expected) + { + foreach (NumberFormatInfo format in new[] { NarrowSpaceGroups, NoBreakSpaceGroups }) + { + Assert.AreEqual(P(expected), PreciseNumber.Parse(input, format)); + Assert.AreEqual(P(expected), PreciseNumber.Parse(input, NumberStyles.Number, format)); + Assert.AreEqual(P(expected), PreciseNumber.Parse(input, NumberStyles.Any, format)); + Assert.AreEqual(decimal.Parse(input, NumberStyles.Number, format), PreciseNumber.Parse(input, NumberStyles.Number, format).To()); + } + } + + [TestMethod] + public void Parse_AcceptsATypedSpaceForANoBreakSpaceDecimalSeparator() + { + Assert.AreEqual(P("1.5"), PreciseNumber.Parse("1 5", NumberStyles.Number, NoBreakSpaceDecimal)); + Assert.AreEqual(decimal.Parse("1 5", NumberStyles.Number, NoBreakSpaceDecimal), PreciseNumber.Parse("1 5", NumberStyles.Number, NoBreakSpaceDecimal).To()); + } + + [TestMethod] + public void Parse_StillAcceptsTheRealNoBreakSpace() + { + Assert.AreEqual(P("1234.5"), PreciseNumber.Parse("1\u202F234,5", NarrowSpaceGroups)); + Assert.AreEqual(P("1234.5"), PreciseNumber.Parse("1\u00A0234,5", NoBreakSpaceGroups)); + } + + [TestMethod] + public void Parse_DoesNotLetASpaceStandInForOtherSeparators() + { + Assert.IsFalse(PreciseNumber.TryParse("1 234,5", NumberStyles.Number, ApostropheGroups, out _)); + Assert.IsFalse(PreciseNumber.TryParse("1 234,5", NumberStyles.Number, CommaDecimal, out _)); + Assert.IsFalse(PreciseNumber.TryParse("1 234", NumberStyles.Number, CultureInfo.InvariantCulture, out _)); + Assert.IsFalse(PreciseNumber.TryParse("1\u00A0234", NumberStyles.Number, NarrowSpaceGroups, out _)); + } + + [TestMethod] + public void Parse_RejectsATypedSpaceWhenTheStyleDoesNotAllowThousands() + { + Assert.IsFalse(PreciseNumber.TryParse("1 234", NumberStyles.Float, NarrowSpaceGroups, out _)); + Assert.IsFalse(PreciseNumber.TryParse("1 234", NumberStyles.Float, NoBreakSpaceGroups, out _)); + } } diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index d59a27b..060127d 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -1255,7 +1255,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor for (int i = startIndex; i < s.Length; i++) { char c = s[i]; - if (allowDecimalPoint && s[i..].StartsWith(decimalSeparator, StringComparison.Ordinal)) + if (allowDecimalPoint && StartsWithSeparator(s[i..], decimalSeparator)) { if (hasDecimal) { @@ -1268,7 +1268,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor } // Group separators may only sit between integral digits. - if (allowThousands && !hasDecimal && digitCount > 0 && s[i..].StartsWith(groupSeparator, StringComparison.Ordinal)) + if (allowThousands && !hasDecimal && digitCount > 0 && StartsWithSeparator(s[i..], groupSeparator)) { i += groupSeparator.Length - 1; continue; @@ -1310,6 +1310,33 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor } } + /// + /// 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. + /// + /// + /// Cultures such as fr-FR, ru-RU and sv-SE group digits with a no-break space, but a keyboard types + /// an ordinary one. The BCL numeric parsers accept it in their place, so decimal.Parse("1 234,5", fr) + /// succeeds, and this matches them. Every other character is compared ordinally. + /// + private static bool StartsWithSeparator(ReadOnlySpan s, ReadOnlySpan separator) + { + if (s.Length < separator.Length) + { + return false; + } + + for (int i = 0; i < separator.Length; i++) + { + if (s[i] != separator[i] && !(s[i] == ' ' && separator[i] is '\u00A0' or '\u202F')) + { + return false; + } + } + + return true; + } + /// /// Builds the value read: the /// written , of which the last follow the