diff --git a/src/GameCult.Caching.MessagePack/CultMathJson.cs b/src/GameCult.Caching.MessagePack/CultMathJson.cs index def7ab9a..438c6150 100644 --- a/src/GameCult.Caching.MessagePack/CultMathJson.cs +++ b/src/GameCult.Caching.MessagePack/CultMathJson.cs @@ -1,6 +1,8 @@ using System; using System.Collections.Generic; using System.Globalization; +using System.Reflection; +using System.Text; using System.Text.Json; using System.Text.Json.Serialization; using CultMath; @@ -16,22 +18,30 @@ namespace GameCult.Caching.MessagePack; /// Property names are fixed: PropertyNamingPolicy does not rename them, and /// PropertyNameCaseInsensitive makes reading case-insensitive. /// -/// Reading skips unknown properties and leaves missing ones at zero (a missing Color32.a -/// is 255). A wrong token, a number outside its component type (including a float beyond -/// , which is refused rather than becoming infinity), or a matrix -/// that is not an array of exactly its row count throws . +/// Reading skips unknown properties (UnmappedMemberHandling.Disallow refuses them) and +/// leaves missing ones at zero (a missing Color32.a is 255). A repeated property replaces +/// the whole earlier value, never merging component-wise, and is refused when +/// AllowDuplicateProperties is false. A wrong token, a number outside its component type, +/// or a matrix that is not an array of exactly its row count throws +/// . /// /// /// Numbers follow as System.Text.Json's own -/// float does: NaN and the infinities need AllowNamedFloatingPointLiterals (written and -/// read as "NaN", "Infinity", "-Infinity") and are otherwise refused with -/// ; AllowReadingFromString reads quoted numbers; -/// WriteAsString writes quoted numbers. +/// float does. NaN and the infinities are the strings "NaN", "Infinity", +/// "-Infinity": written when AllowNamedFloatingPointLiterals or +/// WriteAsString is set (otherwise ), read when +/// AllowNamedFloatingPointLiterals or AllowReadingFromString is set. +/// AllowReadingFromString reads quoted numbers without surrounding whitespace, and +/// WriteAsString writes quoted numbers. Two intended divergences: a bare number beyond +/// the component's range (1e40 as a float) is refused where System.Text.Json reads +/// infinity, and refusals on write are where it throws +/// . /// /// /// Every type is also a dictionary key: the flattened components joined by commas in /// declaration order, invariant culture, shortest round-trip floats ("1,2", /// "true,false", matrices row by row, rect as "minX,minY,maxX,maxY"). +/// Only that exact spelling reads back, so two JSON keys never collapse into one dictionary key. /// /// Register with ; JSON options are the consumer's own. /// @@ -48,150 +58,141 @@ public static class CultMathJson return options; } - private static bool Named(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowNamedFloatingPointLiterals) != 0; + private static bool LiteralsWrite(JsonSerializerOptions o) => + (o.NumberHandling & (JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.WriteAsString)) != 0; + + private static bool LiteralsRead(JsonSerializerOptions o) => + (o.NumberHandling & (JsonNumberHandling.AllowNamedFloatingPointLiterals | JsonNumberHandling.AllowReadingFromString)) != 0; + private static bool FromString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.AllowReadingFromString) != 0; private static bool AsString(JsonSerializerOptions o) => (o.NumberHandling & JsonNumberHandling.WriteAsString) != 0; - private static string Invalid(string what, string got) => $"{what} cannot be read from '{got}'."; + // AllowDuplicateProperties arrived after the System.Text.Json this library compiles against. + private static readonly PropertyInfo? AllowDuplicateProperties = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties"); + + private static bool DuplicatesAllowed(JsonSerializerOptions o) => AllowDuplicateProperties == null || (bool)AllowDuplicateProperties.GetValue(o)!; + + private const NumberStyles FloatStyle = NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint | NumberStyles.AllowExponent; // One component kind. Every CultMath type is a flat list of one kind, so a Scalar plus a Node tree describes it. - private abstract class Scalar + private abstract class Scalar where S : unmanaged { - public virtual S[] Defaults(int count) => new S[count]; public abstract void Write(Utf8JsonWriter w, S value, JsonSerializerOptions o); public abstract S Read(ref Utf8JsonReader r, JsonSerializerOptions o); - public abstract string Text(S value, JsonSerializerOptions o); + + /// The canonical key spelling, whatever the options allow. + public abstract string Spelling(S value); + + /// The key spelling, refusing what the options would refuse to write. + public virtual string Text(S value, JsonSerializerOptions o) => Spelling(value); + public abstract S Parse(string text, JsonSerializerOptions o); + + /// Reads a key component; keys are always text, so AllowReadingFromString does not apply. + public virtual S ParseKey(string text, JsonSerializerOptions o) => Parse(text, o); } - private sealed class FloatScalar : Scalar + private abstract class FloatingScalar : Scalar where S : unmanaged + { + protected static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity"; + protected abstract bool IsFinite(S value); + protected abstract string Literal(S value); + protected abstract S FromLiteral(string literal); + protected abstract bool TryNumber(string text, out S value); + protected abstract bool TryToken(ref Utf8JsonReader r, out S value); + protected abstract void WriteNumber(Utf8JsonWriter w, S value); + protected abstract string Number(S value); + + public override void Write(Utf8JsonWriter w, S v, JsonSerializerOptions o) + { + if (!IsFinite(v)) + { + if (!LiteralsWrite(o)) throw new JsonException($"A non-finite {typeof(S).Name} needs JsonNumberHandling.AllowNamedFloatingPointLiterals or WriteAsString."); + w.WriteStringValue(Literal(v)); + } + else if (AsString(o)) w.WriteStringValue(Number(v)); + else WriteNumber(w, v); + } + + public override S Read(ref Utf8JsonReader r, JsonSerializerOptions o) + { + if (r.TokenType == JsonTokenType.Number) + { + if (!TryToken(ref r, out var v) || !IsFinite(v)) throw new JsonException($"The number is outside the {typeof(S).Name} range."); + return v; + } + + if (r.TokenType == JsonTokenType.String) return Parse(r.GetString()!, o); + throw new JsonException($"Expected a number, found {r.TokenType}."); + } + + public override string Spelling(S v) => Number(v); + + public override string Text(S v, JsonSerializerOptions o) + { + if (!IsFinite(v) && !LiteralsWrite(o)) throw new JsonException($"A non-finite {typeof(S).Name} needs JsonNumberHandling.AllowNamedFloatingPointLiterals or WriteAsString."); + return Spelling(v); + } + + public override S Parse(string text, JsonSerializerOptions o) + { + if (!IsLiteral(text) && !FromString(o)) throw new JsonException($"Expected a number, found the string '{text}'."); + return ParseKey(text, o); + } + + public override S ParseKey(string text, JsonSerializerOptions o) + { + if (IsLiteral(text)) + { + if (!LiteralsRead(o)) throw new JsonException($"'{text}' needs JsonNumberHandling.AllowNamedFloatingPointLiterals or AllowReadingFromString."); + return FromLiteral(text); + } + + if (TryNumber(text, out var v) && IsFinite(v)) return v; + throw new JsonException($"A {typeof(S).Name} cannot be read from '{text}'."); + } + } + + private sealed class FloatScalar : FloatingScalar { public static readonly FloatScalar Instance = new(); - - private static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity"; - - private static string Literal(float v) => float.IsNaN(v) ? "NaN" : v > 0f ? "Infinity" : "-Infinity"; - - public override void Write(Utf8JsonWriter w, float v, JsonSerializerOptions o) - { - if (!float.IsFinite(v)) - { - if (!Named(o)) throw new JsonException("A non-finite float needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); - w.WriteStringValue(Literal(v)); - } - else if (AsString(o)) w.WriteStringValue(v.ToString("R", CultureInfo.InvariantCulture)); - else w.WriteNumberValue(v); - } - - public override float Read(ref Utf8JsonReader r, JsonSerializerOptions o) - { - if (r.TokenType == JsonTokenType.Number) - { - if (!r.TryGetSingle(out var v) || float.IsInfinity(v)) throw new JsonException("The number is outside the float range."); - return v; - } - - if (r.TokenType == JsonTokenType.String) - { - var text = r.GetString()!; - if (!FromString(o) && !(Named(o) && IsLiteral(text))) throw new JsonException($"Expected a number, found the string '{text}'."); - return Parse(text, o); - } - - throw new JsonException($"Expected a number, found {r.TokenType}."); - } - - public override string Text(float v, JsonSerializerOptions o) - { - if (!float.IsFinite(v) && !Named(o)) throw new JsonException("A non-finite float needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); - return v.ToString("R", CultureInfo.InvariantCulture); - } - - public override float Parse(string text, JsonSerializerOptions o) - { - if (Named(o)) - { - if (text == "NaN") return float.NaN; - if (text == "Infinity") return float.PositiveInfinity; - if (text == "-Infinity") return float.NegativeInfinity; - } - - if (float.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) && float.IsFinite(v)) return v; - throw new JsonException(Invalid("A float", text)); - } + protected override bool IsFinite(float v) => float.IsFinite(v); + protected override string Literal(float v) => float.IsNaN(v) ? "NaN" : v > 0f ? "Infinity" : "-Infinity"; + protected override float FromLiteral(string s) => s == "NaN" ? float.NaN : s == "Infinity" ? float.PositiveInfinity : float.NegativeInfinity; + protected override bool TryNumber(string s, out float v) => float.TryParse(s, FloatStyle, CultureInfo.InvariantCulture, out v); + protected override bool TryToken(ref Utf8JsonReader r, out float v) => r.TryGetSingle(out v); + protected override void WriteNumber(Utf8JsonWriter w, float v) => w.WriteNumberValue(v); + protected override string Number(float v) => v.ToString("R", CultureInfo.InvariantCulture); } - private sealed class DoubleScalar : Scalar + private sealed class DoubleScalar : FloatingScalar { public static readonly DoubleScalar Instance = new(); - - private static bool IsLiteral(string s) => s == "NaN" || s == "Infinity" || s == "-Infinity"; - - private static string Literal(double v) => double.IsNaN(v) ? "NaN" : v > 0d ? "Infinity" : "-Infinity"; - - public override void Write(Utf8JsonWriter w, double v, JsonSerializerOptions o) - { - if (!double.IsFinite(v)) - { - if (!Named(o)) throw new JsonException("A non-finite double needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); - w.WriteStringValue(Literal(v)); - } - else if (AsString(o)) w.WriteStringValue(v.ToString("R", CultureInfo.InvariantCulture)); - else w.WriteNumberValue(v); - } - - public override double Read(ref Utf8JsonReader r, JsonSerializerOptions o) - { - if (r.TokenType == JsonTokenType.Number) - { - if (!r.TryGetDouble(out var v) || double.IsInfinity(v)) throw new JsonException("The number is outside the double range."); - return v; - } - - if (r.TokenType == JsonTokenType.String) - { - var text = r.GetString()!; - if (!FromString(o) && !(Named(o) && IsLiteral(text))) throw new JsonException($"Expected a number, found the string '{text}'."); - return Parse(text, o); - } - - throw new JsonException($"Expected a number, found {r.TokenType}."); - } - - public override string Text(double v, JsonSerializerOptions o) - { - if (!double.IsFinite(v) && !Named(o)) throw new JsonException("A non-finite double needs JsonNumberHandling.AllowNamedFloatingPointLiterals."); - return v.ToString("R", CultureInfo.InvariantCulture); - } - - public override double Parse(string text, JsonSerializerOptions o) - { - if (Named(o)) - { - if (text == "NaN") return double.NaN; - if (text == "Infinity") return double.PositiveInfinity; - if (text == "-Infinity") return double.NegativeInfinity; - } - - if (double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out var v) && double.IsFinite(v)) return v; - throw new JsonException(Invalid("A double", text)); - } + protected override bool IsFinite(double v) => double.IsFinite(v); + protected override string Literal(double v) => double.IsNaN(v) ? "NaN" : v > 0d ? "Infinity" : "-Infinity"; + protected override double FromLiteral(string s) => s == "NaN" ? double.NaN : s == "Infinity" ? double.PositiveInfinity : double.NegativeInfinity; + protected override bool TryNumber(string s, out double v) => double.TryParse(s, FloatStyle, CultureInfo.InvariantCulture, out v); + protected override bool TryToken(ref Utf8JsonReader r, out double v) => r.TryGetDouble(out v); + protected override void WriteNumber(Utf8JsonWriter w, double v) => w.WriteNumberValue(v); + protected override string Number(double v) => v.ToString("R", CultureInfo.InvariantCulture); } - private sealed class IntScalar : Scalar + private abstract class IntegerScalar : Scalar where S : unmanaged { - public static readonly IntScalar Instance = new(); + protected abstract bool TryToken(ref Utf8JsonReader r, out S value); + protected abstract bool TryText(string text, out S value); + protected abstract void WriteNumber(Utf8JsonWriter w, S value); - public override void Write(Utf8JsonWriter w, int v, JsonSerializerOptions o) + public override void Write(Utf8JsonWriter w, S v, JsonSerializerOptions o) { - if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v); + if (AsString(o)) w.WriteStringValue(Spelling(v)); else WriteNumber(w, v); } - public override int Read(ref Utf8JsonReader r, JsonSerializerOptions o) + public override S Read(ref Utf8JsonReader r, JsonSerializerOptions o) { if (r.TokenType == JsonTokenType.Number) { - if (!r.TryGetInt32(out var v)) throw new JsonException("The number is not an int."); + if (!TryToken(ref r, out var v)) throw new JsonException($"The number is not a {typeof(S).Name}."); return v; } @@ -199,71 +200,35 @@ public static class CultMathJson throw new JsonException($"Expected an integer, found {r.TokenType}."); } - public override string Text(int v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture); - - public override int Parse(string text, JsonSerializerOptions o) => - int.TryParse(text, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("An int", text)); + public override S Parse(string text, JsonSerializerOptions o) => + TryText(text, out var v) ? v : throw new JsonException($"A {typeof(S).Name} cannot be read from '{text}'."); } - private sealed class UIntScalar : Scalar + private sealed class IntScalar : IntegerScalar + { + public static readonly IntScalar Instance = new(); + protected override bool TryToken(ref Utf8JsonReader r, out int v) => r.TryGetInt32(out v); + protected override bool TryText(string s, out int v) => int.TryParse(s, NumberStyles.AllowLeadingSign, CultureInfo.InvariantCulture, out v); + protected override void WriteNumber(Utf8JsonWriter w, int v) => w.WriteNumberValue(v); + public override string Spelling(int v) => v.ToString(CultureInfo.InvariantCulture); + } + + private sealed class UIntScalar : IntegerScalar { public static readonly UIntScalar Instance = new(); - - public override void Write(Utf8JsonWriter w, uint v, JsonSerializerOptions o) - { - if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v); - } - - public override uint Read(ref Utf8JsonReader r, JsonSerializerOptions o) - { - if (r.TokenType == JsonTokenType.Number) - { - if (!r.TryGetUInt32(out var v)) throw new JsonException("The number is not a uint."); - return v; - } - - if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o); - throw new JsonException($"Expected an unsigned integer, found {r.TokenType}."); - } - - public override string Text(uint v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture); - - public override uint Parse(string text, JsonSerializerOptions o) => - uint.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("A uint", text)); + protected override bool TryToken(ref Utf8JsonReader r, out uint v) => r.TryGetUInt32(out v); + protected override bool TryText(string s, out uint v) => uint.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out v); + protected override void WriteNumber(Utf8JsonWriter w, uint v) => w.WriteNumberValue(v); + public override string Spelling(uint v) => v.ToString(CultureInfo.InvariantCulture); } - private sealed class ByteScalar : Scalar + private sealed class ByteScalar : IntegerScalar { public static readonly ByteScalar Instance = new(); - - public override byte[] Defaults(int count) - { - var defaults = new byte[count]; - if (count == 4) defaults[3] = 255; // Color32 alpha - return defaults; - } - - public override void Write(Utf8JsonWriter w, byte v, JsonSerializerOptions o) - { - if (AsString(o)) w.WriteStringValue(v.ToString(CultureInfo.InvariantCulture)); else w.WriteNumberValue(v); - } - - public override byte Read(ref Utf8JsonReader r, JsonSerializerOptions o) - { - if (r.TokenType == JsonTokenType.Number) - { - if (!r.TryGetByte(out var v)) throw new JsonException("The number is not a byte."); - return v; - } - - if (r.TokenType == JsonTokenType.String && FromString(o)) return Parse(r.GetString()!, o); - throw new JsonException($"Expected a byte, found {r.TokenType}."); - } - - public override string Text(byte v, JsonSerializerOptions o) => v.ToString(CultureInfo.InvariantCulture); - - public override byte Parse(string text, JsonSerializerOptions o) => - byte.TryParse(text, NumberStyles.None, CultureInfo.InvariantCulture, out var v) ? v : throw new JsonException(Invalid("A byte", text)); + protected override bool TryToken(ref Utf8JsonReader r, out byte v) => r.TryGetByte(out v); + protected override bool TryText(string s, out byte v) => byte.TryParse(s, NumberStyles.None, CultureInfo.InvariantCulture, out v); + protected override void WriteNumber(Utf8JsonWriter w, byte v) => w.WriteNumberValue(v); + public override string Spelling(byte v) => v.ToString(CultureInfo.InvariantCulture); } private sealed class BoolScalar : Scalar @@ -277,76 +242,115 @@ public static class CultMathJson : r.TokenType == JsonTokenType.False ? false : throw new JsonException($"Expected true or false, found {r.TokenType}."); - public override string Text(bool v, JsonSerializerOptions o) => v ? "true" : "false"; + public override string Spelling(bool v) => v ? "true" : "false"; public override bool Parse(string text, JsonSerializerOptions o) => - text == "true" ? true : text == "false" ? false : throw new JsonException(Invalid("A bool", text)); + text == "true" ? true : text == "false" ? false : throw new JsonException($"A bool cannot be read from '{text}'."); } // The JSON structure over a flat component list: leaves consume one component each, in order. - private abstract class Node + // Read fills `flat` (pre-loaded with `defaults`), so a missing property keeps its default. + private abstract class Node where S : unmanaged { public int Count { get; protected set; } - public abstract void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o); - public abstract void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o); + public abstract void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o); + public abstract void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o); } - private sealed class Leaf : Node + private sealed class Leaf : Node where S : unmanaged { public Leaf() { Count = 1; } - public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) => scalar.Write(w, flat[offset], o); + public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o) => scalar.Write(w, flat[0], o); - public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) => flat[offset] = scalar.Read(ref r, o); + public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o) => flat[0] = scalar.Read(ref r, o); } - private sealed class Fields : Node + private sealed class Fields : Node where S : unmanaged { private readonly string[] names; + private readonly byte[][] utf8Names; + private readonly JsonEncodedText[] encoded; private readonly Node[] children; private readonly int[] offsets; public Fields(params (string Name, Node Child)[] fields) { names = new string[fields.Length]; + utf8Names = new byte[fields.Length][]; + encoded = new JsonEncodedText[fields.Length]; children = new Node[fields.Length]; offsets = new int[fields.Length]; for (var i = 0; i < fields.Length; i++) { names[i] = fields[i].Name; + utf8Names[i] = Encoding.UTF8.GetBytes(fields[i].Name); + encoded[i] = JsonEncodedText.Encode(fields[i].Name); children[i] = fields[i].Child; offsets[i] = Count; Count += fields[i].Child.Count; } } - public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o) { w.WriteStartObject(); - for (var i = 0; i < names.Length; i++) + for (var i = 0; i < children.Length; i++) { - w.WritePropertyName(names[i]); - children[i].Write(w, flat, offset + offsets[i], scalar, o); + w.WritePropertyName(encoded[i]); + children[i].Write(w, flat.Slice(offsets[i], children[i].Count), scalar, o); } w.WriteEndObject(); } - public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o) { if (r.TokenType != JsonTokenType.StartObject) throw new JsonException($"Expected a JSON object, found {r.TokenType}."); - var comparison = o.PropertyNameCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal; + var ignoreCase = o.PropertyNameCaseInsensitive; + var seen = 0; while (r.Read() && r.TokenType == JsonTokenType.PropertyName) { - var name = r.GetString()!; - var index = Array.FindIndex(names, n => string.Equals(n, name, comparison)); + var index = IndexOf(ref r, ignoreCase); r.Read(); - if (index < 0) r.Skip(); else children[index].Read(ref r, flat, offset + offsets[index], scalar, o); + if (index < 0) + { + if (o.UnmappedMemberHandling == JsonUnmappedMemberHandling.Disallow) + throw new JsonException("The JSON object has a property that is not a component."); + r.Skip(); + continue; + } + + var slice = flat.Slice(offsets[index], children[index].Count); + var bit = 1 << index; + if ((seen & bit) != 0) + { + if (!DuplicatesAllowed(o)) throw new JsonException($"The property '{names[index]}' appears more than once."); + defaults.Slice(offsets[index], slice.Length).CopyTo(slice); + } + + seen |= bit; + children[index].Read(ref r, slice, defaults.Slice(offsets[index], slice.Length), scalar, o); } } + + private int IndexOf(ref Utf8JsonReader r, bool ignoreCase) + { + if (!ignoreCase) + { + for (var i = 0; i < utf8Names.Length; i++) + if (r.ValueTextEquals(utf8Names[i])) return i; + return -1; + } + + var name = r.GetString()!; + for (var i = 0; i < names.Length; i++) + if (string.Equals(names[i], name, StringComparison.OrdinalIgnoreCase)) return i; + return -1; + } } - private sealed class Rows : Node + private sealed class Rows : Node where S : unmanaged { private readonly Node row; private readonly int rows; @@ -358,81 +362,98 @@ public static class CultMathJson Count = rows * row.Count; } - public override void Write(Utf8JsonWriter w, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + public override void Write(Utf8JsonWriter w, ReadOnlySpan flat, Scalar scalar, JsonSerializerOptions o) { w.WriteStartArray(); - for (var i = 0; i < rows; i++) row.Write(w, flat, offset + (i * row.Count), scalar, o); + for (var i = 0; i < rows; i++) row.Write(w, flat.Slice(i * row.Count, row.Count), scalar, o); w.WriteEndArray(); } - public override void Read(ref Utf8JsonReader r, S[] flat, int offset, Scalar scalar, JsonSerializerOptions o) + public override void Read(ref Utf8JsonReader r, scoped Span flat, scoped ReadOnlySpan defaults, Scalar scalar, JsonSerializerOptions o) { if (r.TokenType != JsonTokenType.StartArray) throw new JsonException($"Expected a JSON array of {rows} rows, found {r.TokenType}."); for (var i = 0; i < rows; i++) { if (!r.Read() || r.TokenType == JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows."); - row.Read(ref r, flat, offset + (i * row.Count), scalar, o); + row.Read(ref r, flat.Slice(i * row.Count, row.Count), defaults.Slice(i * row.Count, row.Count), scalar, o); } if (!r.Read() || r.TokenType != JsonTokenType.EndArray) throw new JsonException($"Expected {rows} rows."); } } - private sealed class Shape : JsonConverter + private delegate T Make(ReadOnlySpan components) where S : unmanaged; + private delegate void Flatten(T value, Span components) where S : unmanaged; + + private sealed class Shape : JsonConverter where S : unmanaged { private readonly Scalar scalar; private readonly Node root; - private readonly Func make; - private readonly Action flatten; + private readonly S[] defaults; + private readonly Make make; + private readonly Flatten flatten; - public Shape(Scalar scalar, Node root, Func make, Action flatten) + public Shape(Scalar scalar, Node root, Make make, Flatten flatten, S[]? initial) { this.scalar = scalar; this.root = root; this.make = make; this.flatten = flatten; + defaults = initial ?? new S[root.Count]; } public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var flat = scalar.Defaults(root.Count); - root.Read(ref reader, flat, 0, scalar, options); + Span flat = stackalloc S[root.Count]; + defaults.CopyTo(flat); + root.Read(ref reader, flat, defaults, scalar, options); return make(flat); } public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { - var flat = new S[root.Count]; + Span flat = stackalloc S[root.Count]; flatten(value, flat); - root.Write(writer, flat, 0, scalar, options); + root.Write(writer, flat, scalar, options); } public override T ReadAsPropertyName(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) { - var parts = (reader.GetString() ?? throw new JsonException("A dictionary key cannot be null.")).Split(','); + var text = reader.GetString() ?? throw new JsonException("A dictionary key cannot be null."); + var parts = text.Split(','); if (parts.Length != root.Count) throw new JsonException($"A {typeof(T).Name} key has {root.Count} comma-separated components."); - var flat = new S[root.Count]; - for (var i = 0; i < flat.Length; i++) flat[i] = scalar.Parse(parts[i], options); - return make(flat); + Span flat = stackalloc S[root.Count]; + for (var i = 0; i < parts.Length; i++) flat[i] = scalar.ParseKey(parts[i], options); + var value = make(flat); + if (Spell(flat) != text) throw new JsonException($"'{text}' is not the canonical spelling of a {typeof(T).Name} key."); + return value; } public override void WriteAsPropertyName(Utf8JsonWriter writer, T value, JsonSerializerOptions options) { - var flat = new S[root.Count]; + Span flat = stackalloc S[root.Count]; flatten(value, flat); - writer.WritePropertyName(string.Join(",", Array.ConvertAll(flat, c => scalar.Text(c, options)))); + for (var i = 0; i < flat.Length; i++) scalar.Text(flat[i], options); + writer.WritePropertyName(Spell(flat)); + } + + private string Spell(ReadOnlySpan flat) + { + var parts = new string[flat.Length]; + for (var i = 0; i < parts.Length; i++) parts[i] = scalar.Spelling(flat[i]); + return string.Join(",", parts); } } - private static Node Val() => new Leaf(); + private static Node Val() where S : unmanaged => new Leaf(); - private static Node Xy() => new Fields(("x", Val()), ("y", Val())); - private static Node Xyz() => new Fields(("x", Val()), ("y", Val()), ("z", Val())); - private static Node Xyzw() => new Fields(("x", Val()), ("y", Val()), ("z", Val()), ("w", Val())); + private static Node Xy() where S : unmanaged => new Fields(("x", Val()), ("y", Val())); + private static Node Xyz() where S : unmanaged => new Fields(("x", Val()), ("y", Val()), ("z", Val())); + private static Node Xyzw() where S : unmanaged => new Fields(("x", Val()), ("y", Val()), ("z", Val()), ("w", Val())); private static KeyValuePair Add( - Scalar scalar, Node root, Func make, Action flatten) => - new(typeof(T), new Shape(scalar, root, make, flatten)); + Scalar scalar, Node root, Make make, Flatten flatten, S[]? initial = null) where S : unmanaged => + new(typeof(T), new Shape(scalar, root, make, flatten, initial)); private static readonly Dictionary Converters = Build(); @@ -455,7 +476,8 @@ public static class CultMathJson Add(BoolScalar.Instance, Xyzw(), a => new bool4(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.x; a[1] = v.y; a[2] = v.z; a[3] = v.w; }), Add(ByteScalar.Instance, new Fields(("r", Val()), ("g", Val()), ("b", Val()), ("a", Val())), - a => new Color32(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.r; a[1] = v.g; a[2] = v.b; a[3] = v.a; }), + a => new Color32(a[0], a[1], a[2], a[3]), (v, a) => { a[0] = v.r; a[1] = v.g; a[2] = v.b; a[3] = v.a; }, + initial: new byte[] { 0, 0, 0, 255 }), Add(UIntScalar.Instance, new Fields(("state", Val())), a => new Random { state = a[0] }, (v, a) => a[0] = v.state), diff --git a/src/GameCult.Caching.MessagePack/README.md b/src/GameCult.Caching.MessagePack/README.md index 27e3ea57..fdf91ab2 100644 --- a/src/GameCult.Caching.MessagePack/README.md +++ b/src/GameCult.Caching.MessagePack/README.md @@ -65,14 +65,22 @@ in the default chain) and one JSON shape (`CultMathJson`, added to a consumer's options with `new JsonSerializerOptions().AddCultMathConverters()`; CultLib owns no JSON options of its own). MessagePack decoding skips extra elements, leaves missing ones at zero (`Color32` alpha at 255) and refuses nil. JSON reading -skips unknown properties, leaves missing ones at zero, honours -`PropertyNameCaseInsensitive`, and throws `JsonException` for a wrong token or an -out-of-range number (a float beyond `float.MaxValue` is refused, not read as -infinity). Numbers follow `JsonSerializerOptions.NumberHandling`: NaN and the -infinities need `AllowNamedFloatingPointLiterals` (`"NaN"`, `"Infinity"`, -`"-Infinity"`). Every type is also a dictionary key: its flattened components -joined by commas, invariant culture (`"1,2"`, `"true,false"`, matrices row by -row, `rect` as `"minX,minY,maxX,maxY"`). A consumer resolver or converter +skips unknown properties (`UnmappedMemberHandling.Disallow` refuses them), leaves +missing ones at zero, honours `PropertyNameCaseInsensitive`, lets a repeated +property replace the whole earlier value (refused when `AllowDuplicateProperties` +is false), and throws `JsonException` for a wrong token or an out-of-range number. +Numbers follow `JsonSerializerOptions.NumberHandling` as System.Text.Json's own +float does: `"NaN"`, `"Infinity"` and `"-Infinity"` are written under +`AllowNamedFloatingPointLiterals` or `WriteAsString` and read under +`AllowNamedFloatingPointLiterals` or `AllowReadingFromString`; quoted numbers read +under `AllowReadingFromString` with no surrounding whitespace. +Every type is also a dictionary key: its flattened components joined by commas, +invariant culture (`"1,2"`, `"true,false"`, matrices row by row, `rect` as +`"minX,minY,maxX,maxY"`), and only that exact spelling reads back. Two intended +divergences from System.Text.Json: a bare number beyond a component's range +(`1e40` as a float) is refused where System.Text.Json reads infinity, and a +refusal on write is a `JsonException` where System.Text.Json throws +`ArgumentException`. A consumer resolver or converter registered ahead of these wins. | Type | MessagePack | JSON | diff --git a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs index bf8444f9..87e5b742 100644 --- a/tests/GameCult.Caching.Tests/CultMathJsonTests.cs +++ b/tests/GameCult.Caching.Tests/CultMathJsonTests.cs @@ -1,6 +1,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; @@ -225,6 +226,190 @@ namespace GameCult.Caching.Tests return JsonDocument.Parse(json).RootElement.EnumerateObject().Single().Name; } + private const JsonNumberHandling ReadStrings = JsonNumberHandling.AllowReadingFromString; + private const JsonNumberHandling WriteStrings = JsonNumberHandling.WriteAsString; + + // Every row was measured against System.Text.Json's own float. + [Test] + public void NamedLiterals_FollowTheSameFlagsAsSystemTextJsonFloat() + { + foreach (var handling in new[] { Named, ReadStrings, ReadStrings | Named }) + { + JsonSerializer.Deserialize("""{"x":"NaN","y":"-Infinity"}""", With(handling))!.y.Should().Be(float.NegativeInfinity, handling.ToString()); + } + + foreach (var handling in new[] { JsonNumberHandling.Strict, WriteStrings }) + { + Action read = () => JsonSerializer.Deserialize("""{"x":"NaN"}""", With(handling)); + read.Should().Throw(handling.ToString()); + } + + foreach (var handling in new[] { Named, WriteStrings, WriteStrings | Named }) + { + JsonSerializer.Serialize(new float2(float.NaN, float.PositiveInfinity), With(handling)) + .Should().Be("""{"x":"NaN","y":"Infinity"}""", handling.ToString()); + } + + foreach (var handling in new[] { JsonNumberHandling.Strict, ReadStrings }) + { + Action write = () => JsonSerializer.Serialize(new float2(float.NaN, 0f), With(handling)); + write.Should().Throw(handling.ToString()); + } + + JsonSerializer.Serialize(new float2(1f, float.NaN), With(WriteStrings)).Should().Be("""{"x":"1","y":"NaN"}"""); + JsonSerializer.Deserialize("""{"z":"Infinity"}""", With(ReadStrings))!.z.Should().Be(double.PositiveInfinity); + JsonSerializer.Serialize(new double2(double.NegativeInfinity, 2d), With(WriteStrings)).Should().Be("""{"x":"-Infinity","y":"2"}"""); + } + + [Test] + public void QuotedNumbers_RefuseWhitespace() + { + var options = With(ReadStrings); + foreach (var json in new[] { """{"x":" 1.5"}""", """{"x":"1.5 "}""", """{"x":"\t1"}""" }) + { + Action floatRead = () => JsonSerializer.Deserialize(json, options); + floatRead.Should().Throw(json); + } + + Action doubleRead = () => JsonSerializer.Deserialize("""{"y":" 2"}""", options); + doubleRead.Should().Throw(); + Action intRead = () => JsonSerializer.Deserialize("""{"x":" 1"}""", options); + intRead.Should().Throw(); + Action padded = () => JsonSerializer.Deserialize("""{"x":" NaN"}""", With(ReadStrings | Named)); + padded.Should().Throw(); + } + + [Test] + public void UnknownProperties_AreSkippedWhateverTheyContain() + { + var expected = new float3(1f, 2f, 3f); + foreach (var json in new[] + { + """{"u":{"a":[1,{"b":2}]},"x":1,"y":2,"z":3}""", + """{"x":1,"u":[[1],[2,[3]]],"y":2,"v":{"a":{}},"z":3}""", + """{"x":1,"y":2,"z":3,"u":{"a":{"b":[]}}}""", + """{"x":1,"y":2,"z":3,"u":[{"a":[{}]}]}""", + }) + { + Parse(json).Should().Be(expected, json); + } + + Parse("""{"min":{"q":[{}],"x":1,"y":2},"skip":{"a":[1]},"max":{"x":3,"y":4,"q":{"a":[]}}}""") + .Should().Be(new rect(1f, 2f, 3f, 4f)); + Parse("""[{"x":1,"u":[1],"y":2},{"y":4,"u":{"a":1},"x":3}]""").Should().Be(new float2x2(1f, 2f, 3f, 4f)); + } + + [Test] + public void UnmappedMemberHandling_Disallow_RefusesUnknownProperties() + { + var options = new JsonSerializerOptions { UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow }.AddCultMathConverters(); + Action flat = () => JsonSerializer.Deserialize("""{"x":1,"z":3}""", options); + flat.Should().Throw(); + Action nested = () => JsonSerializer.Deserialize("""{"min":{"x":1,"q":1},"max":{}}""", options); + nested.Should().Throw(); + JsonSerializer.Deserialize("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f)); + } + + [Test] + public void DuplicateProperties_ReplaceTheWholeValue() + { + Parse("""{"x":1,"x":2,"y":5}""").Should().Be(new float2(2f, 5f)); + Parse("""{"min":{"x":1,"y":2},"min":{"x":3},"max":{"x":4,"y":5}}""").Should().Be(new rect(3f, 0f, 4f, 5f)); + Parse("""{"a":9,"a":1}""").Should().Be(new Color32(0, 0, 0, 1)); + Parse("""{"r":3,"r":4}""").Should().Be(new Color32(4, 0, 0, 255)); + Parse("""{"nearest":{"x":1,"y":2},"nearest":{"z":3},"id":1}""") + .Should().Be(new CultCellular(new float4(0f, 0f, 3f, 0f), float4.zero, 1f)); + } + + [Test] + public void AllowDuplicatePropertiesFalse_RefusesRepeats() + { + var property = typeof(JsonSerializerOptions).GetProperty("AllowDuplicateProperties"); + Assert.That(property, Is.Not.Null, "the test runtime's System.Text.Json has AllowDuplicateProperties"); + var options = new JsonSerializerOptions().AddCultMathConverters(); + property!.SetValue(options, false); + + Action flat = () => JsonSerializer.Deserialize("""{"x":1,"x":2}""", options); + flat.Should().Throw(); + Action nested = () => JsonSerializer.Deserialize("""{"min":{"x":1,"x":2}}""", options); + nested.Should().Throw(); + JsonSerializer.Deserialize("""{"x":1,"y":2}""", options).Should().Be(new float2(1f, 2f)); + } + + [Test] + public void DictionaryKeys_ReadOnlyTheCanonicalSpelling() + { + foreach (var key in new[] { "1.0,2", "+1,2", " 1,2", "1, 2", "01,2", "1,2 ", "1e0,2", "1,2,", ",1" }) + { + Action read = () => Parse>($$"""{"{{key}}":1}"""); + read.Should().Throw(key); + } + + foreach (var key in new[] { "01,2", "+1,2", "1,-0" }) + { + Action read = () => Parse>($$"""{"{{key}}":1}"""); + read.Should().Throw(key); + } + + Action bools = () => Parse>("""{"True,false":1}"""); + bools.Should().Throw(); + Parse>("""{"1,2":1}""").Should().ContainKey(new float2(1f, 2f)); + } + + [Test] + public void NegativeZero_IsADistinctKey() + { + var negative = new float2(-0f, 0f); + KeyOf(negative).Should().Be("-0,0"); + KeyOf(float2.zero).Should().Be("0,0"); + KeyOf(new double2(0d, -0d)).Should().Be("0,-0"); + + var parsed = Parse>("""{"-0,0":1}""").Keys.Single(); + float.IsNegative(parsed.x).Should().BeTrue(); + var positive = Parse>("""{"0,0":1}""").Keys.Single(); + float.IsNegative(positive.x).Should().BeFalse(); + } + + [Test] + public void Culture_DoesNotChangeKeysOrValues() + { + var german = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + german.NumberFormat.NumberDecimalSeparator = ","; + german.NumberFormat.NumberGroupSeparator = "."; + german.NumberFormat.NegativeSign = "−"; + german.NumberFormat.PositiveSign = "➕"; + var swedish = (CultureInfo)CultureInfo.InvariantCulture.Clone(); + swedish.NumberFormat.NegativeSign = "−"; + swedish.NumberFormat.NumberDecimalSeparator = ","; + + var original = CultureInfo.CurrentCulture; + try + { + foreach (var culture in new[] { german, swedish }) + { + CultureInfo.CurrentCulture = culture; + var options = With(ReadStrings | WriteStrings | Named); + + KeyOf(new float2(-1.5f, 2.25f)).Should().Be("-1.5,2.25"); + KeyOf(new double3(-1e-7, 1234.5, -0d)).Should().Be("-1E-07,1234.5,-0"); + KeyOf(new int2(-3, 4)).Should().Be("-3,4"); + KeyOf(new float2(float.NaN, float.NegativeInfinity), Named).Should().Be("NaN,-Infinity"); + Parse>("""{"-1.5,2.25":1}""").Keys.Single().Should().Be(new float2(-1.5f, 2.25f)); + Parse>("""{"-3,4":1}""").Keys.Single().Should().Be(new int2(-3, 4)); + + JsonSerializer.Serialize(new float2(-1.5f, 2.25f), options).Should().Be("""{"x":"-1.5","y":"2.25"}"""); + JsonSerializer.Deserialize("""{"x":"-1.5","y":"2.25"}""", options).Should().Be(new float2(-1.5f, 2.25f)); + JsonSerializer.Serialize(new int2(-3, 4), options).Should().Be("""{"x":"-3","y":"4"}"""); + JsonSerializer.Deserialize("""{"x":"-3","y":"4"}""", options).Should().Be(new int2(-3, 4)); + Json(new float2(-1.5f, 2.25f)).Should().Be("""{"x":-1.5,"y":2.25}"""); + } + } + finally + { + CultureInfo.CurrentCulture = original; + } + } + private static void RoundTrip(T value) => Parse(Json(value)).Should().Be(value); } }