From 8dd1bf365c646c969f10b66b0a0d826db0cc7239 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 29 Oct 2026 04:29:54 +0000 Subject: [PATCH] Let a typed space match a no-break-space separator in Parse [patch] fr-FR groups digits with U+202F, and ru-RU, pl-PL and sv-SE with U+00A0, but a keyboard types U+0020. Parse compared separators ordinally, so Parse("1 234,5", fr-FR) threw where decimal, double and int all return 1234.5, and generic INumberBase.Parse code failed only for PreciseNumber. The group and decimal separators are now matched the way the runtime's MatchChars does: a U+0020 in the input stands in for a U+00A0 or U+202F in the separator, and every other character stays ordinal. Fixes #174 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_017vfJW5r5WL5w6Xw9zvRF9a --- .../PreciseNumberParseCultureTests.cs | 69 +++++++++++++++++++ PreciseNumber/PreciseNumber.cs | 31 ++++++++- 2 files changed, 98 insertions(+), 2 deletions(-) diff --git a/PreciseNumber.Test/PreciseNumberParseCultureTests.cs b/PreciseNumber.Test/PreciseNumberParseCultureTests.cs index 59108e9..15484c6 100644 --- a/PreciseNumber.Test/PreciseNumberParseCultureTests.cs +++ b/PreciseNumber.Test/PreciseNumberParseCultureTests.cs @@ -25,6 +25,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 = "<>", @@ -150,4 +172,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 40355c7..6e616ee 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -1239,7 +1239,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) { @@ -1252,7 +1252,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; @@ -1305,6 +1305,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; + } + /// Trims the leading and trailing white space that allows. private static ReadOnlySpan TrimAllowedWhite(ReadOnlySpan s, NumberStyles style) {