Skip to content

Commit 51adcd3

Browse files
committed
Parse with the provider's decimal separator and signs [patch]
Parse hard-coded '.' and '-' and ignored both the style and the provider, while TryFormat writes the provider's separator and negative sign. A value formatted with ToString(culture) under a comma-decimal culture could not be parsed back with the same culture, and "+5" failed under NumberStyles.Any. Parse now resolves the NumberFormatInfo the same way TryFormat does (provider, else invariant) and reads its decimal separator, negative sign and positive sign, which may be several characters. It also honours AllowLeadingWhite, AllowTrailingWhite, AllowLeadingSign and AllowThousands. The ASCII hyphen stays accepted as a negative sign for every provider. Fixes #106 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PKmokmgnpkeYgp439Qbthh
1 parent bbe9e7f commit 51adcd3

2 files changed

Lines changed: 165 additions & 4 deletions

File tree

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright (c) 2023-2026 ktsu-dev contributors
2+
3+
namespace ktsu.PreciseNumber.Test;
4+
5+
using System.Globalization;
6+
7+
[TestClass]
8+
public class PreciseNumberParseCultureTests
9+
{
10+
// Hand-built formats keep these tests independent of the ICU data installed on the machine (and
11+
// of globalization-invariant mode, where no named culture but the invariant one exists).
12+
// CommaDecimal is shaped like de-DE, NarrowSpaceGroups like fr-FR.
13+
private static readonly NumberFormatInfo CommaDecimal = new()
14+
{
15+
NumberDecimalSeparator = ",",
16+
NumberGroupSeparator = ".",
17+
NegativeSign = "−",
18+
PositiveSign = "+",
19+
};
20+
21+
private static readonly NumberFormatInfo NarrowSpaceGroups = new()
22+
{
23+
NumberDecimalSeparator = ",",
24+
NumberGroupSeparator = "\u202F",
25+
NegativeSign = "-",
26+
};
27+
28+
private static readonly NumberFormatInfo MultiCharacterSeparator = new()
29+
{
30+
NumberDecimalSeparator = "<>",
31+
NumberGroupSeparator = " ",
32+
NegativeSign = "neg",
33+
};
34+
35+
private static PreciseNumber P(string text) => PreciseNumber.Parse(text, CultureInfo.InvariantCulture);
36+
37+
[TestMethod]
38+
public void Parse_UsesTheProvidersDecimalSeparator()
39+
{
40+
Assert.AreEqual(P("2.5"), PreciseNumber.Parse("2,5", CommaDecimal));
41+
Assert.IsTrue(PreciseNumber.TryParse("2,5", CommaDecimal, out PreciseNumber parsed));
42+
Assert.AreEqual(P("2.5"), parsed);
43+
}
44+
45+
[TestMethod]
46+
public void Parse_UsesTheProvidersNegativeSign()
47+
{
48+
Assert.AreEqual(P("-0.25"), PreciseNumber.Parse("−0,25", CommaDecimal));
49+
Assert.AreEqual(P("-0.25"), PreciseNumber.Parse("neg0<>25", MultiCharacterSeparator));
50+
}
51+
52+
[TestMethod]
53+
public void Parse_StillAcceptsTheAsciiHyphen()
54+
{
55+
Assert.AreEqual(P("-0.25"), PreciseNumber.Parse("-0,25", CommaDecimal));
56+
}
57+
58+
[TestMethod]
59+
public void Parse_RoundTripsToStringForTheSameProvider()
60+
{
61+
string[] values = ["1.5", "-0.25", "123456789.000000001", "-42", "0.0001"];
62+
NumberFormatInfo[] formats = [CommaDecimal, MultiCharacterSeparator, NarrowSpaceGroups, NumberFormatInfo.InvariantInfo];
63+
64+
foreach (NumberFormatInfo format in formats)
65+
{
66+
foreach (string value in values)
67+
{
68+
PreciseNumber number = P(value);
69+
string formatted = number.ToString(format);
70+
Assert.AreEqual(number, PreciseNumber.Parse(formatted, format), $"'{value}' formatted as '{formatted}'");
71+
}
72+
}
73+
}
74+
75+
[TestMethod]
76+
public void Parse_HonoursLeadingSignWhitespaceAndThousandsUnderNumberStylesAny()
77+
{
78+
Assert.AreEqual(P("5"), PreciseNumber.Parse("+5", CultureInfo.InvariantCulture));
79+
Assert.AreEqual(P("5"), PreciseNumber.Parse(" 5 ", CultureInfo.InvariantCulture));
80+
Assert.AreEqual(P("1234567.5"), PreciseNumber.Parse("1,234,567.5", CultureInfo.InvariantCulture));
81+
Assert.AreEqual(P("1234567.5"), PreciseNumber.Parse("1.234.567,5", CommaDecimal));
82+
}
83+
84+
[TestMethod]
85+
public void Parse_RejectsWhatTheStyleDoesNotAllow()
86+
{
87+
Assert.IsFalse(PreciseNumber.TryParse(" 5", NumberStyles.None, CultureInfo.InvariantCulture, out _));
88+
Assert.IsFalse(PreciseNumber.TryParse("+5", NumberStyles.None, CultureInfo.InvariantCulture, out _));
89+
Assert.IsFalse(PreciseNumber.TryParse("1,000", NumberStyles.Float, CultureInfo.InvariantCulture, out _));
90+
}
91+
92+
[TestMethod]
93+
public void Parse_RejectsASecondDecimalSeparator()
94+
{
95+
Assert.IsFalse(PreciseNumber.TryParse("1,2,3", CommaDecimal, out _));
96+
}
97+
98+
[TestMethod]
99+
public void Parse_ReadsTheExponentSignWithTheProvider()
100+
{
101+
Assert.AreEqual(P("0.015"), PreciseNumber.Parse("1,5e−2", CommaDecimal));
102+
}
103+
}

‎PreciseNumber/PreciseNumber.cs‎

Lines changed: 62 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1156,6 +1156,12 @@ public static PreciseNumber MinMagnitude(PreciseNumber x, PreciseNumber y)
11561156
/// <inheritdoc/>
11571157
public static PreciseNumber Parse(ReadOnlySpan<char> s, NumberStyles style, IFormatProvider? provider)
11581158
{
1159+
// Read the separator and signs from the same culture TryFormat writes them with, so that
1160+
// Parse(x.ToString(p), p) round-trips for every provider.
1161+
NumberFormatInfo numberFormat = NumberFormatInfo.GetInstance(provider ?? InvariantCulture);
1162+
1163+
s = TrimAllowedWhite(s, style);
1164+
11591165
if (s.IsEmpty)
11601166
{
11611167
throw new FormatException(InvalidFormatMessage);
@@ -1166,8 +1172,11 @@ public static PreciseNumber Parse(ReadOnlySpan<char> s, NumberStyles style, IFor
11661172
return Zero;
11671173
}
11681174

1169-
bool isNegative = s[0] == '-';
1170-
int startIndex = isNegative ? 1 : 0;
1175+
ReadOnlySpan<char> decimalSeparator = numberFormat.NumberDecimalSeparator;
1176+
ReadOnlySpan<char> groupSeparator = numberFormat.NumberGroupSeparator;
1177+
bool allowThousands = style.HasFlag(NumberStyles.AllowThousands) && !groupSeparator.IsEmpty;
1178+
1179+
int startIndex = ReadLeadingSign(s, style, numberFormat, out bool isNegative);
11711180

11721181
// Collect the digits first and hand them to BigInteger in one go. Accumulating with
11731182
// significand = significand * 10 + digit costs a full BigInteger multiply per character.
@@ -1185,21 +1194,29 @@ public static PreciseNumber Parse(ReadOnlySpan<char> s, NumberStyles style, IFor
11851194
for (int i = startIndex; i < s.Length; i++)
11861195
{
11871196
char c = s[i];
1188-
if (c == '.')
1197+
if (s[i..].StartsWith(decimalSeparator, StringComparison.Ordinal))
11891198
{
11901199
if (hasDecimal)
11911200
{
11921201
throw new FormatException(InvalidFormatMessage);
11931202
}
11941203

11951204
hasDecimal = true;
1205+
i += decimalSeparator.Length - 1;
1206+
continue;
1207+
}
1208+
1209+
// Group separators may only sit between integral digits.
1210+
if (allowThousands && !hasDecimal && digitCount > 0 && s[i..].StartsWith(groupSeparator, StringComparison.Ordinal))
1211+
{
1212+
i += groupSeparator.Length - 1;
11961213
continue;
11971214
}
11981215

11991216
if (c is 'e' or 'E')
12001217
{
12011218
// An exponent outside the range of int throws OverflowException, which TryParse reports as failure.
1202-
exponent = int.Parse(s[(i + 1)..], InvariantCulture);
1219+
exponent = int.Parse(s[(i + 1)..], NumberStyles.Integer, numberFormat);
12031220
break;
12041221
}
12051222

@@ -1241,6 +1258,47 @@ public static PreciseNumber Parse(ReadOnlySpan<char> s, NumberStyles style, IFor
12411258
}
12421259
}
12431260

1261+
/// <summary>Trims the leading and trailing white space that <paramref name="style"/> allows.</summary>
1262+
private static ReadOnlySpan<char> TrimAllowedWhite(ReadOnlySpan<char> s, NumberStyles style)
1263+
{
1264+
if (style.HasFlag(NumberStyles.AllowLeadingWhite))
1265+
{
1266+
s = s.TrimStart();
1267+
}
1268+
1269+
if (style.HasFlag(NumberStyles.AllowTrailingWhite))
1270+
{
1271+
s = s.TrimEnd();
1272+
}
1273+
1274+
return s;
1275+
}
1276+
1277+
/// <summary>
1278+
/// Reads a leading sign spelled as <paramref name="numberFormat"/> spells it, returning the index
1279+
/// of the first character after it. The ASCII hyphen stays accepted whatever the culture spells
1280+
/// its negative sign with, as it always has been here and as the BCL does for cultures that use
1281+
/// U+2212. A positive sign is only read when <paramref name="style"/> allows a leading sign.
1282+
/// </summary>
1283+
private static int ReadLeadingSign(ReadOnlySpan<char> s, NumberStyles style, NumberFormatInfo numberFormat, out bool isNegative)
1284+
{
1285+
isNegative = true;
1286+
if (s.StartsWith(numberFormat.NegativeSign, StringComparison.Ordinal))
1287+
{
1288+
return numberFormat.NegativeSign.Length;
1289+
}
1290+
1291+
if (s[0] == '-')
1292+
{
1293+
return 1;
1294+
}
1295+
1296+
isNegative = false;
1297+
return style.HasFlag(NumberStyles.AllowLeadingSign) && s.StartsWith(numberFormat.PositiveSign, StringComparison.Ordinal)
1298+
? numberFormat.PositiveSign.Length
1299+
: 0;
1300+
}
1301+
12441302
/// <inheritdoc/>
12451303
public static PreciseNumber Parse(string s, NumberStyles style, IFormatProvider? provider) =>
12461304
Parse(s.AsSpan(), style, provider);

0 commit comments

Comments
 (0)