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);