From 5273188f7db7a921733f2cc2e4e15fb40ccbaa37 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 7 Oct 2026 19:35:15 +0000 Subject: [PATCH] Honour AllowDecimalPoint, AllowExponent and AllowLeadingSign in Parse [patch] Parse(ReadOnlySpan, NumberStyles, IFormatProvider?) accepted a decimal separator, an exponent and a leading minus whatever the style said, so "1.5" and "1e5" parsed under NumberStyles.Integer and "-5" under NumberStyles.None. Code that validates input through INumberBase.TryParse with a narrow style let through values it had excluded, where every BCL numeric type rejects them. Each of the three is now read only when its flag is set, and the exponent digits are parsed with AllowLeadingSign rather than Integer, so "1e 5" is rejected too. Behaviour change: callers passing NumberStyles.Integer or None now get a FormatException (or false from TryParse) for fractional, exponent or signed text, as with decimal. Fixes ktsu-dev/PreciseNumber#132 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01FtvW1bPVVfCTF7fCWpeyRE --- .../PreciseNumberParseCultureTests.cs | 50 +++++++++++++++++++ PreciseNumber/PreciseNumber.cs | 21 ++++++-- 2 files changed, 66 insertions(+), 5 deletions(-) diff --git a/PreciseNumber.Test/PreciseNumberParseCultureTests.cs b/PreciseNumber.Test/PreciseNumberParseCultureTests.cs index 6cdf76a..59108e9 100644 --- a/PreciseNumber.Test/PreciseNumberParseCultureTests.cs +++ b/PreciseNumber.Test/PreciseNumberParseCultureTests.cs @@ -100,4 +100,54 @@ public void Parse_ReadsTheExponentSignWithTheProvider() { Assert.AreEqual(P("0.015"), PreciseNumber.Parse("1,5eāˆ’2", CommaDecimal)); } + + [TestMethod] + [DataRow("1.5", NumberStyles.Integer)] + [DataRow("1e5", NumberStyles.Integer)] + [DataRow("-5", NumberStyles.None)] + [DataRow("1e 5", NumberStyles.Float)] + [DataRow("1e5", NumberStyles.Number)] + [DataRow("1.5", NumberStyles.None)] + [DataRow("1.5", NumberStyles.Float)] + [DataRow("-1.5e-3", NumberStyles.Float)] + [DataRow("-1.5e-3", NumberStyles.Any)] + [DataRow("1,234.5", NumberStyles.Any)] + [DataRow("-5", NumberStyles.Integer)] + [DataRow("1e+5", NumberStyles.Float)] + public void TryParse_AcceptsWhatDecimalAccepts(string text, NumberStyles style) + { + bool decimalParsed = decimal.TryParse(text, style, CultureInfo.InvariantCulture, out decimal expected); + bool parsed = PreciseNumber.TryParse(text, style, CultureInfo.InvariantCulture, out PreciseNumber actual); + + Assert.AreEqual(decimalParsed, parsed, $"PreciseNumber and decimal disagree on whether \"{text}\" parses under {style}."); + if (parsed) + { + Assert.AreEqual(P(expected.ToString(CultureInfo.InvariantCulture)), actual); + } + } + + [TestMethod] + public void Parse_ThrowsForWhatTheStyleDoesNotAllow() + { + Assert.ThrowsExactly(() => PreciseNumber.Parse("1.5", NumberStyles.Integer, CultureInfo.InvariantCulture)); + Assert.ThrowsExactly(() => PreciseNumber.Parse("1e5", NumberStyles.Integer, CultureInfo.InvariantCulture)); + Assert.ThrowsExactly(() => PreciseNumber.Parse("-5", NumberStyles.None, CultureInfo.InvariantCulture)); + Assert.ThrowsExactly(() => PreciseNumber.Parse("1e 5", NumberStyles.Float, CultureInfo.InvariantCulture)); + } + + [TestMethod] + public void Parse_WithoutAStyleStillReadsFractionsExponentsAndSigns() + { + Assert.AreEqual(P("1.5"), PreciseNumber.Parse("1.5", CultureInfo.InvariantCulture)); + Assert.AreEqual(P("-0.0015"), PreciseNumber.Parse("-1.5e-3", CultureInfo.InvariantCulture)); + Assert.AreEqual(P("1234.5"), PreciseNumber.Parse("1,234.5", CultureInfo.InvariantCulture)); + } + + [TestMethod] + public void Parse_ReadsACulturesNegativeSignOnlyWhenTheStyleAllowsIt() + { + Assert.AreEqual(P("-2.5"), PreciseNumber.Parse("āˆ’2,5", NumberStyles.Float, CommaDecimal)); + Assert.IsFalse(PreciseNumber.TryParse("āˆ’2", NumberStyles.None, CommaDecimal, out _)); + Assert.IsFalse(PreciseNumber.TryParse("-2", NumberStyles.None, CommaDecimal, out _)); + } } diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index a3da4fd..621bd81 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -1210,6 +1210,8 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor ReadOnlySpan decimalSeparator = numberFormat.NumberDecimalSeparator; ReadOnlySpan groupSeparator = numberFormat.NumberGroupSeparator; bool allowThousands = style.HasFlag(NumberStyles.AllowThousands) && !groupSeparator.IsEmpty; + bool allowDecimalPoint = style.HasFlag(NumberStyles.AllowDecimalPoint); + bool allowExponent = style.HasFlag(NumberStyles.AllowExponent); int startIndex = ReadLeadingSign(s, style, numberFormat, out bool isNegative); @@ -1229,7 +1231,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor for (int i = startIndex; i < s.Length; i++) { char c = s[i]; - if (s[i..].StartsWith(decimalSeparator, StringComparison.Ordinal)) + if (allowDecimalPoint && s[i..].StartsWith(decimalSeparator, StringComparison.Ordinal)) { if (hasDecimal) { @@ -1248,10 +1250,12 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor continue; } - if (c is 'e' or 'E') + if (allowExponent && 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)..], NumberStyles.Integer, numberFormat); + // Only a sign may precede its digits: NumberStyles.Integer would also let white space through, + // accepting "1e 5" where every BCL numeric type rejects it. + exponent = int.Parse(s[(i + 1)..], NumberStyles.AllowLeadingSign, numberFormat); break; } @@ -1313,10 +1317,17 @@ private static ReadOnlySpan TrimAllowedWhite(ReadOnlySpan s, NumberS /// 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. + /// U+2212. Neither sign is read unless allows a leading sign, so under + /// a sign is left in place and rejected as a non-digit. /// private static int ReadLeadingSign(ReadOnlySpan s, NumberStyles style, NumberFormatInfo numberFormat, out bool isNegative) { + isNegative = false; + if (!style.HasFlag(NumberStyles.AllowLeadingSign)) + { + return 0; + } + isNegative = true; if (s.StartsWith(numberFormat.NegativeSign, StringComparison.Ordinal)) { @@ -1329,7 +1340,7 @@ private static int ReadLeadingSign(ReadOnlySpan s, NumberStyles style, Num } isNegative = false; - return style.HasFlag(NumberStyles.AllowLeadingSign) && s.StartsWith(numberFormat.PositiveSign, StringComparison.Ordinal) + return s.StartsWith(numberFormat.PositiveSign, StringComparison.Ordinal) ? numberFormat.PositiveSign.Length : 0; }