diff --git a/src/Argon.FSharp.Tests/Argon.FSharp.Tests.fsproj b/src/Argon.FSharp.Tests/Argon.FSharp.Tests.fsproj
index e9d381dd..7b531aa2 100644
--- a/src/Argon.FSharp.Tests/Argon.FSharp.Tests.fsproj
+++ b/src/Argon.FSharp.Tests/Argon.FSharp.Tests.fsproj
@@ -9,6 +9,7 @@
+
diff --git a/src/Argon.FSharp.Tests/NullTokenTests.fs b/src/Argon.FSharp.Tests/NullTokenTests.fs
new file mode 100644
index 00000000..2b39adb2
--- /dev/null
+++ b/src/Argon.FSharp.Tests/NullTokenTests.fs
@@ -0,0 +1,31 @@
+module NullTokenTests
+
+open Argon
+open Xunit
+
+type ListHolder() =
+ member val List: int list = [] with get, set
+ member val After: int = 0 with get, set
+
+type MapHolder() =
+ member val Map: Map = Map.empty with get, set
+ member val After: int = 0 with get, set
+
+// Bug: FSharpListConverter.ReadJson had no Null handling, so a JSON null crashed and
+// desynced the reader instead of yielding a null list.
+[]
+let ``Deserialize null into FSharp list property`` () =
+ let result =
+ JsonConvert.DeserializeObject("""{"List": null, "After": 7}""", FSharpListConverter())
+
+ Assert.Null(box result.List)
+ Assert.Equal(7, result.After)
+
+// Bug: FSharpMapConverter.ReadJson had the same missing Null check.
+[]
+let ``Deserialize null into FSharp map property`` () =
+ let result =
+ JsonConvert.DeserializeObject("""{"Map": null, "After": 7}""", FSharpMapConverter())
+
+ Assert.Null(box result.Map)
+ Assert.Equal(7, result.After)
diff --git a/src/Argon.FSharp/FSharpListConverter.cs b/src/Argon.FSharp/FSharpListConverter.cs
index 4fb2162d..01fcf847 100644
--- a/src/Argon.FSharp/FSharpListConverter.cs
+++ b/src/Argon.FSharp/FSharpListConverter.cs
@@ -21,6 +21,11 @@ public override void WriteJson(JsonWriter writer, object value, JsonSerializer s
public override object? ReadJson(JsonReader reader, Type type, object? existingValue, JsonSerializer serializer)
{
+ if (reader.TokenType == JsonToken.Null)
+ {
+ return null;
+ }
+
var genericArgument = type.GetGenericArguments()[0];
return readList.MakeGenericMethod(genericArgument)
.Invoke(
diff --git a/src/Argon.FSharp/FSharpMapConverter.cs b/src/Argon.FSharp/FSharpMapConverter.cs
index 6fb1d55f..1b0a1413 100644
--- a/src/Argon.FSharp/FSharpMapConverter.cs
+++ b/src/Argon.FSharp/FSharpMapConverter.cs
@@ -24,6 +24,11 @@ public static void WriteMap(JsonWriter writer, FSharpMap value, Json
public override object? ReadJson(JsonReader reader, Type type, object? existingValue, JsonSerializer serializer)
{
+ if (reader.TokenType == JsonToken.Null)
+ {
+ return null;
+ }
+
var arguments = type.GetGenericArguments();
return readMap.MakeGenericMethod(arguments[0], arguments[1])
.Invoke(
diff --git a/src/Argon.JsonPath/FieldMultipleFilter.cs b/src/Argon.JsonPath/FieldMultipleFilter.cs
index ce1fd3dd..ca979ed0 100644
--- a/src/Argon.JsonPath/FieldMultipleFilter.cs
+++ b/src/Argon.JsonPath/FieldMultipleFilter.cs
@@ -17,8 +17,7 @@ public override IEnumerable ExecuteFilter(JToken root, IEnumerable
- new XmlDocumentTypeWrapper(document.CreateDocumentType(name, publicId, systemId, null));
+ new XmlDocumentTypeWrapper(document.CreateDocumentType(name, publicId, systemId, internalSubset));
public IXmlNode CreateProcessingInstruction(string target, string data) =>
new XmlNodeWrapper(document.CreateProcessingInstruction(target, data));
diff --git a/src/Argon.Xml/XmlNodeConverter.cs b/src/Argon.Xml/XmlNodeConverter.cs
index 294cc58e..c8a9b771 100644
--- a/src/Argon.Xml/XmlNodeConverter.cs
+++ b/src/Argon.Xml/XmlNodeConverter.cs
@@ -295,6 +295,19 @@ void WriteGroupedNodes(JsonWriter writer, XmlNamespaceManager manager, bool writ
if (writeArray)
{
+ // Comments share the name "#comment" and cannot be array values. Write each as an
+ // inline JSON comment (which round-trips back to XML comments) instead of an empty
+ // array that silently drops the comment text.
+ if (groupedNodes[0].NodeType == XmlNodeType.Comment)
+ {
+ foreach (var node in groupedNodes)
+ {
+ writer.WriteComment(node.Value);
+ }
+
+ return;
+ }
+
if (writePropertyName)
{
writer.WritePropertyName(elementNames);
@@ -614,16 +627,16 @@ void DeserializeValue(JsonReader reader, IXmlDocument document, XmlNamespaceMana
switch (propertyName)
{
case textName:
- currentNode.AppendChild(document.CreateTextNode(ConvertTokenToXmlValue(reader)!));
+ currentNode.AppendChild(document.CreateTextNode(ConvertTokenToXmlValue(reader) ?? ""));
return;
case cDataName:
- currentNode.AppendChild(document.CreateCDataSection(ConvertTokenToXmlValue(reader)!));
+ currentNode.AppendChild(document.CreateCDataSection(ConvertTokenToXmlValue(reader) ?? ""));
return;
case whitespaceName:
- currentNode.AppendChild(document.CreateWhitespace(ConvertTokenToXmlValue(reader)!));
+ currentNode.AppendChild(document.CreateWhitespace(ConvertTokenToXmlValue(reader) ?? ""));
return;
case significantWhitespaceName:
- currentNode.AppendChild(document.CreateSignificantWhitespace(ConvertTokenToXmlValue(reader)!));
+ currentNode.AppendChild(document.CreateSignificantWhitespace(ConvertTokenToXmlValue(reader) ?? ""));
return;
default:
// processing instructions and the xml declaration start with ?
@@ -874,9 +887,15 @@ void ReadArrayElements(JsonReader reader, IXmlDocument document, string property
if (count == 1 && WriteArrayAttribute)
{
+ // Match on the decoded local name and namespace (not the raw JSON property name),
+ // otherwise prefixed or XML-encoded names never match and a single-element nested
+ // array loses its json:Array marker on round-trip.
+ XmlUtils.GetQualifiedNameParts(propertyName, out var qualifiedPrefix, out var localName);
+ var ns = qualifiedPrefix.IsNullOrEmpty() ? manager.DefaultNamespace : manager.LookupNamespace(qualifiedPrefix);
+
foreach (var childNode in nestedArrayElement.ChildNodes)
{
- if (childNode is IXmlElement element && element.LocalName == propertyName)
+ if (childNode is IXmlElement element && element.LocalName == localName && element.NamespaceUri == ns)
{
AddJsonArrayAttribute(element, document);
break;
diff --git a/src/Argon/JsonConvert.cs b/src/Argon/JsonConvert.cs
index f52b0e02..d3640caa 100644
--- a/src/Argon/JsonConvert.cs
+++ b/src/Argon/JsonConvert.cs
@@ -245,8 +245,8 @@ public static string ToString(Guid value)
public static string ToString(TimeSpan value)
{
Span destination = stackalloc char[26];
- value.TryFormat(destination, out _, ['c']);
- return ToString(destination, '"');
+ value.TryFormat(destination, out var charsWritten, ['c']);
+ return ToString(destination[..charsWritten], '"');
}
///
diff --git a/src/Argon/JsonConverter.cs b/src/Argon/JsonConverter.cs
index d889eeae..ef966c7b 100644
--- a/src/Argon/JsonConverter.cs
+++ b/src/Argon/JsonConverter.cs
@@ -65,7 +65,7 @@ public sealed override void WriteJson(JsonWriter writer, object value, JsonSeria
throw new JsonSerializationException($"Converter cannot read JSON with the specified existing value. {typeof(T)} is required.");
}
- if (reader.Value is null)
+ if (reader.TokenType is JsonToken.Null or JsonToken.Undefined)
{
return null;
}
diff --git a/src/Argon/JsonReader.cs b/src/Argon/JsonReader.cs
index 4571c991..972b1797 100644
--- a/src/Argon/JsonReader.cs
+++ b/src/Argon/JsonReader.cs
@@ -1028,7 +1028,7 @@ protected void SetNoneToken()
///
protected void SetUndefinedToken()
{
- tokenType = JsonToken.Null;
+ tokenType = JsonToken.Undefined;
value = null;
SetPostValueState(false);
}
diff --git a/src/Argon/JsonSerializer.cs b/src/Argon/JsonSerializer.cs
index 6c8beaf7..2c6cd41b 100644
--- a/src/Argon/JsonSerializer.cs
+++ b/src/Argon/JsonSerializer.cs
@@ -129,12 +129,15 @@ public virtual IContractResolver? ContractResolver
public JsonContract ResolveContract(Type type)
{
- if (contractResolver == null)
+ // Read through the virtual ContractResolver property so that subclasses
+ // (e.g. JsonSerializerProxy handed to converters) can redirect resolution.
+ var resolver = ContractResolver;
+ if (resolver == null)
{
return DefaultContractResolver.Instance.ResolveContract(type);
}
- return contractResolver.ResolveContract(type);
+ return resolver.ResolveContract(type);
}
///
diff --git a/src/Argon/JsonTextReader.cs b/src/Argon/JsonTextReader.cs
index e3375185..2844cea8 100644
--- a/src/Argon/JsonTextReader.cs
+++ b/src/Argon/JsonTextReader.cs
@@ -2171,9 +2171,15 @@ object ParseNumberNaN(ReadType readType, bool matched)
///
public override void Close()
{
+ if (CurrentState == State.Closed)
+ {
+ return;
+ }
+
base.Close();
BufferUtils.ReturnBuffer(charBuffer);
+ charBuffer = [];
if (CloseInput)
{
diff --git a/src/Argon/JsonTextWriter.cs b/src/Argon/JsonTextWriter.cs
index 2d4de301..d982c0bc 100644
--- a/src/Argon/JsonTextWriter.cs
+++ b/src/Argon/JsonTextWriter.cs
@@ -459,7 +459,7 @@ public override void WriteValue(ushort value)
public override void WriteValue(char value)
{
InternalWriteValue(JsonToken.String);
- WriteValueInternal(JsonConvert.ToString(value));
+ WriteEscapedString([value], QuoteValue);
}
///
diff --git a/src/Argon/JsonWriter.cs b/src/Argon/JsonWriter.cs
index fba3dd26..5a9428d0 100644
--- a/src/Argon/JsonWriter.cs
+++ b/src/Argon/JsonWriter.cs
@@ -796,10 +796,7 @@ public virtual void WriteValue(StringBuilder? value)
return;
}
- foreach (var chunk in value.GetChunks())
- {
- WriteValue(chunk.Span);
- }
+ WriteValue(value.ToString());
}
///
diff --git a/src/Argon/Linq/JContainer.cs b/src/Argon/Linq/JContainer.cs
index 62b3fd27..51ef1055 100644
--- a/src/Argon/Linq/JContainer.cs
+++ b/src/Argon/Linq/JContainer.cs
@@ -402,8 +402,10 @@ internal bool TryAddInternal(int index, object? content, bool skipParentCheck)
var multiIndex = index;
foreach (var c in enumerable)
{
- TryAddInternal(multiIndex, c, skipParentCheck);
- multiIndex++;
+ if (TryAddInternal(multiIndex, c, skipParentCheck))
+ {
+ multiIndex++;
+ }
}
return true;
diff --git a/src/Argon/Linq/JObject.cs b/src/Argon/Linq/JObject.cs
index ac11f7be..b8e10524 100644
--- a/src/Argon/Linq/JObject.cs
+++ b/src/Argon/Linq/JObject.cs
@@ -458,7 +458,10 @@ public bool TryGetValue(string propertyName, [NotNullWhen(true)] out JToken? val
return true;
}
- ICollection IDictionary.Values => throw new NotImplementedException();
+ ICollection IDictionary.Values =>
+ Properties()
+ .Select(_ => (JToken?) _.Value)
+ .ToList();
#endregion
diff --git a/src/Argon/Linq/JValue.cs b/src/Argon/Linq/JValue.cs
index a2d13c41..0c51c004 100644
--- a/src/Argon/Linq/JValue.cs
+++ b/src/Argon/Linq/JValue.cs
@@ -176,13 +176,13 @@ static int CompareBigInteger(BigInteger i1, object i2)
// check for fraction if result is two numbers are equal
if (i2 is decimal d1)
{
- return 0m.CompareTo(Math.Abs(d1 - Math.Truncate(d1)));
+ return 0m.CompareTo(d1 - Math.Truncate(d1));
}
if (i2 is double or float)
{
var d = Convert.ToDouble(i2, InvariantCulture);
- return 0d.CompareTo(Math.Abs(d - Math.Truncate(d)));
+ return 0d.CompareTo(d - Math.Truncate(d));
}
return result;
@@ -759,14 +759,33 @@ public override void WriteTo(JsonWriter writer, params IList conv
throw MiscellaneousUtils.CreateArgumentOutOfRangeException(nameof(Type), valueType, "Unexpected token type.");
}
- internal override int GetDeepHashCode()
+ // Numeric JValues of the same JTokenType are equal by numeric value regardless of the
+ // backing CLR type (int vs long, decimal vs double, BigInteger). Hash a canonical double
+ // so GetHashCode stays consistent with Equals for exact numeric values. Values that are
+ // only approximately equal cannot share a hash - that is inherent to the approximate float
+ // comparison used by Equals.
+ int GetValueHashCode()
{
- var valueHashCode = value?.GetHashCode() ?? 0;
+ if (value == null)
+ {
+ return 0;
+ }
- // GetHashCode on an enum boxes so cast to int
- return ((int) valueType).GetHashCode() ^ valueHashCode;
+ if (valueType is JTokenType.Integer or JTokenType.Float)
+ {
+ var d = value is BigInteger bigInteger
+ ? (double) bigInteger
+ : Convert.ToDouble(value, InvariantCulture);
+ return d.GetHashCode();
+ }
+
+ return value.GetHashCode();
}
+ internal override int GetDeepHashCode() =>
+ // GetHashCode on an enum boxes so cast to int
+ ((int) valueType).GetHashCode() ^ GetValueHashCode();
+
static bool ValuesEquals(JValue v1, JValue v2) =>
v1 == v2 || (v1.valueType == v2.valueType && Compare(v1.valueType, v1.value, v2.value) == 0);
@@ -804,15 +823,8 @@ public override bool Equals(object? obj)
///
/// A hash code for the current .
///
- public override int GetHashCode()
- {
- if (value == null)
- {
- return 0;
- }
-
- return value.GetHashCode();
- }
+ public override int GetHashCode() =>
+ GetValueHashCode();
///
/// Returns a that represents this instance.
diff --git a/src/Argon/Serialization/DefaultContractResolver.cs b/src/Argon/Serialization/DefaultContractResolver.cs
index 628ea938..1cdb359e 100644
--- a/src/Argon/Serialization/DefaultContractResolver.cs
+++ b/src/Argon/Serialization/DefaultContractResolver.cs
@@ -37,7 +37,6 @@ public class DefaultContractResolver : IContractResolver
new EncodingConverter(),
new PathInfoConverter(),
new RegexConverter(),
- new EncodingConverter(),
new TimeZoneInfoConverter(),
new VersionConverter(),
new StringWriterConverter()
@@ -690,7 +689,9 @@ protected virtual IList CreateProperties(Type type, MemberSerializ
if (needsSort)
{
- list.Sort(static (a, b) => (a.Order ?? -1).CompareTo(b.Order ?? -1));
+ // OrderBy is a stable sort, so properties without an explicit Order keep
+ // their declaration order. List.Sort (introsort) is unstable above 16 items.
+ return list.OrderBy(_ => _.Order ?? -1).ToList();
}
return list;
diff --git a/src/Argon/Serialization/JsonSerializerInternalWriter.cs b/src/Argon/Serialization/JsonSerializerInternalWriter.cs
index e11604e9..f5aef7f7 100644
--- a/src/Argon/Serialization/JsonSerializerInternalWriter.cs
+++ b/src/Argon/Serialization/JsonSerializerInternalWriter.cs
@@ -912,7 +912,7 @@ static IEnumerable Items(IDictionary values)
{
if (contract.DictionaryKeyType == typeof(string))
{
- foreach (var entry in Items(values).OrderBy(_ => ((string) _.Key, StringComparer.OrdinalIgnoreCase)))
+ foreach (var entry in Items(values).OrderBy(_ => (string) _.Key, StringComparer.OrdinalIgnoreCase))
{
SerializeDictionaryItem(writer, contract, member, entry.Key, entry.Value, keyContract, underlying);
}
diff --git a/src/Argon/Serialization/JsonSerializerProxy.cs b/src/Argon/Serialization/JsonSerializerProxy.cs
index 4f039646..d4b88959 100644
--- a/src/Argon/Serialization/JsonSerializerProxy.cs
+++ b/src/Argon/Serialization/JsonSerializerProxy.cs
@@ -67,6 +67,8 @@ public override IContractResolver? ContractResolver
set => serializer.ContractResolver = value;
}
+ public override List Converters => serializer.Converters;
+
public override MissingMemberHandling? MissingMemberHandling
{
get => serializer.MissingMemberHandling;
diff --git a/src/Argon/Utilities/BoxedPrimitives.cs b/src/Argon/Utilities/BoxedPrimitives.cs
index 77b53df4..9958d787 100644
--- a/src/Argon/Utilities/BoxedPrimitives.cs
+++ b/src/Argon/Utilities/BoxedPrimitives.cs
@@ -78,16 +78,21 @@ internal static object Get(decimal value)
{
Span bits = stackalloc int[4];
decimal.GetBits(value, bits);
- var scale = (byte) (bits[3] >> ScaleShift);
- // Only use cached boxed value if value is zero and there is zero or one trailing zeros.
- if (scale == 0)
+ // Preserve the sign of negative zero (-0.0m). The cached boxes are positive zero,
+ // so only use them for positive zero - consistent across scales and with Get(double).
+ if ((bits[3] & int.MinValue) == 0)
{
- return DecimalZero;
- }
-
- if (scale == 1)
- {
- return DecimalZeroWithTrailingZero;
+ var scale = (byte) (bits[3] >> ScaleShift);
+ // Only use cached boxed value if value is zero and there is zero or one trailing zeros.
+ if (scale == 0)
+ {
+ return DecimalZero;
+ }
+
+ if (scale == 1)
+ {
+ return DecimalZeroWithTrailingZero;
+ }
}
}
#endif
diff --git a/src/Argon/Utilities/DateTimeUtils.cs b/src/Argon/Utilities/DateTimeUtils.cs
index 74bb9e9a..85bda8ef 100644
--- a/src/Argon/Utilities/DateTimeUtils.cs
+++ b/src/Argon/Utilities/DateTimeUtils.cs
@@ -52,6 +52,14 @@ internal static bool TryParseDateTimeOffsetIso(StringReference text, out DateTim
break;
}
+ // DateTimeOffset only permits offsets within +/- 14 hours. The ISO parser accepts
+ // larger zone hour/minute values, so reject them here instead of throwing from the ctor.
+ if (Math.Abs(offset.Ticks) > 14 * TimeSpan.TicksPerHour)
+ {
+ dt = default;
+ return false;
+ }
+
var ticks = d.Ticks - offset.Ticks;
if (ticks is < 0 or > 3155378975999999999)
{
diff --git a/src/Argon/Utilities/ReflectionUtils.cs b/src/Argon/Utilities/ReflectionUtils.cs
index 4b5d1a4f..24cb766b 100644
--- a/src/Argon/Utilities/ReflectionUtils.cs
+++ b/src/Argon/Utilities/ReflectionUtils.cs
@@ -712,6 +712,7 @@ public static bool IsMethodOverridden(
case PrimitiveTypeCode.Boolean:
return false;
case PrimitiveTypeCode.Char:
+ return '\0';
case PrimitiveTypeCode.SByte:
case PrimitiveTypeCode.Byte:
case PrimitiveTypeCode.Int16:
diff --git a/src/ArgonTests/ArgonTests.csproj b/src/ArgonTests/ArgonTests.csproj
index ec05ea36..5294a977 100644
--- a/src/ArgonTests/ArgonTests.csproj
+++ b/src/ArgonTests/ArgonTests.csproj
@@ -43,6 +43,7 @@
+
diff --git a/src/ArgonTests/BugFixes/ContractResolverBugFixes.cs b/src/ArgonTests/BugFixes/ContractResolverBugFixes.cs
new file mode 100644
index 00000000..7f55ebed
--- /dev/null
+++ b/src/ArgonTests/BugFixes/ContractResolverBugFixes.cs
@@ -0,0 +1,65 @@
+// Copyright (c) 2007 James Newton-King. All rights reserved.
+// Use of this source code is governed by The MIT License,
+// as found in the license.md file.
+
+public class ContractResolverBugFixes : TestFixtureBase
+{
+ // Bug: CreateProperties used List.Sort (unstable introsort) so properties without an
+ // explicit Order lost their declaration order once the type had more than 16 members
+ // and any property carried an Order.
+
+ [Fact]
+ public void Property_order_is_stable_when_a_property_has_order()
+ {
+ var json = JsonConvert.SerializeObject(new ManyProperties());
+ var names = JObject.Parse(json)
+ .Properties()
+ .Select(_ => _.Name)
+ .ToList();
+
+ var expected = new List();
+ for (var i = 1; i <= 18; i++)
+ {
+ expected.Add($"P{i:D2}");
+ }
+
+ // The ordered property sorts last (Order 1 > the implicit -1 of the rest),
+ // and the unordered properties keep declaration order.
+ expected.Add("Ordered");
+
+ Assert.Equal(expected, names);
+ }
+
+ [Fact]
+ public void Default_converters_contains_a_single_encoding_converter()
+ {
+ var count = DefaultContractResolver.Converters.Count(_ => _.GetType().Name == "EncodingConverter");
+
+ Assert.Equal(1, count);
+ }
+
+ public class ManyProperties
+ {
+ public int P01 { get; set; }
+ public int P02 { get; set; }
+ public int P03 { get; set; }
+ public int P04 { get; set; }
+ public int P05 { get; set; }
+ public int P06 { get; set; }
+ public int P07 { get; set; }
+ public int P08 { get; set; }
+ public int P09 { get; set; }
+ public int P10 { get; set; }
+ public int P11 { get; set; }
+ public int P12 { get; set; }
+ public int P13 { get; set; }
+ public int P14 { get; set; }
+ public int P15 { get; set; }
+ public int P16 { get; set; }
+ public int P17 { get; set; }
+ public int P18 { get; set; }
+
+ [JsonProperty(Order = 1)]
+ public int Ordered { get; set; }
+ }
+}
diff --git a/src/ArgonTests/BugFixes/JsonPathBugFixes.cs b/src/ArgonTests/BugFixes/JsonPathBugFixes.cs
new file mode 100644
index 00000000..23d13893
--- /dev/null
+++ b/src/ArgonTests/BugFixes/JsonPathBugFixes.cs
@@ -0,0 +1,73 @@
+// Copyright (c) 2007 James Newton-King. All rights reserved.
+// Use of this source code is governed by The MIT License,
+// as found in the license.md file.
+
+public class JsonPathBugFixes : TestFixtureBase
+{
+ // Bug: FieldMultipleFilter threw for ErrorWhenNoMatch unconditionally (missing else), so
+ // $['a','b'] errored even when the properties existed.
+
+ [Fact]
+ public void SelectTokens_multiple_existing_fields_does_not_error()
+ {
+ var o = JObject.Parse("""{"a":1,"b":2}""");
+
+ var values = o.SelectTokens("$['a','b']", errorWhenNoMatch: true)
+ .Select(_ => (int) _)
+ .ToList();
+
+ Assert.Equal(new[] {1, 2}, values);
+ }
+
+ [Fact]
+ public void SelectTokens_multiple_with_missing_field_still_errors()
+ {
+ var o = JObject.Parse("""{"a":1}""");
+
+ Assert.Throws(() =>
+ o.SelectTokens("$['a','missing']", errorWhenNoMatch: true).ToList());
+ }
+
+ // Bug: filter numeric literals were only terminated by space or ')', so && / || directly
+ // after a number failed to parse.
+
+ [Fact]
+ public void Filter_with_no_spaces_around_logical_and()
+ {
+ var a = JArray.Parse("""[{"a":1,"b":2},{"a":1,"b":9}]""");
+
+ var matches = a.SelectTokens("$[?(@.a==1&&@.b==2)]").ToList();
+
+ Assert.Single(matches);
+ Assert.Equal(2, (int) matches[0]["b"]);
+ }
+
+ [Fact]
+ public void Filter_with_no_spaces_around_logical_or()
+ {
+ var a = JArray.Parse("""[{"a":1},{"a":5},{"a":9}]""");
+
+ var matches = a.SelectTokens("$[?(@.a==1||@.a==5)]").ToList();
+
+ Assert.Equal(2, matches.Count);
+ }
+
+ // Bug: GetTokenIndex had no negative-index guard, so $[-1] crashed with
+ // ArgumentOutOfRangeException instead of returning null / throwing JsonException.
+
+ [Fact]
+ public void Negative_array_index_returns_null()
+ {
+ var a = new JArray(1, 2, 3);
+
+ Assert.Null(a.SelectToken("$[-1]"));
+ }
+
+ [Fact]
+ public void Negative_array_index_with_error_throws_jsonexception()
+ {
+ var a = new JArray(1, 2, 3);
+
+ Assert.Throws(() => a.SelectToken("$[-1]", errorWhenNoMatch: true));
+ }
+}
diff --git a/src/ArgonTests/BugFixes/LinqDomBugFixes.cs b/src/ArgonTests/BugFixes/LinqDomBugFixes.cs
new file mode 100644
index 00000000..6977c910
--- /dev/null
+++ b/src/ArgonTests/BugFixes/LinqDomBugFixes.cs
@@ -0,0 +1,99 @@
+// Copyright (c) 2007 James Newton-King. All rights reserved.
+// Use of this source code is governed by The MIT License,
+// as found in the license.md file.
+
+public class LinqDomBugFixes : TestFixtureBase
+{
+ // Bug: CompareBigInteger used Math.Abs on the fractional part, discarding its sign and
+ // inverting comparisons when the other operand had a negative fraction.
+
+ [Fact]
+ public void BigInteger_compares_correctly_against_negative_fraction()
+ {
+ // -5 > -5.5
+ Assert.True(new JValue(BigInteger.Parse("-5")).CompareTo(new JValue(-5.5)) > 0);
+ Assert.True(new JValue(-5.5).CompareTo(new JValue(BigInteger.Parse("-5"))) < 0);
+
+ // 5 < 5.5 (positive fraction still correct)
+ Assert.True(new JValue(BigInteger.Parse("5")).CompareTo(new JValue(5.5)) < 0);
+ Assert.True(new JValue(5.5).CompareTo(new JValue(BigInteger.Parse("5"))) > 0);
+
+ // equal integers
+ Assert.Equal(0, new JValue(BigInteger.Parse("5")).CompareTo(new JValue(5.0)));
+ }
+
+ // Bug: GetHashCode/GetDeepHashCode hashed the boxed CLR value, so numerically-equal
+ // JValues with different backing types produced different hashes, breaking hash-based
+ // lookups through JToken.EqualityComparer.
+
+ [Fact]
+ public void Equal_numeric_values_have_equal_hashcodes()
+ {
+ var intBacked = (JValue) JToken.FromObject(-1);
+ var longBacked = new JValue(-1L);
+ Assert.True(intBacked.Equals(longBacked));
+ Assert.Equal(intBacked.GetHashCode(), longBacked.GetHashCode());
+
+ var decimalBacked = new JValue(1.5m);
+ var doubleBacked = new JValue(1.5d);
+ Assert.True(decimalBacked.Equals(doubleBacked));
+ Assert.Equal(decimalBacked.GetHashCode(), doubleBacked.GetHashCode());
+ }
+
+ [Fact]
+ public void EqualityComparer_matches_across_numeric_representations()
+ {
+ var comparer = JToken.EqualityComparer;
+ var intBacked = JToken.FromObject(-1);
+ var longBacked = new JValue(-1L);
+
+ Assert.True(comparer.Equals(intBacked, longBacked));
+ Assert.Equal(comparer.GetHashCode(intBacked), comparer.GetHashCode(longBacked));
+ }
+
+ // Bug: TryAddInternal incremented the insert index even when the child insert was skipped
+ // (e.g. a comment token in a JObject), so the next insert ran past the end and threw.
+
+ [Fact]
+ public void Add_multicontent_with_comment_does_not_throw()
+ {
+ var o = new JObject();
+ object[] content =
+ [
+ new JProperty("p1", 1),
+ JValue.CreateComment("c"),
+ new JProperty("p2", 2)
+ ];
+ o.Add(content);
+
+ Assert.Equal(1, (int) o["p1"]);
+ Assert.Equal(2, (int) o["p2"]);
+ }
+
+ [Fact]
+ public void JObject_ctor_with_leading_comment_does_not_throw()
+ {
+ var o = new JObject(JValue.CreateComment("c"), new JProperty("a", 1));
+
+ Assert.Equal(1, (int) o["a"]);
+ }
+
+ // Bug: JObject's explicit IDictionary.Values threw NotImplementedException.
+
+ [Fact]
+ public void IDictionary_Values_returns_property_values()
+ {
+ IDictionary dictionary = new JObject
+ {
+ ["a"] = 1,
+ ["b"] = 2
+ };
+
+ var values = dictionary.Values
+ .Select(_ => (int) _)
+ .OrderBy(_ => _)
+ .ToList();
+
+ Assert.Equal(new[] {1, 2}, values);
+ }
+}
diff --git a/src/ArgonTests/BugFixes/ReaderBugFixes.cs b/src/ArgonTests/BugFixes/ReaderBugFixes.cs
new file mode 100644
index 00000000..a4072cfd
--- /dev/null
+++ b/src/ArgonTests/BugFixes/ReaderBugFixes.cs
@@ -0,0 +1,63 @@
+// Copyright (c) 2007 James Newton-King. All rights reserved.
+// Use of this source code is governed by The MIT License,
+// as found in the license.md file.
+
+using System.Reflection;
+
+public class ReaderBugFixes : TestFixtureBase
+{
+ [Fact]
+ public void Close_releases_buffer_and_is_idempotent()
+ {
+ var reader = new JsonTextReader(new StringReader("123"));
+ reader.Read();
+ reader.Close();
+
+ // After close the pooled buffer must be released so a second close cannot
+ // return the same array to ArrayPool.Shared a second time.
+ var field = typeof(JsonTextReader).GetField("charBuffer", BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.NotNull(field);
+ Assert.Empty((char[]) field.GetValue(reader));
+
+ // second close must be a no-op (no double-return, no throw)
+ reader.Close();
+ Assert.Empty((char[]) field.GetValue(reader));
+ }
+
+ [Fact]
+ public void Dispose_after_explicit_close_does_not_double_return()
+ {
+ var reader = new JsonTextReader(new StringReader("123"));
+ reader.Read();
+ reader.Close();
+
+ // Disposing after an explicit Close must not return the buffer again.
+ ((IDisposable) reader).Dispose();
+
+ var field = typeof(JsonTextReader).GetField("charBuffer", BindingFlags.NonPublic | BindingFlags.Instance);
+ Assert.Empty((char[]) field.GetValue(reader));
+ }
+
+ [Fact]
+ public void ReadAsInt32_number_error_sets_Undefined_token()
+ {
+ var reader = new JsonTextReader(new StringReader("[123456789012345]"));
+ reader.Read(); // StartArray
+
+ Assert.Throws(() => reader.ReadAsInt32());
+
+ // The error-recovery token must be Undefined, distinguishable from a real JSON null.
+ Assert.Equal(JsonToken.Undefined, reader.TokenType);
+ }
+
+ [Fact]
+ public void ReadAsInt32_invalid_integer_sets_Undefined_token()
+ {
+ var reader = new JsonTextReader(new StringReader("[1.5]"));
+ reader.Read(); // StartArray
+
+ Assert.Throws(() => reader.ReadAsInt32());
+
+ Assert.Equal(JsonToken.Undefined, reader.TokenType);
+ }
+}
diff --git a/src/ArgonTests/BugFixes/SerializerEngineBugFixes.cs b/src/ArgonTests/BugFixes/SerializerEngineBugFixes.cs
new file mode 100644
index 00000000..3ed8cb46
--- /dev/null
+++ b/src/ArgonTests/BugFixes/SerializerEngineBugFixes.cs
@@ -0,0 +1,155 @@
+// Copyright (c) 2007 James Newton-King. All rights reserved.
+// Use of this source code is governed by The MIT License,
+// as found in the license.md file.
+
+public class SerializerEngineBugFixes : TestFixtureBase
+{
+ // Bug: JsonConverter.ReadJson returned null for any token whose Value is null,
+ // including StartObject/StartArray, without consuming the token -> reader desync.
+
+ [Fact]
+ public void GenericConverter_reads_object_token_and_keeps_reader_aligned()
+ {
+ var holder = JsonConvert.DeserializeObject("""{"Point":{"X":1,"Y":2},"After":42}""");
+
+ Assert.NotNull(holder.Point);
+ Assert.Equal(1, holder.Point.X);
+ Assert.Equal(2, holder.Point.Y);
+ Assert.Equal(42, holder.After);
+ }
+
+ [Fact]
+ public void GenericConverter_still_returns_null_for_json_null()
+ {
+ var holder = JsonConvert.DeserializeObject("""{"Point":null,"After":7}""");
+
+ Assert.Null(holder.Point);
+ Assert.Equal(7, holder.After);
+ }
+
+ public class Holder
+ {
+ [JsonConverter(typeof(PointConverter))]
+ public Point Point { get; set; }
+
+ public int After { get; set; }
+ }
+
+ public class Point
+ {
+ public int X { get; set; }
+ public int Y { get; set; }
+ }
+
+ class PointConverter : JsonConverter
+ {
+ public override void WriteJson(JsonWriter writer, Point value, JsonSerializer serializer)
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("X");
+ writer.WriteValue(value.X);
+ writer.WritePropertyName("Y");
+ writer.WriteValue(value.Y);
+ writer.WriteEndObject();
+ }
+
+ public override Point ReadJson(JsonReader reader, Type type, Point existingValue, bool hasExisting, JsonSerializer serializer)
+ {
+ var o = JObject.Load(reader);
+ return new()
+ {
+ X = (int) o["X"],
+ Y = (int) o["Y"]
+ };
+ }
+ }
+
+ // Bug: OrderByKey packed StringComparer into the sort-key tuple, so ordering fell
+ // back to the culture-sensitive default comparer. The fix uses OrdinalIgnoreCase,
+ // making ordering deterministic and culture-independent.
+
+ [Fact]
+ public void OrderByKey_sorts_ordinal_ignore_case()
+ {
+ var target = new Dictionary
+ {
+ {"_x", 5},
+ {"ab", 4},
+ {"@", 2},
+ {"a-c", 3},
+ {"#", 1}
+ };
+
+ var settings = new JsonSerializerSettings
+ {
+ ContractResolver = new OrderByKeyContractResolver()
+ };
+
+ var json = JsonConvert.SerializeObject(target, settings);
+
+ Assert.Equal("""{"#":1,"@":2,"a-c":3,"ab":4,"_x":5}""", json);
+ }
+
+ class OrderByKeyContractResolver : DefaultContractResolver
+ {
+ protected override JsonDictionaryContract CreateDictionaryContract(Type type)
+ {
+ var contract = base.CreateDictionaryContract(type);
+ contract.OrderByKey = true;
+ return contract;
+ }
+ }
+
+ // Bug: JsonSerializerProxy (handed to converters) did not proxy ResolveContract
+ // or Converters, so converters saw the default resolver and an empty converter list.
+
+ [Fact]
+ public void Converter_sees_registered_converters_and_configured_resolver()
+ {
+ CaptureConverter.Reset();
+
+ var settings = new JsonSerializerSettings
+ {
+ ContractResolver = new DefaultContractResolver
+ {
+ NamingStrategy = new CamelCaseNamingStrategy()
+ }
+ };
+ settings.Converters.Add(new CaptureConverter());
+
+ JsonConvert.SerializeObject(new CaptureHolder(), settings);
+
+ Assert.True(CaptureConverter.ConverterCount >= 1);
+ Assert.Equal("someValue", CaptureConverter.ResolvedPropertyName);
+ }
+
+ public class CaptureHolder;
+
+ public class Inner
+ {
+ public int SomeValue { get; set; }
+ }
+
+ class CaptureConverter : JsonConverter
+ {
+ public static int ConverterCount;
+ public static string ResolvedPropertyName;
+
+ public static void Reset()
+ {
+ ConverterCount = 0;
+ ResolvedPropertyName = null;
+ }
+
+ public override void WriteJson(JsonWriter writer, CaptureHolder value, JsonSerializer serializer)
+ {
+ ConverterCount = serializer.Converters.Count;
+ var contract = (JsonObjectContract) serializer.ResolveContract(typeof(Inner));
+ ResolvedPropertyName = contract.Properties[0].PropertyName;
+ writer.WriteNull();
+ }
+
+ public override CaptureHolder ReadJson(JsonReader reader, Type type, CaptureHolder existingValue, bool hasExisting, JsonSerializer serializer) =>
+ null;
+ }
+}
diff --git a/src/ArgonTests/BugFixes/UtilitiesBugFixes.cs b/src/ArgonTests/BugFixes/UtilitiesBugFixes.cs
new file mode 100644
index 00000000..31f9ab21
--- /dev/null
+++ b/src/ArgonTests/BugFixes/UtilitiesBugFixes.cs
@@ -0,0 +1,85 @@
+// Copyright (c) 2007 James Newton-King. All rights reserved.
+// Use of this source code is governed by The MIT License,
+// as found in the license.md file.
+
+public class UtilitiesBugFixes : TestFixtureBase
+{
+ // Bug: ReflectionUtils.GetDefaultValue(typeof(char)) returned a boxed int 0 instead of
+ // '\0', so DefaultValueHandling.Ignore never omitted a default char member.
+
+ [Fact]
+ public void DefaultValueHandling_Ignore_omits_default_char()
+ {
+ var json = JsonConvert.SerializeObject(
+ new HasChar(),
+ new JsonSerializerSettings {DefaultValueHandling = DefaultValueHandling.Ignore});
+
+ Assert.Equal("{}", json);
+ }
+
+ [Fact]
+ public void DefaultValueHandling_Ignore_keeps_non_default_char()
+ {
+ var json = JsonConvert.SerializeObject(
+ new HasChar {C = 'a'},
+ new JsonSerializerSettings {DefaultValueHandling = DefaultValueHandling.Ignore});
+
+ Assert.Equal("""{"C":"a"}""", json);
+ }
+
+ public class HasChar
+ {
+ public char C { get; set; }
+ }
+
+ // Bug: BoxedPrimitives.Get(decimal) returned a cached positive-zero box for -0.0m, so
+ // the negative-zero sign was dropped at scale 0/1 but kept at higher scales.
+
+ [Fact]
+ public void Negative_zero_decimal_preserves_sign_across_scales()
+ {
+ Assert.True(HasNegativeSignBit(ReadDecimal("-0.0")));
+ Assert.True(HasNegativeSignBit(ReadDecimal("-0.00")));
+ Assert.False(HasNegativeSignBit(ReadDecimal("0.0")));
+
+ // ToString is unaffected (decimal never renders a negative-zero sign).
+ Assert.Equal("0.0", ReadDecimal("-0.0").ToString(InvariantCulture));
+ }
+
+ static bool HasNegativeSignBit(decimal value) =>
+ (decimal.GetBits(value)[3] & int.MinValue) != 0;
+
+ static decimal ReadDecimal(string json)
+ {
+ var reader = new JsonTextReader(new StringReader(json))
+ {
+ FloatParseHandling = FloatParseHandling.Decimal
+ };
+ while (reader.Read())
+ {
+ if (reader.TokenType == JsonToken.Float)
+ {
+ return (decimal) reader.Value;
+ }
+ }
+
+ throw new InvalidOperationException("no float token");
+ }
+
+ // Bug: the ISO date parser accepts zone offsets up to +/-99:99, then the DateTimeOffset
+ // constructor threw instead of the parse returning false.
+
+ [Fact]
+ public void TryParseDateTimeOffsetIso_rejects_out_of_range_offset()
+ {
+ Assert.False(DateTimeUtils.TryParseDateTimeOffsetIso(Ref("2000-01-01T00:00:00+15:00"), out _));
+ Assert.False(DateTimeUtils.TryParseDateTimeOffsetIso(Ref("2000-01-01T00:00:00-15:00"), out _));
+ Assert.False(DateTimeUtils.TryParseDateTimeOffsetIso(Ref("2000-01-01T00:00:00+13:99"), out _));
+
+ // +/-14:00 is the maximum offset DateTimeOffset allows.
+ Assert.True(DateTimeUtils.TryParseDateTimeOffsetIso(Ref("2000-01-01T00:00:00+14:00"), out _));
+ }
+
+ static StringReference Ref(string s) =>
+ new(s.ToCharArray(), 0, s.Length);
+}
diff --git a/src/ArgonTests/BugFixes/WriterBugFixes.cs b/src/ArgonTests/BugFixes/WriterBugFixes.cs
new file mode 100644
index 00000000..b8d2a125
--- /dev/null
+++ b/src/ArgonTests/BugFixes/WriterBugFixes.cs
@@ -0,0 +1,103 @@
+// Copyright (c) 2007 James Newton-King. All rights reserved.
+// Use of this source code is governed by The MIT License,
+// as found in the license.md file.
+
+using System.Text;
+
+public class WriterBugFixes : TestFixtureBase
+{
+ [Fact]
+ public void ToString_TimeSpan_does_not_pad_with_nulls()
+ {
+ Assert.Equal("\"01:00:00\"", JsonConvert.ToString(TimeSpan.FromHours(1)));
+ Assert.Equal("\"1.02:03:04\"", JsonConvert.ToString(new TimeSpan(1, 2, 3, 4)));
+ }
+
+ [Fact]
+ public void ToString_object_TimeSpan_does_not_pad_with_nulls() =>
+ Assert.Equal("\"01:00:00\"", JsonConvert.ToString((object) TimeSpan.FromHours(1)));
+
+ [Fact]
+ public void WriteValue_StringBuilder_multichunk_writes_single_string_in_array()
+ {
+ var builder = new StringBuilder();
+ builder.Append('a', 10);
+ builder.Append('b', 9000);
+
+ var stringWriter = new StringWriter();
+ using (var writer = new JsonTextWriter(stringWriter))
+ {
+ writer.WriteStartArray();
+ writer.WriteValue(builder);
+ writer.WriteEndArray();
+ }
+
+ Assert.Equal($"[\"{builder}\"]", stringWriter.ToString());
+ }
+
+ [Fact]
+ public void WriteValue_StringBuilder_multichunk_writes_single_string_as_property()
+ {
+ var builder = new StringBuilder();
+ builder.Append('x', 10000);
+
+ var stringWriter = new StringWriter();
+ using (var writer = new JsonTextWriter(stringWriter))
+ {
+ writer.WriteStartObject();
+ writer.WritePropertyName("p");
+ writer.WriteValue(builder);
+ writer.WriteEndObject();
+ }
+
+ Assert.Equal($"{{\"p\":\"{builder}\"}}", stringWriter.ToString());
+ }
+
+ [Fact]
+ public void WriteValue_StringBuilder_null_writes_null()
+ {
+ var stringWriter = new StringWriter();
+ using (var writer = new JsonTextWriter(stringWriter))
+ {
+ writer.WriteValue((StringBuilder) null);
+ }
+
+ Assert.Equal("null", stringWriter.ToString());
+ }
+
+ [Fact]
+ public void WriteValue_char_honors_QuoteChar()
+ {
+ var stringWriter = new StringWriter();
+ using (var writer = new JsonTextWriter(stringWriter) {QuoteChar = '\''})
+ {
+ writer.WriteValue('c');
+ }
+
+ Assert.Equal("'c'", stringWriter.ToString());
+ }
+
+ [Fact]
+ public void WriteValue_char_honors_QuoteValue()
+ {
+ var stringWriter = new StringWriter();
+ using (var writer = new JsonTextWriter(stringWriter) {QuoteValue = false})
+ {
+ writer.WriteValue('c');
+ }
+
+ Assert.Equal("c", stringWriter.ToString());
+ }
+
+ [Fact]
+ public void WriteValue_char_default_is_double_quoted()
+ {
+ var stringWriter = new StringWriter();
+ using (var writer = new JsonTextWriter(stringWriter))
+ {
+ writer.WriteValue('c');
+ }
+
+ Assert.Equal("\"c\"", stringWriter.ToString());
+ }
+}
diff --git a/src/ArgonTests/BugFixes/XmlBugFixes.cs b/src/ArgonTests/BugFixes/XmlBugFixes.cs
new file mode 100644
index 00000000..a5b0de6a
--- /dev/null
+++ b/src/ArgonTests/BugFixes/XmlBugFixes.cs
@@ -0,0 +1,83 @@
+// Copyright (c) 2007 James Newton-King. All rights reserved.
+// Use of this source code is governed by The MIT License,
+// as found in the license.md file.
+
+using System.Xml;
+
+public class XmlBugFixes : TestFixtureBase
+{
+ // Bug: a single-element nested array lost its json:Array marker when the property name was
+ // prefixed or XML-encoded, because the raw JSON name was compared against the decoded
+ // element LocalName. Result: the inner array nesting was dropped on round-trip.
+
+ [Fact]
+ public void Nested_single_element_array_with_prefix_round_trips()
+ {
+ var json = """{"root":{"@xmlns:ns":"http://x","ns:items":[["a"]]}}""";
+
+ var doc = JsonXmlConvert.DeserializeXmlNode(json, null, true);
+ var roundTripped = JObject.Parse(JsonXmlConvert.SerializeXmlNode(doc));
+
+ var items = roundTripped["root"]["ns:items"];
+ Assert.Equal(JTokenType.Array, items.Type);
+ Assert.Equal(JTokenType.Array, items[0].Type);
+ Assert.Equal("a", (string) items[0][0]);
+ }
+
+ // Bug: two or more sibling comments serialized to "#comment": [] - all comment text dropped
+ // plus a bogus property that cannot round-trip.
+
+ [Fact]
+ public void Multiple_sibling_comments_round_trip()
+ {
+ var doc = new XmlDocument();
+ doc.LoadXml("");
+
+ var json = JsonXmlConvert.SerializeXmlNode(doc);
+ Assert.DoesNotContain("#comment", json);
+
+ var roundTripped = JsonXmlConvert.DeserializeXmlNode(json);
+ var comments = roundTripped.DocumentElement
+ .ChildNodes
+ .Cast()
+ .Where(_ => _.NodeType == XmlNodeType.Comment)
+ .Select(_ => _.Value)
+ .ToList();
+
+ Assert.Equal(new[] {"a", "b"}, comments);
+ }
+
+ // Bug: XmlDocumentWrapper.CreateXmlDocumentType discarded the internalSubset argument, so
+ // JSON -> XmlDocument silently lost DTD internal subsets.
+
+ [Fact]
+ public void DocType_internal_subset_is_preserved()
+ {
+ var json = """{"!DOCTYPE":{"@name":"root","@internalSubset":""},"root":"x"}""";
+
+ var doc = JsonXmlConvert.DeserializeXmlNode(json);
+
+ Assert.NotNull(doc.DocumentType);
+ Assert.Contains("ENTITY foo", doc.DocumentType.InternalSubset);
+ }
+
+ // Bug: #cdata-section / #text with a JSON null crashed with ArgumentNullException for
+ // XDocument targets (while XmlDocument produced an empty node).
+
+ [Fact]
+ public void CData_null_does_not_crash_xdocument()
+ {
+ var xdoc = JsonXmlConvert.DeserializeXNode("""{"root":{"#cdata-section":null}}""");
+
+ Assert.NotNull(xdoc);
+ Assert.Contains("CDATA", xdoc.ToString());
+ }
+
+ [Fact]
+ public void Text_null_does_not_crash_xdocument()
+ {
+ var xdoc = JsonXmlConvert.DeserializeXNode("""{"root":{"#text":null}}""");
+
+ Assert.NotNull(xdoc);
+ }
+}