File: System\Text\Json\Serialization\Converters\Value\EnumConverter.cs
Web Access
Project: src\src\libraries\System.Text.Json\src\System.Text.Json.csproj (System.Text.Json)
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.
 
using System.Buffers;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Encodings.Web;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
 
namespace System.Text.Json.Serialization.Converters
{
    internal sealed class EnumConverter<T> : JsonPrimitiveConverter<T> // Do not rename FQN (legacy schema generation)
        where T : struct, Enum
    {
        private static readonly TypeCode s_enumTypeCode = Type.GetTypeCode(typeof(T));
 
        // Odd type codes are conveniently signed types (for enum backing types).
        private static readonly bool s_isSignedEnum = ((int)s_enumTypeCode % 2) == 1;
        private static readonly bool s_isFlagsEnum = typeof(T).IsDefined(typeof(FlagsAttribute), inherit: false);
 
        private readonly EnumConverterOptions _converterOptions; // Do not rename (legacy schema generation)
        private readonly JsonNamingPolicy? _namingPolicy; // Do not rename (legacy schema generation)
 
        /// <summary>
        /// Stores metadata for the individual fields declared on the enum.
        /// </summary>
        private readonly EnumFieldInfo[] _enumFieldInfo;
 
        /// <summary>
        /// Defines a case-insensitive index of enum field names to their metadata.
        /// In case of casing conflicts, extra fields are appended to a list in the value.
        /// This is the main dictionary that is queried by the enum parser implementation.
        /// </summary>
        private readonly Dictionary<string, EnumFieldInfo> _enumFieldInfoIndex;
 
        /// <summary>
        /// Holds a cache from enum value to formatted UTF-8 text including flag combinations.
        /// <see cref="ulong"/> is as the key used rather than <typeparamref name="T"/> given measurements that
        /// show private memory savings when a single type is used https://github.com/dotnet/runtime/pull/36726#discussion_r428868336.
        /// </summary>
        private readonly ConcurrentDictionary<ulong, JsonEncodedText> _nameCacheForWriting;
 
        /// <summary>
        /// Holds a mapping from input text to enum values including flag combinations and alternative casings.
        /// </summary>
        private readonly ConcurrentDictionary<string, ulong> _nameCacheForReading;
 
        // This is used to prevent flooding the cache due to exponential bitwise combinations of flags.
        // Since multiple threads can add to the cache, a few more values might be added.
        private const int NameCacheSizeSoftLimit = 64;
 
        public EnumConverter(EnumConverterOptions converterOptions, JsonNamingPolicy? namingPolicy, JsonSerializerOptions options)
        {
            Debug.Assert(EnumConverterFactory.Helpers.IsSupportedTypeCode(s_enumTypeCode));
 
            _converterOptions = converterOptions;
            _namingPolicy = namingPolicy;
            _enumFieldInfo = ResolveEnumFields(namingPolicy);
            _enumFieldInfoIndex = new(StringComparer.OrdinalIgnoreCase);
 
            _nameCacheForWriting = new();
            _nameCacheForReading = new(StringComparer.Ordinal);
 
            JavaScriptEncoder? encoder = options.Encoder;
            foreach (EnumFieldInfo fieldInfo in _enumFieldInfo)
            {
                AddToEnumFieldIndex(fieldInfo);
 
                JsonEncodedText encodedName = JsonEncodedText.Encode(fieldInfo.JsonName, encoder);
                _nameCacheForWriting.TryAdd(fieldInfo.Key, encodedName);
                _nameCacheForReading.TryAdd(fieldInfo.JsonName, fieldInfo.Key);
            }
 
            if (namingPolicy != null)
            {
                // Additionally populate the field index with the default names of fields that used a naming policy.
                // This is done to preserve backward compat: default names should still be recognized by the parser.
                foreach (EnumFieldInfo fieldInfo in _enumFieldInfo)
                {
                    if (fieldInfo.Kind is EnumFieldNameKind.NamingPolicy)
                    {
                        AddToEnumFieldIndex(new EnumFieldInfo(fieldInfo.Key, EnumFieldNameKind.Default, fieldInfo.OriginalName, fieldInfo.OriginalName));
                    }
                }
            }
 
            void AddToEnumFieldIndex(EnumFieldInfo fieldInfo)
            {
                if (!_enumFieldInfoIndex.TryAdd(fieldInfo.JsonName, fieldInfo))
                {
                    // We have a casing conflict, append field to the existing entry.
                    EnumFieldInfo existingFieldInfo = _enumFieldInfoIndex[fieldInfo.JsonName];
                    existingFieldInfo.AppendConflictingField(fieldInfo);
                }
            }
        }
 
        public override T Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            switch (reader.TokenType)
            {
                case JsonTokenType.String when (_converterOptions & EnumConverterOptions.AllowStrings) != 0:
                    if (TryParseEnumFromString(ref reader, out T result))
                    {
                        return result;
                    }
                    break;
 
                case JsonTokenType.Number when (_converterOptions & EnumConverterOptions.AllowNumbers) != 0:
                    switch (s_enumTypeCode)
                    {
                        case TypeCode.Int32 when reader.TryGetInt32(out int int32): return Unsafe.As<int, T>(ref int32);
                        case TypeCode.UInt32 when reader.TryGetUInt32(out uint uint32): return Unsafe.As<uint, T>(ref uint32);
                        case TypeCode.Int64 when reader.TryGetInt64(out long int64): return Unsafe.As<long, T>(ref int64);
                        case TypeCode.UInt64 when reader.TryGetUInt64(out ulong uint64): return Unsafe.As<ulong, T>(ref uint64);
                        case TypeCode.Byte when reader.TryGetByte(out byte ubyte8): return Unsafe.As<byte, T>(ref ubyte8);
                        case TypeCode.SByte when reader.TryGetSByte(out sbyte byte8): return Unsafe.As<sbyte, T>(ref byte8);
                        case TypeCode.Int16 when reader.TryGetInt16(out short int16): return Unsafe.As<short, T>(ref int16);
                        case TypeCode.UInt16 when reader.TryGetUInt16(out ushort uint16): return Unsafe.As<ushort, T>(ref uint16);
                    }
                    break;
            }
 
            ThrowHelper.ThrowJsonException();
            return default;
        }
 
        public override void Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options)
        {
            EnumConverterOptions converterOptions = _converterOptions;
            if ((converterOptions & EnumConverterOptions.AllowStrings) != 0)
            {
                ulong key = ConvertToUInt64(value);
 
                if (_nameCacheForWriting.TryGetValue(key, out JsonEncodedText formatted))
                {
                    writer.WriteStringValue(formatted);
                    return;
                }
 
                if (IsDefinedValueOrCombinationOfValues(key))
                {
                    Debug.Assert(s_isFlagsEnum, "Should only be entered by flags enums.");
                    string stringValue = FormatEnumAsString(key, value, dictionaryKeyPolicy: null);
                    if (_nameCacheForWriting.Count < NameCacheSizeSoftLimit)
                    {
                        formatted = JsonEncodedText.Encode(stringValue, options.Encoder);
                        writer.WriteStringValue(formatted);
                        _nameCacheForWriting.TryAdd(key, formatted);
                    }
                    else
                    {
                        // We also do not create a JsonEncodedText instance here because passing the string
                        // directly to the writer is cheaper than creating one and not caching it for reuse.
                        writer.WriteStringValue(stringValue);
                    }
 
                    return;
                }
            }
 
            if ((converterOptions & EnumConverterOptions.AllowNumbers) == 0)
            {
                ThrowHelper.ThrowJsonException();
            }
 
            if (s_isSignedEnum)
            {
                writer.WriteNumberValue(ConvertToInt64(value));
            }
            else
            {
                writer.WriteNumberValue(ConvertToUInt64(value));
            }
        }
 
        internal override T ReadAsPropertyNameCore(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
        {
            // NB JsonSerializerOptions.DictionaryKeyPolicy is ignored on deserialization.
            // This is true for all converters that implement dictionary key serialization.
 
            if (!TryParseEnumFromString(ref reader, out T result))
            {
                ThrowHelper.ThrowJsonException();
            }
 
            return result;
        }
 
        internal override void WriteAsPropertyNameCore(Utf8JsonWriter writer, T value, JsonSerializerOptions options, bool isWritingExtensionDataProperty)
        {
            JsonNamingPolicy? dictionaryKeyPolicy = options.DictionaryKeyPolicy is { } dkp && dkp != _namingPolicy ? dkp : null;
            ulong key = ConvertToUInt64(value);
 
            if (dictionaryKeyPolicy is null && _nameCacheForWriting.TryGetValue(key, out JsonEncodedText formatted))
            {
                writer.WritePropertyName(formatted);
                return;
            }
 
            if (IsDefinedValueOrCombinationOfValues(key))
            {
                Debug.Assert(s_isFlagsEnum || dictionaryKeyPolicy != null, "Should only be entered by flags enums or dictionary key policy.");
                string stringValue = FormatEnumAsString(key, value, dictionaryKeyPolicy);
                if (dictionaryKeyPolicy is null && _nameCacheForWriting.Count < NameCacheSizeSoftLimit)
                {
                    // Only attempt to cache if there is no dictionary key policy.
                    formatted = JsonEncodedText.Encode(stringValue, options.Encoder);
                    writer.WritePropertyName(formatted);
                    _nameCacheForWriting.TryAdd(key, formatted);
                }
                else
                {
                    // We also do not create a JsonEncodedText instance here because passing the string
                    // directly to the writer is cheaper than creating one and not caching it for reuse.
                    writer.WritePropertyName(stringValue);
                }
 
                return;
            }
 
            if (s_isSignedEnum)
            {
                writer.WritePropertyName(ConvertToInt64(value));
            }
            else
            {
                writer.WritePropertyName(key);
            }
        }
 
        private bool TryParseEnumFromString(ref Utf8JsonReader reader, out T result)
        {
            Debug.Assert(reader.TokenType is JsonTokenType.String or JsonTokenType.PropertyName);
 
            int bufferLength = reader.ValueLength;
            char[]? rentedBuffer = null;
            bool success;
 
            Span<char> charBuffer = bufferLength <= JsonConstants.StackallocCharThreshold
                ? stackalloc char[JsonConstants.StackallocCharThreshold]
                : (rentedBuffer = ArrayPool<char>.Shared.Rent(bufferLength));
 
            int charsWritten = reader.CopyString(charBuffer);
            charBuffer = charBuffer.Slice(0, charsWritten);
#if NET9_0_OR_GREATER
            ReadOnlySpan<char> source = charBuffer.Trim();
            ConcurrentDictionary<string, ulong>.AlternateLookup<ReadOnlySpan<char>> lookup = _nameCacheForReading.GetAlternateLookup<ReadOnlySpan<char>>();
#else
            string source = ((ReadOnlySpan<char>)charBuffer).Trim().ToString();
            ConcurrentDictionary<string, ulong> lookup = _nameCacheForReading;
#endif
            if (lookup.TryGetValue(source, out ulong key))
            {
                result = ConvertFromUInt64(key);
                success = true;
                goto End;
            }
 
            if (JsonHelpers.IntegerRegex.IsMatch(source))
            {
                // We found an integer that is not an enum field name.
                if ((_converterOptions & EnumConverterOptions.AllowNumbers) != 0)
                {
                    success = Enum.TryParse(source, out result);
                }
                else
                {
                    result = default;
                    success = false;
                }
            }
            else
            {
                success = TryParseNamedEnum(source, out result);
            }
 
            if (success && _nameCacheForReading.Count < NameCacheSizeSoftLimit)
            {
                lookup.TryAdd(source, ConvertToUInt64(result));
            }
 
        End:
            if (rentedBuffer != null)
            {
                charBuffer.Clear();
                ArrayPool<char>.Shared.Return(rentedBuffer);
            }
 
            return success;
        }
 
        private bool TryParseNamedEnum(
#if NET9_0_OR_GREATER
            ReadOnlySpan<char> source,
#else
            string source,
#endif
            out T result)
        {
#if NET9_0_OR_GREATER
            Dictionary<string, EnumFieldInfo>.AlternateLookup<ReadOnlySpan<char>> lookup = _enumFieldInfoIndex.GetAlternateLookup<ReadOnlySpan<char>>();
            ReadOnlySpan<char> rest = source;
#else
            Dictionary<string, EnumFieldInfo> lookup = _enumFieldInfoIndex;
            ReadOnlySpan<char> rest = source.AsSpan();
#endif
            ulong key = 0;
 
            do
            {
                ReadOnlySpan<char> next;
                int i = rest.IndexOf(',');
                if (i == -1)
                {
                    next = rest;
                    rest = default;
                }
                else
                {
                    next = rest.Slice(0, i).TrimEnd();
                    rest = rest.Slice(i + 1).TrimStart();
                }
 
                if (lookup.TryGetValue(
#if NET9_0_OR_GREATER
                        next,
#else
                        next.ToString(),
#endif
                        out EnumFieldInfo? firstResult) &&
                    firstResult.GetMatchingField(next) is EnumFieldInfo match)
                {
                    key |= match.Key;
                    continue;
                }
 
                result = default;
                return false;
 
            } while (!rest.IsEmpty);
 
            result = ConvertFromUInt64(key);
            return true;
        }
 
        private static ulong ConvertToUInt64(T value)
        {
            switch (s_enumTypeCode)
            {
                case TypeCode.Int32 or TypeCode.UInt32: return Unsafe.As<T, uint>(ref value);
                case TypeCode.Int64 or TypeCode.UInt64: return Unsafe.As<T, ulong>(ref value);
                case TypeCode.Int16 or TypeCode.UInt16: return Unsafe.As<T, ushort>(ref value);
                default:
                    Debug.Assert(s_enumTypeCode is TypeCode.SByte or TypeCode.Byte);
                    return Unsafe.As<T, byte>(ref value);
            };
        }
 
        private static long ConvertToInt64(T value)
        {
            Debug.Assert(s_isSignedEnum);
            switch (s_enumTypeCode)
            {
                case TypeCode.Int32: return Unsafe.As<T, int>(ref value);
                case TypeCode.Int64: return Unsafe.As<T, long>(ref value);
                case TypeCode.Int16: return Unsafe.As<T, short>(ref value);
                default:
                    Debug.Assert(s_enumTypeCode is TypeCode.SByte);
                    return Unsafe.As<T, sbyte>(ref value);
            };
        }
 
        private static T ConvertFromUInt64(ulong value)
        {
            switch (s_enumTypeCode)
            {
                case TypeCode.Int32 or TypeCode.UInt32:
                    uint uintValue = (uint)value;
                    return Unsafe.As<uint, T>(ref uintValue);
 
                case TypeCode.Int64 or TypeCode.UInt64:
                    ulong ulongValue = value;
                    return Unsafe.As<ulong, T>(ref ulongValue);
 
                case TypeCode.Int16 or TypeCode.UInt16:
                    ushort ushortValue = (ushort)value;
                    return Unsafe.As<ushort, T>(ref ushortValue);
 
                default:
                    Debug.Assert(s_enumTypeCode is TypeCode.SByte or TypeCode.Byte);
                    byte byteValue = (byte)value;
                    return Unsafe.As<byte, T>(ref byteValue);
            };
        }
 
        /// <summary>
        /// Attempt to format the enum value as a comma-separated string of flag values, or returns false if not a valid flag combination.
        /// </summary>
        private string FormatEnumAsString(ulong key, T value, JsonNamingPolicy? dictionaryKeyPolicy)
        {
            Debug.Assert(IsDefinedValueOrCombinationOfValues(key), "must only be invoked against valid enum values.");
            Debug.Assert(
                s_isFlagsEnum || (dictionaryKeyPolicy is not null && Enum.IsDefined(typeof(T), value)),
                "must either be a flag type or computing a dictionary key policy.");
 
            if (s_isFlagsEnum)
            {
                using ValueStringBuilder sb = new(stackalloc char[JsonConstants.StackallocCharThreshold]);
                ulong remainingBits = key;
 
                foreach (EnumFieldInfo enumField in _enumFieldInfo)
                {
                    ulong fieldKey = enumField.Key;
                    if (fieldKey == 0 ? key == 0 : (remainingBits & fieldKey) == fieldKey)
                    {
                        remainingBits &= ~fieldKey;
                        string name = dictionaryKeyPolicy is not null
                            ? ResolveAndValidateJsonName(enumField.OriginalName, dictionaryKeyPolicy, enumField.Kind)
                            : enumField.JsonName;
 
                        if (sb.Length > 0)
                        {
                            sb.Append(", ");
                        }
 
                        sb.Append(name);
 
                        if (remainingBits == 0)
                        {
                            break;
                        }
                    }
                }
 
                Debug.Assert(remainingBits == 0 && sb.Length > 0, "unexpected remaining bits or empty string.");
                return sb.ToString();
            }
            else
            {
                Debug.Assert(dictionaryKeyPolicy != null);
 
                foreach (EnumFieldInfo enumField in _enumFieldInfo)
                {
                    // Search for an exact match and apply the key policy.
                    if (enumField.Key == key)
                    {
                        return ResolveAndValidateJsonName(enumField.OriginalName, dictionaryKeyPolicy, enumField.Kind);
                    }
                }
 
                Debug.Fail("should not have been reached.");
                return null;
            }
        }
 
        private bool IsDefinedValueOrCombinationOfValues(ulong key)
        {
            if (s_isFlagsEnum)
            {
                ulong remainingBits = key;
 
                foreach (EnumFieldInfo fieldInfo in _enumFieldInfo)
                {
                    ulong fieldKey = fieldInfo.Key;
                    if (fieldKey == 0 ? key == 0 : (remainingBits & fieldKey) == fieldKey)
                    {
                        remainingBits &= ~fieldKey;
 
                        if (remainingBits == 0)
                        {
                            return true;
                        }
                    }
                }
 
                return false;
            }
            else
            {
                foreach (EnumFieldInfo fieldInfo in _enumFieldInfo)
                {
                    if (fieldInfo.Key == key)
                    {
                        return true;
                    }
                }
 
                return false;
            }
        }
 
        internal override JsonSchema? GetSchema(JsonNumberHandling numberHandling)
        {
            if ((_converterOptions & EnumConverterOptions.AllowStrings) != 0)
            {
                // This explicitly ignores the integer component in converters configured as AllowNumbers | AllowStrings
                // which is the default for JsonStringEnumConverter. This sacrifices some precision in the schema for simplicity.
 
                if (s_isFlagsEnum)
                {
                    // Do not report enum values in case of flags.
                    return new() { Type = JsonSchemaType.String };
                }
 
                JsonArray enumValues = [];
                foreach (EnumFieldInfo fieldInfo in _enumFieldInfo)
                {
                    enumValues.Add((JsonNode)fieldInfo.JsonName);
                }
 
                return new() { Enum = enumValues };
            }
 
            return new() { Type = JsonSchemaType.Integer };
        }
 
        private static EnumFieldInfo[] ResolveEnumFields(JsonNamingPolicy? namingPolicy)
        {
#if NET
            string[] names = Enum.GetNames<T>();
            T[] values = Enum.GetValues<T>();
#else
            string[] names = Enum.GetNames(typeof(T));
            T[] values = (T[])Enum.GetValues(typeof(T));
#endif
            Debug.Assert(names.Length == values.Length);
 
            Dictionary<string, string>? enumMemberAttributes = null;
            foreach (FieldInfo field in typeof(T).GetFields(BindingFlags.Public | BindingFlags.Static))
            {
                if (field.GetCustomAttribute<JsonStringEnumMemberNameAttribute>() is { } attribute)
                {
                    (enumMemberAttributes ??= new(StringComparer.Ordinal)).Add(field.Name, attribute.Name);
                }
            }
 
            var enumFields = new EnumFieldInfo[names.Length];
            for (int i = 0; i < names.Length; i++)
            {
                string originalName = names[i];
                T value = values[i];
                ulong key = ConvertToUInt64(value);
                EnumFieldNameKind kind;
 
                if (enumMemberAttributes != null && enumMemberAttributes.TryGetValue(originalName, out string? attributeName))
                {
                    originalName = attributeName;
                    kind = EnumFieldNameKind.Attribute;
                }
                else
                {
                    kind = namingPolicy != null ? EnumFieldNameKind.NamingPolicy : EnumFieldNameKind.Default;
                }
 
                string jsonName = ResolveAndValidateJsonName(originalName, namingPolicy, kind);
                enumFields[i] = new EnumFieldInfo(key, kind, originalName, jsonName);
            }
 
            return enumFields;
        }
 
        private static string ResolveAndValidateJsonName(string name, JsonNamingPolicy? namingPolicy, EnumFieldNameKind kind)
        {
            if (kind is not EnumFieldNameKind.Attribute && namingPolicy is not null)
            {
                // Do not apply a naming policy to names that are explicitly set via attributes.
                // This is consistent with JsonPropertyNameAttribute semantics.
                name = namingPolicy.ConvertName(name);
            }
 
            if (string.IsNullOrEmpty(name) || char.IsWhiteSpace(name[0]) || char.IsWhiteSpace(name[name.Length - 1]) ||
                (s_isFlagsEnum && name.AsSpan().IndexOf(',') >= 0))
            {
                // Reject null or empty strings or strings with leading or trailing whitespace.
                // In the case of flags additionally reject strings containing commas.
                ThrowHelper.ThrowInvalidOperationException_UnsupportedEnumIdentifier(typeof(T), name);
            }
 
            return name;
        }
 
        private sealed class EnumFieldInfo(ulong key, EnumFieldNameKind kind, string originalName, string jsonName)
        {
            private List<EnumFieldInfo>? _conflictingFields;
            public EnumFieldNameKind Kind { get; } = kind;
            public ulong Key { get; } = key;
            public string OriginalName { get; } = originalName;
            public string JsonName { get; } = jsonName;
 
            /// <summary>
            /// Assuming we have field that conflicts with the current up to case sensitivity,
            /// append it to a list of trailing values for use by the enum value parser.
            /// </summary>
            public void AppendConflictingField(EnumFieldInfo other)
            {
                Debug.Assert(JsonName.Equals(other.JsonName, StringComparison.OrdinalIgnoreCase), "The conflicting entry must be equal up to case insensitivity.");
 
                if (Kind is EnumFieldNameKind.Default || JsonName.Equals(other.JsonName, StringComparison.Ordinal))
                {
                    // Silently discard if the preceding entry is the default or has identical name.
                    return;
                }
 
                List<EnumFieldInfo> conflictingFields = _conflictingFields ??= [];
 
                // Walk the existing list to ensure we do not add duplicates.
                foreach (EnumFieldInfo conflictingField in conflictingFields)
                {
                    if (conflictingField.Kind is EnumFieldNameKind.Default || conflictingField.JsonName.Equals(other.JsonName, StringComparison.Ordinal))
                    {
                        return;
                    }
                }
 
                conflictingFields.Add(other);
            }
 
            public EnumFieldInfo? GetMatchingField(ReadOnlySpan<char> input)
            {
                Debug.Assert(input.Equals(JsonName.AsSpan(), StringComparison.OrdinalIgnoreCase), "Must equal the field name up to case insensitivity.");
 
                if (Kind is EnumFieldNameKind.Default || input.SequenceEqual(JsonName.AsSpan()))
                {
                    // Default enum names use case insensitive parsing so are always a match.
                    return this;
                }
 
                if (_conflictingFields is { } conflictingFields)
                {
                    Debug.Assert(conflictingFields.Count > 0);
                    foreach (EnumFieldInfo matchingField in conflictingFields)
                    {
                        if (matchingField.Kind is EnumFieldNameKind.Default || input.SequenceEqual(matchingField.JsonName.AsSpan()))
                        {
                            return matchingField;
                        }
                    }
                }
 
                return null;
            }
        }
 
        private enum EnumFieldNameKind
        {
            Default = 0,
            NamingPolicy = 1,
            Attribute = 2,
        }
    }
}