diff --git a/PreciseNumber.Test/PreciseNumberParseCultureTests.cs b/PreciseNumber.Test/PreciseNumberParseCultureTests.cs new file mode 100644 index 0000000..6cdf76a --- /dev/null +++ b/PreciseNumber.Test/PreciseNumberParseCultureTests.cs @@ -0,0 +1,103 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Test; + +using System.Globalization; + +[TestClass] +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. + private static readonly NumberFormatInfo CommaDecimal = new() + { + NumberDecimalSeparator = ",", + NumberGroupSeparator = ".", + NegativeSign = "āˆ’", + PositiveSign = "+", + }; + + private static readonly NumberFormatInfo NarrowSpaceGroups = new() + { + NumberDecimalSeparator = ",", + NumberGroupSeparator = "\u202F", + NegativeSign = "-", + }; + + private static readonly NumberFormatInfo MultiCharacterSeparator = new() + { + NumberDecimalSeparator = "<>", + NumberGroupSeparator = " ", + NegativeSign = "neg", + }; + + private static PreciseNumber P(string text) => PreciseNumber.Parse(text, CultureInfo.InvariantCulture); + + [TestMethod] + public void Parse_UsesTheProvidersDecimalSeparator() + { + Assert.AreEqual(P("2.5"), PreciseNumber.Parse("2,5", CommaDecimal)); + Assert.IsTrue(PreciseNumber.TryParse("2,5", CommaDecimal, out PreciseNumber parsed)); + Assert.AreEqual(P("2.5"), parsed); + } + + [TestMethod] + public void Parse_UsesTheProvidersNegativeSign() + { + Assert.AreEqual(P("-0.25"), PreciseNumber.Parse("āˆ’0,25", CommaDecimal)); + Assert.AreEqual(P("-0.25"), PreciseNumber.Parse("neg0<>25", MultiCharacterSeparator)); + } + + [TestMethod] + public void Parse_StillAcceptsTheAsciiHyphen() + { + Assert.AreEqual(P("-0.25"), PreciseNumber.Parse("-0,25", CommaDecimal)); + } + + [TestMethod] + public void Parse_RoundTripsToStringForTheSameProvider() + { + string[] values = ["1.5", "-0.25", "123456789.000000001", "-42", "0.0001"]; + NumberFormatInfo[] formats = [CommaDecimal, MultiCharacterSeparator, NarrowSpaceGroups, NumberFormatInfo.InvariantInfo]; + + foreach (NumberFormatInfo format in formats) + { + foreach (string value in values) + { + PreciseNumber number = P(value); + string formatted = number.ToString(format); + Assert.AreEqual(number, PreciseNumber.Parse(formatted, format), $"'{value}' formatted as '{formatted}'"); + } + } + } + + [TestMethod] + public void Parse_HonoursLeadingSignWhitespaceAndThousandsUnderNumberStylesAny() + { + Assert.AreEqual(P("5"), PreciseNumber.Parse("+5", CultureInfo.InvariantCulture)); + Assert.AreEqual(P("5"), PreciseNumber.Parse(" 5 ", CultureInfo.InvariantCulture)); + Assert.AreEqual(P("1234567.5"), PreciseNumber.Parse("1,234,567.5", CultureInfo.InvariantCulture)); + Assert.AreEqual(P("1234567.5"), PreciseNumber.Parse("1.234.567,5", CommaDecimal)); + } + + [TestMethod] + public void Parse_RejectsWhatTheStyleDoesNotAllow() + { + Assert.IsFalse(PreciseNumber.TryParse(" 5", NumberStyles.None, CultureInfo.InvariantCulture, out _)); + Assert.IsFalse(PreciseNumber.TryParse("+5", NumberStyles.None, CultureInfo.InvariantCulture, out _)); + Assert.IsFalse(PreciseNumber.TryParse("1,000", NumberStyles.Float, CultureInfo.InvariantCulture, out _)); + } + + [TestMethod] + public void Parse_RejectsASecondDecimalSeparator() + { + Assert.IsFalse(PreciseNumber.TryParse("1,2,3", CommaDecimal, out _)); + } + + [TestMethod] + public void Parse_ReadsTheExponentSignWithTheProvider() + { + Assert.AreEqual(P("0.015"), PreciseNumber.Parse("1,5eāˆ’2", CommaDecimal)); + } +} diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index 68184fb..45aa2e9 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -1156,6 +1156,12 @@ public static PreciseNumber MinMagnitude(PreciseNumber x, PreciseNumber y) /// public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFormatProvider? provider) { + // Read the separator and signs from the same culture TryFormat writes them with, so that + // Parse(x.ToString(p), p) round-trips for every provider. + NumberFormatInfo numberFormat = NumberFormatInfo.GetInstance(provider ?? InvariantCulture); + + s = TrimAllowedWhite(s, style); + if (s.IsEmpty) { throw new FormatException(InvalidFormatMessage); @@ -1166,8 +1172,11 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor return Zero; } - bool isNegative = s[0] == '-'; - int startIndex = isNegative ? 1 : 0; + ReadOnlySpan decimalSeparator = numberFormat.NumberDecimalSeparator; + ReadOnlySpan groupSeparator = numberFormat.NumberGroupSeparator; + bool allowThousands = style.HasFlag(NumberStyles.AllowThousands) && !groupSeparator.IsEmpty; + + int startIndex = ReadLeadingSign(s, style, numberFormat, out bool isNegative); // Collect the digits first and hand them to BigInteger in one go. Accumulating with // significand = significand * 10 + digit costs a full BigInteger multiply per character. @@ -1185,7 +1194,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor for (int i = startIndex; i < s.Length; i++) { char c = s[i]; - if (c == '.') + if (s[i..].StartsWith(decimalSeparator, StringComparison.Ordinal)) { if (hasDecimal) { @@ -1193,13 +1202,21 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor } hasDecimal = true; + i += decimalSeparator.Length - 1; + continue; + } + + // Group separators may only sit between integral digits. + if (allowThousands && !hasDecimal && digitCount > 0 && s[i..].StartsWith(groupSeparator, StringComparison.Ordinal)) + { + i += groupSeparator.Length - 1; continue; } if (c is 'e' or 'E') { // An exponent outside the range of int throws OverflowException, which TryParse reports as failure. - exponent = int.Parse(s[(i + 1)..], InvariantCulture); + exponent = int.Parse(s[(i + 1)..], NumberStyles.Integer, numberFormat); break; } @@ -1241,6 +1258,47 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor } } + /// Trims the leading and trailing white space that allows. + private static ReadOnlySpan TrimAllowedWhite(ReadOnlySpan s, NumberStyles style) + { + if (style.HasFlag(NumberStyles.AllowLeadingWhite)) + { + s = s.TrimStart(); + } + + if (style.HasFlag(NumberStyles.AllowTrailingWhite)) + { + s = s.TrimEnd(); + } + + return s; + } + + /// + /// Reads a leading sign spelled as spells it, returning the index + /// of the first character after it. The ASCII hyphen stays accepted whatever the culture spells + /// its negative sign with, as it always has been here and as the BCL does for cultures that use + /// U+2212. A positive sign is only read when allows a leading sign. + /// + private static int ReadLeadingSign(ReadOnlySpan s, NumberStyles style, NumberFormatInfo numberFormat, out bool isNegative) + { + isNegative = true; + if (s.StartsWith(numberFormat.NegativeSign, StringComparison.Ordinal)) + { + return numberFormat.NegativeSign.Length; + } + + if (s[0] == '-') + { + return 1; + } + + isNegative = false; + return style.HasFlag(NumberStyles.AllowLeadingSign) && s.StartsWith(numberFormat.PositiveSign, StringComparison.Ordinal) + ? numberFormat.PositiveSign.Length + : 0; + } + /// public static PreciseNumber Parse(string s, NumberStyles style, IFormatProvider? provider) => Parse(s.AsSpan(), style, provider);