Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
126 changes: 126 additions & 0 deletions Src/Newtonsoft.Json.Tests/JsonTextReaderTests/FloatTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -365,5 +365,131 @@ public void ReadFloatingPointNumber()
Assert.AreEqual(JsonToken.EndArray, jsonReader.TokenType);
}
}

[Test]
public void PreserveNumberText_Float_ReturnsRawText()
{
const string testJson = "[1.5, 42, 3.14]";
using JsonTextReader reader = new JsonTextReader(new StringReader(testJson));
reader.PreserveNumberText = true;

reader.Read(); // [

reader.Read(); // 1.5
Assert.AreEqual(JsonToken.Float, reader.TokenType);
Assert.AreEqual("1.5", reader.NumberText);

reader.Read(); // 42
Assert.AreEqual(JsonToken.Integer, reader.TokenType);
Assert.AreEqual("42", reader.NumberText);

reader.Read(); // 3.14
Assert.AreEqual(JsonToken.Float, reader.TokenType);
Assert.AreEqual("3.14", reader.NumberText);

reader.Read(); // ]
Assert.AreEqual(JsonToken.EndArray, reader.TokenType);
Assert.IsNull(reader.NumberText);
}

[Test]
public void PreserveNumberText_NegativeDoubleMaxValue_RawTextExact()
{
const string testJson = "[-1.7976931348623157E+308]";
using JsonTextReader reader = new JsonTextReader(new StringReader(testJson));
reader.PreserveNumberText = true;

reader.Read(); // [
reader.Read(); // -1.7976931348623157E+308
Assert.AreEqual(JsonToken.Float, reader.TokenType);
Assert.AreEqual("-1.7976931348623157E+308", reader.NumberText);
Assert.AreEqual(-double.MaxValue, (double)reader.Value!);
}

[Test]
public void PreserveNumberText_Disabled_NumberTextIsNull()
{
const string testJson = "[1.5]";
using JsonTextReader reader = new JsonTextReader(new StringReader(testJson));

reader.Read(); // [
reader.Read(); // 1.5
Assert.AreEqual(JsonToken.Float, reader.TokenType);
Assert.IsNull(reader.NumberText);
}

[Test]
public void PreserveNumberText_DoubleMaxValue_RawTextExact()
{
string doubleMax = double.MaxValue.ToString("R", CultureInfo.InvariantCulture);
string testJson = "[" + doubleMax + "]";
using JsonTextReader reader = new JsonTextReader(new StringReader(testJson));
reader.PreserveNumberText = true;

reader.Read();
reader.Read();
Assert.AreEqual(JsonToken.Float, reader.TokenType);
Assert.AreEqual(doubleMax, reader.NumberText);
}

[Test]
public void OnNumberParsed_Override_CanChangeParsedValue()
{
// Verify that a subclass can intercept number tokens and replace the value
string testJson = "[1.7976931348623157E+308]"; // double.MaxValue — overflows decimal
using SmartNumberReader reader = new SmartNumberReader(new StringReader(testJson));

reader.Read(); // [
reader.Read(); // number

Assert.AreEqual(JsonToken.Float, reader.TokenType);
Assert.IsInstanceOf(typeof(double), reader.Value);
Assert.AreEqual(double.MaxValue, (double)reader.Value!);
}

[Test]
public void OnNumberParsed_Override_HighPrecisionDecimal_PreservesExactValue()
{
// 29 significant digits — exact as decimal, lossy as double (~15-16 sig figs)
const string rawDecimal = "1.23456789012345678901234567890";
const string testJson = "[" + rawDecimal + "]";
using SmartNumberReader reader = new SmartNumberReader(new StringReader(testJson));

reader.Read(); // [
reader.Read(); // number

// Default FloatParseHandling.Double would lose precision; SmartNumberReader picks decimal
Assert.AreEqual(JsonToken.Float, reader.TokenType);
Assert.IsInstanceOf(typeof(decimal), reader.Value);
Assert.AreEqual(decimal.Parse(rawDecimal, CultureInfo.InvariantCulture), (decimal)reader.Value!);
}

// Minimal subclass demonstrating Option C: override OnNumberParsed to pick the
// best .NET numeric type for each float token based on the raw source text.
private sealed class SmartNumberReader : JsonTextReader
{
public SmartNumberReader(TextReader reader) : base(reader)
{
PreserveNumberText = true;
}

protected override void OnNumberParsed(string rawText, ref JsonToken tokenType, ref object value)
{
if (tokenType != JsonToken.Float)
{
return;
}

// Try decimal first (higher precision), fall back to double for out-of-range values.
if (decimal.TryParse(rawText, NumberStyles.Float, CultureInfo.InvariantCulture, out decimal d))
{
value = d;
}
else if (double.TryParse(rawText, NumberStyles.Float, CultureInfo.InvariantCulture, out double dbl))
{
value = dbl;
}
}
}
}
}
65 changes: 65 additions & 0 deletions Src/Newtonsoft.Json/JsonTextReader.cs
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,10 @@ public partial class JsonTextReader : JsonReader, IJsonLineInfo
private StringBuffer _stringBuffer;
private StringReference _stringReference;
private IArrayPool<char>? _arrayPool;
private bool _preserveNumberText;
private string? _numberText;
// cached at construction time so hot paths avoid repeated GetType() virtual calls
private readonly bool _hasNumberOverride;

/// <summary>
/// Initializes a new instance of the <see cref="JsonTextReader"/> class with the specified <see cref="TextReader"/>.
Expand All @@ -90,6 +94,7 @@ public JsonTextReader(TextReader reader)

_reader = reader;
_lineNumber = 1;
_hasNumberOverride = GetType() != typeof(JsonTextReader);

#if HAVE_ASYNC
_safeAsync = GetType() == typeof(JsonTextReader);
Expand Down Expand Up @@ -128,6 +133,53 @@ public IArrayPool<char>? ArrayPool
}
}

/// <summary>
/// Gets or sets a value indicating whether the raw source text of each number token is
/// preserved and accessible via <see cref="NumberText"/> after <see cref="JsonReader.Read"/> returns.
/// When <c>false</c> (the default), <see cref="NumberText"/> is always <c>null</c>.
/// Enabling this causes one string allocation per number token parsed.
/// </summary>
public bool PreserveNumberText
{
get => _preserveNumberText;
set => _preserveNumberText = value;
}

/// <summary>
/// Gets the raw source text of the current number token, or <c>null</c> if the current token
/// is not <see cref="JsonToken.Integer"/> or <see cref="JsonToken.Float"/>, or if
/// <see cref="PreserveNumberText"/> is <c>false</c>.
/// </summary>
public string? NumberText
{
get
{
if (TokenType != JsonToken.Integer && TokenType != JsonToken.Float)
{
return null;
}

return _numberText;
}
}

/// <summary>
/// Called after a number token has been parsed and its default value determined, but before
/// the token is committed via <see cref="JsonReader.SetToken(JsonToken, object)"/>.
/// Override to replace <paramref name="tokenType"/> and/or <paramref name="value"/> with a
/// more precise representation (e.g. choose between <see cref="double"/> and
/// <see cref="decimal"/> based on the raw text).
/// </summary>
/// <param name="rawText">The exact source text of the number as it appeared in the JSON.</param>
/// <param name="tokenType">
/// The resolved token type (<see cref="JsonToken.Integer"/> or <see cref="JsonToken.Float"/>).
/// May be changed by the override.
/// </param>
/// <param name="value">The resolved boxed value. May be changed by the override.</param>
protected virtual void OnNumberParsed(string rawText, ref JsonToken tokenType, ref object value)
{
}

private void EnsureBufferNotEmpty()
{
if (_stringBuffer.IsEmpty)
Expand Down Expand Up @@ -2236,8 +2288,21 @@ private void ParseReadNumber(ReadType readType, char firstChar, int initialPosit
throw JsonReaderException.Create(this, "Cannot read number value as type.");
}

// Capture raw text before the string reference is cleared.
// Only pay the allocation cost when PreserveNumberText is set or a subclass may
// override OnNumberParsed (detected once at construction time via _hasNumberOverride).
string? rawText = (_preserveNumberText || _hasNumberOverride)
? _stringReference.ToString()
: null;

_numberText = rawText;
ClearRecentString();

if (rawText != null)
{
OnNumberParsed(rawText, ref numberType, ref numberValue);
}

// index has already been updated
SetToken(numberType, numberValue, false);
}
Expand Down